Skip to content

Commit 725ec9b

Browse files
committed
feat(ast-grep): add safety limits to prevent token overflow
- Add timeout (5min), output limit (1MB), match limit (500) - Add SgResult type with truncation info - Update formatSearchResult/formatReplaceResult for truncation display - cli.ts: timeout + output truncation + graceful JSON recovery
1 parent 1f717a7 commit 725ec9b

File tree

5 files changed

+161
-57
lines changed

5 files changed

+161
-57
lines changed

src/tools/ast-grep/cli.ts

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { spawn } from "bun"
22
import { existsSync } from "fs"
3-
import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants"
3+
import {
4+
getSgCliPath,
5+
setSgCliPath,
6+
findSgCliPathSync,
7+
DEFAULT_TIMEOUT_MS,
8+
DEFAULT_MAX_OUTPUT_BYTES,
9+
DEFAULT_MAX_MATCHES,
10+
} from "./constants"
411
import { ensureAstGrepBinary } from "./downloader"
5-
import type { CliMatch, CliLanguage } from "./types"
12+
import type { CliMatch, CliLanguage, SgResult } from "./types"
613

714
export interface RunOptions {
815
pattern: string
@@ -54,26 +61,7 @@ export function startBackgroundInit(): void {
5461
}
5562
}
5663

57-
interface SpawnResult {
58-
stdout: string
59-
stderr: string
60-
exitCode: number
61-
}
62-
63-
async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> {
64-
const proc = spawn([cliPath, ...args], {
65-
stdout: "pipe",
66-
stderr: "pipe",
67-
})
68-
69-
const stdout = await new Response(proc.stdout).text()
70-
const stderr = await new Response(proc.stderr).text()
71-
const exitCode = await proc.exited
72-
73-
return { stdout, stderr, exitCode }
74-
}
75-
76-
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
64+
export async function runSg(options: RunOptions): Promise<SgResult> {
7765
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
7866

7967
if (options.rewrite) {
@@ -105,55 +93,129 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
10593
}
10694
}
10795

108-
let result: SpawnResult
96+
const timeout = DEFAULT_TIMEOUT_MS
97+
98+
const proc = spawn([cliPath, ...args], {
99+
stdout: "pipe",
100+
stderr: "pipe",
101+
})
102+
103+
const timeoutPromise = new Promise<never>((_, reject) => {
104+
const id = setTimeout(() => {
105+
proc.kill()
106+
reject(new Error(`Search timeout after ${timeout}ms`))
107+
}, timeout)
108+
proc.exited.then(() => clearTimeout(id))
109+
})
110+
111+
let stdout: string
112+
let stderr: string
113+
let exitCode: number
114+
109115
try {
110-
result = await spawnSg(cliPath, args)
116+
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
117+
stderr = await new Response(proc.stderr).text()
118+
exitCode = await proc.exited
111119
} catch (e) {
112-
const error = e as NodeJS.ErrnoException
120+
const error = e as Error
121+
if (error.message?.includes("timeout")) {
122+
return {
123+
matches: [],
124+
totalMatches: 0,
125+
truncated: true,
126+
truncatedReason: "timeout",
127+
error: error.message,
128+
}
129+
}
130+
131+
const nodeError = e as NodeJS.ErrnoException
113132
if (
114-
error.code === "ENOENT" ||
115-
error.message?.includes("ENOENT") ||
116-
error.message?.includes("not found")
133+
nodeError.code === "ENOENT" ||
134+
nodeError.message?.includes("ENOENT") ||
135+
nodeError.message?.includes("not found")
117136
) {
118137
const downloadedPath = await ensureAstGrepBinary()
119138
if (downloadedPath) {
120139
resolvedCliPath = downloadedPath
121140
setSgCliPath(downloadedPath)
122-
result = await spawnSg(downloadedPath, args)
141+
return runSg(options)
123142
} else {
124-
throw new Error(
125-
`ast-grep CLI binary not found.\n\n` +
143+
return {
144+
matches: [],
145+
totalMatches: 0,
146+
truncated: false,
147+
error:
148+
`ast-grep CLI binary not found.\n\n` +
126149
`Auto-download failed. Manual install options:\n` +
127150
` bun add -D @ast-grep/cli\n` +
128151
` cargo install ast-grep --locked\n` +
129-
` brew install ast-grep`
130-
)
152+
` brew install ast-grep`,
153+
}
131154
}
132-
} else {
133-
throw new Error(`Failed to spawn ast-grep: ${error.message}`)
134155
}
135-
}
136156

137-
const { stdout, stderr, exitCode } = result
157+
return {
158+
matches: [],
159+
totalMatches: 0,
160+
truncated: false,
161+
error: `Failed to spawn ast-grep: ${error.message}`,
162+
}
163+
}
138164

139165
if (exitCode !== 0 && stdout.trim() === "") {
140166
if (stderr.includes("No files found")) {
141-
return []
167+
return { matches: [], totalMatches: 0, truncated: false }
142168
}
143169
if (stderr.trim()) {
144-
throw new Error(stderr.trim())
170+
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
145171
}
146-
return []
172+
return { matches: [], totalMatches: 0, truncated: false }
147173
}
148174

149175
if (!stdout.trim()) {
150-
return []
176+
return { matches: [], totalMatches: 0, truncated: false }
151177
}
152178

179+
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
180+
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
181+
182+
let matches: CliMatch[] = []
153183
try {
154-
return JSON.parse(stdout) as CliMatch[]
184+
matches = JSON.parse(outputToProcess) as CliMatch[]
155185
} catch {
156-
return []
186+
if (outputTruncated) {
187+
try {
188+
const lastValidIndex = outputToProcess.lastIndexOf("}")
189+
if (lastValidIndex > 0) {
190+
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
191+
if (bracketIndex > 0) {
192+
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
193+
matches = JSON.parse(truncatedJson) as CliMatch[]
194+
}
195+
}
196+
} catch {
197+
return {
198+
matches: [],
199+
totalMatches: 0,
200+
truncated: true,
201+
truncatedReason: "max_output_bytes",
202+
error: "Output too large and could not be parsed",
203+
}
204+
}
205+
} else {
206+
return { matches: [], totalMatches: 0, truncated: false }
207+
}
208+
}
209+
210+
const totalMatches = matches.length
211+
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
212+
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
213+
214+
return {
215+
matches: finalMatches,
216+
totalMatches,
217+
truncated: outputTruncated || matchesTruncated,
218+
truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined,
157219
}
158220
}
159221

src/tools/ast-grep/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ export const CLI_LANGUAGES = [
135135
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
136136

137137
// Language to file extensions mapping
138+
export const DEFAULT_TIMEOUT_MS = 300_000
139+
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
140+
export const DEFAULT_MAX_MATCHES = 500
141+
138142
export const LANG_EXTENSIONS: Record<string, string[]> = {
139143
bash: [".bash", ".sh", ".zsh", ".bats"],
140144
c: [".c", ".h"],

src/tools/ast-grep/tools.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@ export const ast_grep_search = tool({
4949
},
5050
execute: async (args, context) => {
5151
try {
52-
const matches = await runSg({
52+
const result = await runSg({
5353
pattern: args.pattern,
5454
lang: args.lang as CliLanguage,
5555
paths: args.paths,
5656
globs: args.globs,
5757
context: args.context,
5858
})
5959

60-
let output = formatSearchResult(matches)
60+
let output = formatSearchResult(result)
6161

62-
if (matches.length === 0) {
62+
if (result.matches.length === 0 && !result.error) {
6363
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
6464
if (hint) {
6565
output += `\n\n${hint}`
@@ -91,15 +91,15 @@ export const ast_grep_replace = tool({
9191
},
9292
execute: async (args, context) => {
9393
try {
94-
const matches = await runSg({
94+
const result = await runSg({
9595
pattern: args.pattern,
9696
rewrite: args.rewrite,
9797
lang: args.lang as CliLanguage,
9898
paths: args.paths,
9999
globs: args.globs,
100100
updateAll: args.dryRun === false,
101101
})
102-
const output = formatReplaceResult(matches, args.dryRun !== false)
102+
const output = formatReplaceResult(result, args.dryRun !== false)
103103
showOutputToUser(context, output)
104104
return output
105105
} catch (e) {

src/tools/ast-grep/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,11 @@ export interface TransformResult {
5151
transformed: string
5252
editCount: number
5353
}
54+
55+
export interface SgResult {
56+
matches: CliMatch[]
57+
totalMatches: number
58+
truncated: boolean
59+
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
60+
error?: string
61+
}

src/tools/ast-grep/utils.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
1-
import type { CliMatch, AnalyzeResult } from "./types"
1+
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
22

3-
export function formatSearchResult(matches: CliMatch[]): string {
4-
if (matches.length === 0) {
3+
export function formatSearchResult(result: SgResult): string {
4+
if (result.error) {
5+
return `Error: ${result.error}`
6+
}
7+
8+
if (result.matches.length === 0) {
59
return "No matches found"
610
}
711

8-
const lines: string[] = [`Found ${matches.length} match(es):\n`]
12+
const lines: string[] = []
13+
14+
if (result.truncated) {
15+
const reason = result.truncatedReason === "max_matches"
16+
? `showing first ${result.matches.length} of ${result.totalMatches}`
17+
: result.truncatedReason === "max_output_bytes"
18+
? "output exceeded 1MB limit"
19+
: "search timed out"
20+
lines.push(`⚠️ Results truncated (${reason})\n`)
21+
}
22+
23+
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
924

10-
for (const match of matches) {
25+
for (const match of result.matches) {
1126
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
1227
lines.push(`${loc}`)
1328
lines.push(` ${match.lines.trim()}`)
@@ -17,15 +32,30 @@ export function formatSearchResult(matches: CliMatch[]): string {
1732
return lines.join("\n")
1833
}
1934

20-
export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string {
21-
if (matches.length === 0) {
35+
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
36+
if (result.error) {
37+
return `Error: ${result.error}`
38+
}
39+
40+
if (result.matches.length === 0) {
2241
return "No matches found to replace"
2342
}
2443

2544
const prefix = isDryRun ? "[DRY RUN] " : ""
26-
const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`]
45+
const lines: string[] = []
46+
47+
if (result.truncated) {
48+
const reason = result.truncatedReason === "max_matches"
49+
? `showing first ${result.matches.length} of ${result.totalMatches}`
50+
: result.truncatedReason === "max_output_bytes"
51+
? "output exceeded 1MB limit"
52+
: "search timed out"
53+
lines.push(`⚠️ Results truncated (${reason})\n`)
54+
}
55+
56+
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
2757

28-
for (const match of matches) {
58+
for (const match of result.matches) {
2959
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
3060
lines.push(`${loc}`)
3161
lines.push(` ${match.text}`)

0 commit comments

Comments
 (0)