Skip to content

Commit 51ca8cf

Browse files
fix: replace shell syntax in run() calls across 8 tools (#172)
git.run() uses execFileSync (no shell) but these tools passed strings containing shell operators (pipes, redirects, &&, ||) that silently broke. Changes per tool: - verify-completion: use execFileSync for tsc/build, readFileSync for package.json, array args for git diff - token-audit: use Node fs for line counting, file size, tail reads; array args for git diff - session-handoff: use which+execFileSync for command detection and gh CLI calls - audit-workspace: array args for git diff, git ls-files for test counting - sharpen-followup: array args for all git commands - scope-work: git ls-files + JS filter/regex instead of shell pipes - enrich-agent-task: git ls-files + JS filtering, readFileSync for file head reads - sequence-tasks: array args for git ls-files + JS slicing Closes #172
1 parent 4637eaf commit 51ca8cf

8 files changed

Lines changed: 144 additions & 65 deletions

src/tools/audit-workspace.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { run } from "../lib/git.js";
3-
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
3+
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
4+
// (fs imports removed — not needed after shell syntax cleanup)
45

56
/** Extract top-level work areas from file paths generically */
67
function detectWorkAreas(files: string[]): Set<string> {
@@ -36,7 +37,7 @@ export function registerAuditWorkspace(server: McpServer): void {
3637
{},
3738
async () => {
3839
const docs = findWorkspaceDocs();
39-
const recentFiles = run("git diff --name-only HEAD~10 2>/dev/null || echo ''").split("\n").filter(Boolean);
40+
const recentFiles = run(["diff", "--name-only", "HEAD~10"]).split("\n").filter(Boolean);
4041
const sections: string[] = [];
4142

4243
// Doc freshness
@@ -75,7 +76,9 @@ export function registerAuditWorkspace(server: McpServer): void {
7576
// Check for gap trackers or similar tracking docs
7677
const trackingDocs = Object.entries(docs).filter(([n]) => /gap|track|progress/i.test(n));
7778
if (trackingDocs.length > 0) {
78-
const testFilesCount = parseInt(run("find tests -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | wc -l").trim()) || 0;
79+
// Count test files using git ls-files instead of find|wc
80+
const testFilesOutput = run(["ls-files", "--", "tests/**/*.spec.ts", "tests/**/*.test.ts"]);
81+
const testFilesCount = testFilesOutput ? testFilesOutput.split("\n").filter(Boolean).length : 0;
7982
sections.push(`## Tracking Docs\n${trackingDocs.map(([n]) => {
8083
const age = docStatus.find(d => d.name === n)?.ageHours ?? "?";
8184
return `- .claude/${n} — last updated ${age}h ago`;

src/tools/enrich-agent-task.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ import { execFileSync } from "child_process";
88
import { join, basename } from "path";
99
import { createHash } from "crypto";
1010

11-
/** Sanitize user input for safe use in shell commands */
12-
function shellEscape(s: string): string {
13-
return s.replace(/[^a-zA-Z0-9_\-./]/g, "");
14-
}
15-
1611
/** Detect package manager from lockfiles */
1712
function detectPackageManager(): string {
1813
if (existsSync(join(PROJECT_DIR, "pnpm-lock.yaml"))) return "pnpm";
@@ -25,35 +20,48 @@ function detectPackageManager(): string {
2520
function findAreaFiles(area: string): string {
2621
if (!area) return getDiffFiles("HEAD~3");
2722

28-
const safeArea = shellEscape(area);
29-
3023
// If area looks like a path, search directly
3124
if (area.includes("/")) {
32-
return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`);
25+
const files = run(["ls-files", "--", `${area}*`]);
26+
if (files && !files.startsWith("[")) {
27+
return files.split("\n").filter(Boolean).slice(0, 20).join("\n");
28+
}
29+
return getDiffFiles("HEAD~3");
3330
}
3431

3532
// Search for area keyword in git-tracked file paths
36-
const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`);
37-
if (files && !files.startsWith("[command failed")) return files;
33+
const allFiles = run(["ls-files"]);
34+
if (allFiles && !allFiles.startsWith("[")) {
35+
const re = new RegExp(area.replace(/[^a-zA-Z0-9_\-./]/g, ""), "i");
36+
const matched = allFiles.split("\n").filter(f => re.test(f)).slice(0, 20);
37+
if (matched.length > 0) return matched.join("\n");
38+
}
3839

3940
// Fallback to recently changed files
4041
return getDiffFiles("HEAD~3");
4142
}
4243

4344
/** Find related test files for an area */
4445
function findRelatedTests(area: string): string {
45-
if (!area) return run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");
46+
const allFiles = run(["ls-files"]).split("\n").filter(Boolean);
47+
const testFiles = allFiles.filter(f => /\.(spec|test)\.(ts|tsx|js|jsx)$/.test(f));
48+
49+
if (!area) return testFiles.slice(0, 10).join("\n");
4650

47-
const safeArea = shellEscape(area.split(/\s+/)[0]);
48-
const tests = run(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`);
49-
return tests || run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10");
51+
const keyword = area.split(/\s+/)[0].replace(/[^a-zA-Z0-9_\-./]/g, "").toLowerCase();
52+
const matched = testFiles.filter(f => f.toLowerCase().includes(keyword)).slice(0, 10);
53+
return matched.length > 0 ? matched.join("\n") : testFiles.slice(0, 10).join("\n");
5054
}
5155

5256
/** Get an example pattern from the first matching file */
5357
function getExamplePattern(files: string): string {
5458
const firstFile = files.split("\n").filter(Boolean)[0];
5559
if (!firstFile) return "no pattern available";
56-
return run(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`);
60+
try {
61+
const abs = join(PROJECT_DIR, firstFile);
62+
const content = readFileSync(abs, "utf-8");
63+
return content.split("\n").slice(0, 30).join("\n");
64+
} catch { return "could not read file"; }
5765
}
5866

5967
// ---------------------------------------------------------------------------

src/tools/scope-work.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,6 @@ const STOP_WORDS = new Set([
1717
"like", "some", "each", "only", "need", "want", "please", "update", "change",
1818
]);
1919

20-
/** Shell-escape a string for use inside single quotes */
21-
function shellEscape(s: string): string {
22-
return s.replace(/'/g, "'\\''");
23-
}
24-
2520
/** Safely parse git porcelain status lines */
2621
function parsePortelainFiles(porcelain: string): string[] {
2722
if (!porcelain.trim()) return [];
@@ -127,8 +122,9 @@ export function registerScopeWork(server: McpServer): void {
127122
.filter((k) => k.length > 2)
128123
.slice(0, 5);
129124
if (grepTerms.length > 0) {
130-
const pattern = shellEscape(grepTerms.join("|"));
131-
matchedFiles = run(`git ls-files | head -500 | grep -iE '${pattern}' | head -30`);
125+
const allFiles = run(["ls-files"]).split("\n").filter(Boolean).slice(0, 500);
126+
const re = new RegExp(grepTerms.join("|"), "i");
127+
matchedFiles = allFiles.filter(f => re.test(f)).slice(0, 30).join("\n");
132128
}
133129

134130
// Check which relevant dirs actually exist (with path traversal protection)

src/tools/sequence-tasks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ export function registerSequenceTasks(server: McpServer): void {
9090
// For locality: infer directories from path-like tokens in task text
9191
if (strategy === "locality") {
9292
// Use git ls-files with a depth limit instead of find for performance
93-
const gitFiles = run("git ls-files 2>/dev/null | head -1000");
93+
const gitFilesRaw = run(["ls-files"]);
94+
const gitFiles = gitFilesRaw.startsWith("[") ? "" : gitFilesRaw.split("\n").slice(0, 1000).join("\n");
9495
const knownDirs = new Set<string>();
9596
for (const f of gitFiles.split("\n").filter(Boolean)) {
9697
const parts = f.split("/");

src/tools/session-handoff.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { z } from "zod";
22
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33
import { existsSync, readFileSync } from "fs";
4+
import { execFileSync } from "child_process";
45
import { join } from "path";
56
import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js";
6-
import { readIfExists, findWorkspaceDocs } from "../lib/files.js";
7+
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
78
import { STATE_DIR, now } from "../lib/state.js";
89

910
/** Check if a CLI tool is available */
1011
function hasCommand(cmd: string): boolean {
11-
const result = run(`command -v ${cmd} 2>/dev/null`);
12-
return !!result && !result.startsWith("[command failed");
12+
try {
13+
execFileSync("which", [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
14+
return true;
15+
} catch { return false; }
1316
}
1417

1518
export function registerSessionHandoff(server: McpServer): void {
@@ -44,7 +47,13 @@ export function registerSessionHandoff(server: McpServer): void {
4447

4548
// Only try gh if it exists
4649
if (hasCommand("gh")) {
47-
const openPRs = run("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'");
50+
let openPRs = "[]";
51+
try {
52+
openPRs = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"], {
53+
cwd: PROJECT_DIR, encoding: "utf-8", timeout: 10000,
54+
stdio: ["pipe", "pipe", "pipe"],
55+
}).trim();
56+
} catch { /* gh not authenticated or no remote */ }
4857
if (openPRs && openPRs !== "[]") {
4958
sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``);
5059
}

src/tools/sharpen-followup.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ function parsePortelainFiles(output: string): string[] {
2727
/** Get recently changed files, safe for first commit / shallow clones */
2828
function getRecentChangedFiles(): string[] {
2929
// Try HEAD~1..HEAD, fall back to just staged, then unstaged
30-
const commands = [
31-
"git diff --name-only HEAD~1 HEAD 2>/dev/null",
32-
"git diff --name-only --cached 2>/dev/null",
33-
"git diff --name-only 2>/dev/null",
30+
const commands: string[][] = [
31+
["diff", "--name-only", "HEAD~1", "HEAD"],
32+
["diff", "--name-only", "--cached"],
33+
["diff", "--name-only"],
3434
];
3535
const results = new Set<string>();
3636
for (const cmd of commands) {
@@ -87,7 +87,7 @@ export function registerSharpenFollowup(server: McpServer): void {
8787
// Gather context to resolve ambiguity
8888
const contextFiles: string[] = [...(previous_files ?? [])];
8989
const recentChanged = getRecentChangedFiles();
90-
const porcelainOutput = run("git status --porcelain 2>/dev/null");
90+
const porcelainOutput = run(["status", "--porcelain"]);
9191
const untrackedOrModified = parsePortelainFiles(porcelainOutput);
9292

9393
const allKnownFiles = [...new Set([...contextFiles, ...recentChanged, ...untrackedOrModified])].filter(Boolean);

src/tools/token-audit.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,38 @@ import { run } from "../lib/git.js";
55
import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js";
66
import { loadState, saveState, now, STATE_DIR } from "../lib/state.js";
77
import { readFileSync, existsSync, statSync } from "fs";
8-
import { join } from "path";
8+
import { join, resolve } from "path";
99

10-
/** Shell-escape a filename for safe interpolation */
11-
function shellEscape(s: string): string {
12-
return s.replace(/'/g, "'\\''");
10+
/** Count lines in a file using Node.js fs */
11+
function countLines(filePath: string): number {
12+
try {
13+
const abs = resolve(PROJECT_DIR, filePath);
14+
if (!abs.startsWith(resolve(PROJECT_DIR))) return 0;
15+
const content = readFileSync(abs, "utf-8");
16+
return content.split("\n").length;
17+
} catch { return 0; }
18+
}
19+
20+
/** Get file size in bytes using Node.js fs */
21+
function fileSize(filePath: string): number {
22+
try {
23+
const abs = resolve(PROJECT_DIR, filePath);
24+
if (!abs.startsWith(resolve(PROJECT_DIR))) return 0;
25+
return statSync(abs).size;
26+
} catch { return 0; }
27+
}
28+
29+
/** Read tail of a file (last N bytes) */
30+
function readTail(filePath: string, maxBytes: number): string {
31+
try {
32+
const stat = statSync(filePath);
33+
if (stat.size <= maxBytes) return readFileSync(filePath, "utf-8");
34+
const fd = require("fs").openSync(filePath, "r");
35+
const buf = Buffer.alloc(maxBytes);
36+
require("fs").readSync(fd, buf, 0, maxBytes, stat.size - maxBytes);
37+
require("fs").closeSync(fd);
38+
return buf.toString("utf-8");
39+
} catch { return ""; }
1340
}
1441

1542
/**
@@ -39,8 +66,8 @@ export function registerTokenAudit(server: McpServer): void {
3966
let wasteScore = 0;
4067

4168
// 1. Git diff size & dirty file count
42-
const diffStat = run("git diff --stat --no-color 2>/dev/null");
43-
const dirtyFiles = run("git diff --name-only 2>/dev/null");
69+
const diffStat = run(["diff", "--stat", "--no-color"]);
70+
const dirtyFiles = run(["diff", "--name-only"]);
4471
const dirtyList = dirtyFiles.split("\n").filter(Boolean);
4572
const dirtyCount = dirtyList.length;
4673

@@ -62,9 +89,7 @@ export function registerTokenAudit(server: McpServer): void {
6289
const largeFiles: string[] = [];
6390

6491
for (const f of dirtyList.slice(0, 30)) {
65-
// Use shell-safe quoting instead of interpolation
66-
const wc = run(`wc -l < '${shellEscape(f)}' 2>/dev/null`);
67-
const lines = parseInt(wc) || 0;
92+
const lines = countLines(f);
6893
estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE;
6994
if (lines > 500) {
7095
largeFiles.push(`${f} (${lines} lines)`);
@@ -80,8 +105,7 @@ export function registerTokenAudit(server: McpServer): void {
80105
// 3. CLAUDE.md bloat check
81106
const claudeMd = readIfExists("CLAUDE.md", 1);
82107
if (claudeMd !== null) {
83-
const stat = run(`wc -c < '${shellEscape("CLAUDE.md")}' 2>/dev/null`);
84-
const bytes = parseInt(stat) || 0;
108+
const bytes = fileSize("CLAUDE.md");
85109
if (bytes > 5120) {
86110
patterns.push(`CLAUDE.md is ${(bytes / 1024).toFixed(1)}KB — injected every session, burns tokens on paste`);
87111
recommendations.push("Trim CLAUDE.md to essentials (<5KB). Move reference docs to files read on-demand");
@@ -139,7 +163,7 @@ export function registerTokenAudit(server: McpServer): void {
139163
// Read with size cap: take the tail if too large
140164
const raw = stat.size <= MAX_TOOL_LOG_BYTES
141165
? readFileSync(toolLogPath, "utf-8")
142-
: run(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`);
166+
: readTail(toolLogPath, MAX_TOOL_LOG_BYTES);
143167

144168
const lines = raw.trim().split("\n").filter(Boolean);
145169
totalToolCalls = lines.length;

0 commit comments

Comments
 (0)