@calliopelabs/cli 2.3.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.
- package/README.md +17 -0
- package/dist/agents/agent-config-loader.js +1 -1
- package/dist/agents/agent-config-presets.js +13 -13
- package/dist/agents/agent-config-presets.js.map +1 -1
- package/dist/agents/agent-config-types.d.ts +1 -1
- package/dist/agents/agent-config-types.d.ts.map +1 -1
- package/dist/agents/dynamic-tools.d.ts.map +1 -1
- package/dist/agents/dynamic-tools.js +39 -10
- package/dist/agents/dynamic-tools.js.map +1 -1
- package/dist/agents/sdk-backend.js +1 -1
- package/dist/agents/sdk-backend.js.map +1 -1
- package/dist/api-server.d.ts +9 -0
- package/dist/api-server.d.ts.map +1 -1
- package/dist/api-server.js +74 -3
- package/dist/api-server.js.map +1 -1
- package/dist/auto-checkpoint.d.ts.map +1 -1
- package/dist/auto-checkpoint.js +50 -17
- package/dist/auto-checkpoint.js.map +1 -1
- package/dist/auto-compressor.d.ts.map +1 -1
- package/dist/auto-compressor.js +9 -5
- package/dist/auto-compressor.js.map +1 -1
- package/dist/bin.d.ts +8 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +59 -4
- package/dist/bin.js.map +1 -1
- package/dist/branching.d.ts.map +1 -1
- package/dist/branching.js +14 -1
- package/dist/branching.js.map +1 -1
- package/dist/checkpoint.d.ts.map +1 -1
- package/dist/checkpoint.js +13 -1
- package/dist/checkpoint.js.map +1 -1
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/agent.js +19 -3
- package/dist/cli/agent.js.map +1 -1
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +99 -0
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +32 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/types.js +1 -1
- package/dist/cli/types.js.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +42 -4
- package/dist/diff.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +30 -3
- package/dist/errors.js.map +1 -1
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +56 -2
- package/dist/headless.js.map +1 -1
- package/dist/hooks.d.ts +8 -2
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +97 -11
- package/dist/hooks.js.map +1 -1
- package/dist/idle-eviction.d.ts.map +1 -1
- package/dist/idle-eviction.js +8 -1
- package/dist/idle-eviction.js.map +1 -1
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +32 -10
- package/dist/markdown.js.map +1 -1
- package/dist/mcp.d.ts +35 -5
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +186 -12
- package/dist/mcp.js.map +1 -1
- package/dist/model-detection.d.ts +14 -1
- package/dist/model-detection.d.ts.map +1 -1
- package/dist/model-detection.js +307 -114
- package/dist/model-detection.js.map +1 -1
- package/dist/model-router.js +7 -7
- package/dist/model-router.js.map +1 -1
- package/dist/parallel-tools.d.ts +9 -1
- package/dist/parallel-tools.d.ts.map +1 -1
- package/dist/parallel-tools.js +6 -5
- package/dist/parallel-tools.js.map +1 -1
- package/dist/plugins.d.ts +37 -0
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +87 -0
- package/dist/plugins.js.map +1 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +36 -2
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts.map +1 -1
- package/dist/providers/bedrock.js +81 -17
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +2 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +19 -10
- package/dist/providers/types.js.map +1 -1
- package/dist/risk.d.ts.map +1 -1
- package/dist/risk.js +15 -5
- package/dist/risk.js.map +1 -1
- package/dist/sandbox-native.d.ts +1 -0
- package/dist/sandbox-native.d.ts.map +1 -1
- package/dist/sandbox-native.js +37 -5
- package/dist/sandbox-native.js.map +1 -1
- package/dist/scope.d.ts +10 -0
- package/dist/scope.d.ts.map +1 -1
- package/dist/scope.js +75 -15
- package/dist/scope.js.map +1 -1
- package/dist/scuttlebot/client.d.ts +83 -0
- package/dist/scuttlebot/client.d.ts.map +1 -0
- package/dist/scuttlebot/client.js +350 -0
- package/dist/scuttlebot/client.js.map +1 -0
- package/dist/scuttlebot/config.d.ts +28 -0
- package/dist/scuttlebot/config.d.ts.map +1 -0
- package/dist/scuttlebot/config.js +91 -0
- package/dist/scuttlebot/config.js.map +1 -0
- package/dist/scuttlebot/http-client.d.ts +63 -0
- package/dist/scuttlebot/http-client.d.ts.map +1 -0
- package/dist/scuttlebot/http-client.js +124 -0
- package/dist/scuttlebot/http-client.js.map +1 -0
- package/dist/scuttlebot/index.d.ts +13 -0
- package/dist/scuttlebot/index.d.ts.map +1 -0
- package/dist/scuttlebot/index.js +10 -0
- package/dist/scuttlebot/index.js.map +1 -0
- package/dist/scuttlebot/irc-client.d.ts +124 -0
- package/dist/scuttlebot/irc-client.d.ts.map +1 -0
- package/dist/scuttlebot/irc-client.js +599 -0
- package/dist/scuttlebot/irc-client.js.map +1 -0
- package/dist/skills.d.ts +19 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +98 -10
- package/dist/skills.js.map +1 -1
- package/dist/smart-router.js +4 -4
- package/dist/smart-router.js.map +1 -1
- package/dist/storage.d.ts +0 -4
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +81 -5
- package/dist/storage.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +232 -38
- package/dist/tools.js.map +1 -1
- package/dist/trust.d.ts +16 -3
- package/dist/trust.d.ts.map +1 -1
- package/dist/trust.js +23 -4
- package/dist/trust.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -4
- package/dist/types.js.map +1 -1
- package/dist/ui/agent.d.ts +1 -1
- package/dist/ui/agent.d.ts.map +1 -1
- package/dist/ui/agent.js +35 -44
- package/dist/ui/agent.js.map +1 -1
- package/dist/ui/chat-input.d.ts +3 -1
- package/dist/ui/chat-input.d.ts.map +1 -1
- package/dist/ui/chat-input.js +82 -17
- package/dist/ui/chat-input.js.map +1 -1
- package/dist/ui/commands.d.ts +2 -0
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +318 -10
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +236 -46
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/input-utils.d.ts +20 -0
- package/dist/ui/input-utils.d.ts.map +1 -0
- package/dist/ui/input-utils.js +35 -0
- package/dist/ui/input-utils.js.map +1 -0
- package/dist/ui/messages.d.ts +6 -2
- package/dist/ui/messages.d.ts.map +1 -1
- package/dist/ui/messages.js +42 -11
- package/dist/ui/messages.js.map +1 -1
- package/dist/ui/modals.d.ts +21 -1
- package/dist/ui/modals.d.ts.map +1 -1
- package/dist/ui/modals.js +67 -5
- package/dist/ui/modals.js.map +1 -1
- package/dist/ui/status-bar.d.ts +4 -1
- package/dist/ui/status-bar.d.ts.map +1 -1
- package/dist/ui/status-bar.js +12 -1
- package/dist/ui/status-bar.js.map +1 -1
- package/dist/ui/types.d.ts +3 -0
- package/dist/ui/types.d.ts.map +1 -1
- package/package.json +4 -7
- package/dist/completion.d.ts +0 -75
- package/dist/completion.d.ts.map +0 -1
- package/dist/completion.js +0 -234
- package/dist/completion.js.map +0 -1
- package/dist/keyboard.d.ts +0 -57
- package/dist/keyboard.d.ts.map +0 -1
- package/dist/keyboard.js +0 -265
- 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-
|
|
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
|
|
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);
|
|
@@ -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
|
-
*
|
|
742
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
const totalLines =
|
|
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
|
-
|
|
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
|
-
|
|
1286
|
-
const
|
|
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 ${
|
|
1371
|
+
command = `git diff ${quoted}`.trim();
|
|
1294
1372
|
break;
|
|
1295
1373
|
case 'log':
|
|
1296
|
-
command = `git log --oneline -20 ${
|
|
1374
|
+
command = `git log --oneline -20 ${quoted}`.trim();
|
|
1297
1375
|
break;
|
|
1298
1376
|
case 'branch':
|
|
1299
|
-
command = `git branch ${
|
|
1377
|
+
command = `git branch ${quoted}`.trim();
|
|
1300
1378
|
break;
|
|
1301
1379
|
case 'add':
|
|
1302
|
-
command = `git add ${
|
|
1380
|
+
command = tokens.length ? `git add ${quoted}` : 'git add .';
|
|
1303
1381
|
break;
|
|
1304
1382
|
case 'commit':
|
|
1305
|
-
if (!
|
|
1383
|
+
if (!tokens.includes('-m')) {
|
|
1306
1384
|
return 'Error: commit requires -m "message"';
|
|
1307
1385
|
}
|
|
1308
|
-
command = `git commit ${
|
|
1386
|
+
command = `git commit ${quoted}`.trim();
|
|
1309
1387
|
break;
|
|
1310
1388
|
case 'push':
|
|
1311
|
-
command = `git push ${
|
|
1389
|
+
command = `git push ${quoted}`.trim();
|
|
1312
1390
|
break;
|
|
1313
1391
|
case 'pull':
|
|
1314
|
-
command = `git pull ${
|
|
1392
|
+
command = `git pull ${quoted}`.trim();
|
|
1315
1393
|
break;
|
|
1316
1394
|
case 'stash':
|
|
1317
|
-
command = `git stash ${
|
|
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
|
-
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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';
|