agent-gauntlet 0.1.9 → 0.1.11

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 (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -2
  3. package/src/cli-adapters/claude.ts +139 -108
  4. package/src/cli-adapters/codex.ts +141 -117
  5. package/src/cli-adapters/cursor.ts +152 -0
  6. package/src/cli-adapters/gemini.ts +171 -139
  7. package/src/cli-adapters/github-copilot.ts +153 -0
  8. package/src/cli-adapters/index.ts +77 -48
  9. package/src/commands/check.test.ts +24 -20
  10. package/src/commands/check.ts +65 -59
  11. package/src/commands/detect.test.ts +38 -32
  12. package/src/commands/detect.ts +74 -61
  13. package/src/commands/health.test.ts +67 -53
  14. package/src/commands/health.ts +167 -145
  15. package/src/commands/help.test.ts +37 -37
  16. package/src/commands/help.ts +30 -22
  17. package/src/commands/index.ts +9 -9
  18. package/src/commands/init.test.ts +118 -107
  19. package/src/commands/init.ts +515 -417
  20. package/src/commands/list.test.ts +87 -70
  21. package/src/commands/list.ts +28 -24
  22. package/src/commands/rerun.ts +142 -119
  23. package/src/commands/review.test.ts +26 -20
  24. package/src/commands/review.ts +65 -59
  25. package/src/commands/run.test.ts +22 -20
  26. package/src/commands/run.ts +64 -58
  27. package/src/commands/shared.ts +44 -35
  28. package/src/config/loader.test.ts +112 -90
  29. package/src/config/loader.ts +132 -123
  30. package/src/config/schema.ts +49 -47
  31. package/src/config/types.ts +15 -13
  32. package/src/config/validator.ts +521 -454
  33. package/src/core/change-detector.ts +122 -104
  34. package/src/core/entry-point.test.ts +60 -62
  35. package/src/core/entry-point.ts +76 -67
  36. package/src/core/job.ts +69 -59
  37. package/src/core/runner.ts +261 -221
  38. package/src/gates/check.ts +78 -69
  39. package/src/gates/result.ts +7 -6
  40. package/src/gates/review.test.ts +188 -0
  41. package/src/gates/review.ts +717 -506
  42. package/src/index.ts +16 -15
  43. package/src/output/console.ts +253 -198
  44. package/src/output/logger.ts +65 -51
  45. package/src/templates/run_gauntlet.template.md +18 -0
  46. package/src/utils/diff-parser.ts +64 -62
  47. package/src/utils/log-parser.ts +227 -206
  48. package/src/utils/sanitizer.ts +1 -1
@@ -0,0 +1,152 @@
1
+ import { exec } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { type CLIAdapter, isUsageLimit } from "./index.js";
7
+
8
+ const execAsync = promisify(exec);
9
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
10
+
11
+ export class CursorAdapter implements CLIAdapter {
12
+ name = "cursor";
13
+
14
+ async isAvailable(): Promise<boolean> {
15
+ try {
16
+ // Note: Cursor's CLI binary is named "agent", not "cursor"
17
+ await execAsync("which agent");
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ async checkHealth(options?: { checkUsageLimit?: boolean }): Promise<{
25
+ available: boolean;
26
+ status: "healthy" | "missing" | "unhealthy";
27
+ message?: string;
28
+ }> {
29
+ const available = await this.isAvailable();
30
+ if (!available) {
31
+ return {
32
+ available: false,
33
+ status: "missing",
34
+ message: "Command not found",
35
+ };
36
+ }
37
+
38
+ if (options?.checkUsageLimit) {
39
+ try {
40
+ // Try a lightweight command to check if we're rate limited
41
+ const { stdout, stderr } = await execAsync('echo "hello" | agent', {
42
+ timeout: 10000,
43
+ });
44
+
45
+ const combined = (stdout || "") + (stderr || "");
46
+ if (isUsageLimit(combined)) {
47
+ return {
48
+ available: true,
49
+ status: "unhealthy",
50
+ message: "Usage limit exceeded",
51
+ };
52
+ }
53
+
54
+ return { available: true, status: "healthy", message: "Ready" };
55
+ } catch (error: unknown) {
56
+ const execError = error as {
57
+ stderr?: string;
58
+ stdout?: string;
59
+ message?: string;
60
+ };
61
+ const stderr = execError.stderr || "";
62
+ const stdout = execError.stdout || "";
63
+ const combined = stderr + stdout;
64
+
65
+ if (isUsageLimit(combined)) {
66
+ return {
67
+ available: true,
68
+ status: "unhealthy",
69
+ message: "Usage limit exceeded",
70
+ };
71
+ }
72
+
73
+ // Since we sent a valid prompt ("hello"), any other error implies the tool is broken
74
+ const cleanError =
75
+ combined.split("\n")[0]?.trim() ||
76
+ execError.message ||
77
+ "Command failed";
78
+ return {
79
+ available: true,
80
+ status: "unhealthy",
81
+ message: `Error: ${cleanError}`,
82
+ };
83
+ }
84
+ }
85
+
86
+ return { available: true, status: "healthy", message: "Ready" };
87
+ }
88
+
89
+ getProjectCommandDir(): string | null {
90
+ // Cursor does not support custom commands
91
+ return null;
92
+ }
93
+
94
+ getUserCommandDir(): string | null {
95
+ // Cursor does not support custom commands
96
+ return null;
97
+ }
98
+
99
+ getCommandExtension(): string {
100
+ return ".md";
101
+ }
102
+
103
+ canUseSymlink(): boolean {
104
+ // Not applicable - no command directory support
105
+ return false;
106
+ }
107
+
108
+ transformCommand(markdownContent: string): string {
109
+ // Not applicable - no command directory support
110
+ return markdownContent;
111
+ }
112
+
113
+ async execute(opts: {
114
+ prompt: string;
115
+ diff: string;
116
+ model?: string;
117
+ timeoutMs?: number;
118
+ }): Promise<string> {
119
+ const fullContent = `${opts.prompt}\n\n--- DIFF ---\n${opts.diff}`;
120
+
121
+ const tmpDir = os.tmpdir();
122
+ // Include process.pid for uniqueness across concurrent processes
123
+ const tmpFile = path.join(
124
+ tmpDir,
125
+ `gauntlet-cursor-${process.pid}-${Date.now()}.txt`,
126
+ );
127
+ await fs.writeFile(tmpFile, fullContent);
128
+
129
+ try {
130
+ // Cursor agent command reads from stdin
131
+ // Note: As of the current version, the Cursor 'agent' CLI does not expose
132
+ // flags for restricting tools or enforcing read-only mode (unlike claude's --allowedTools
133
+ // or codex's --sandbox read-only). The agent is assumed to be repo-scoped and
134
+ // safe for code review use. If Cursor adds such flags in the future, they should
135
+ // be added here for defense-in-depth.
136
+ //
137
+ // Shell command construction: We use exec() with shell piping
138
+ // because the agent requires stdin input. The tmpFile path is system-controlled
139
+ // (os.tmpdir() + Date.now() + process.pid), not user-supplied, eliminating injection risk.
140
+ // Double quotes handle paths with spaces.
141
+ const cmd = `cat "${tmpFile}" | agent`;
142
+ const { stdout } = await execAsync(cmd, {
143
+ timeout: opts.timeoutMs,
144
+ maxBuffer: MAX_BUFFER_BYTES,
145
+ });
146
+ return stdout;
147
+ } finally {
148
+ // Cleanup errors are intentionally ignored - the tmp file will be cleaned up by OS
149
+ await fs.unlink(tmpFile).catch(() => {});
150
+ }
151
+ }
152
+ }
@@ -1,149 +1,181 @@
1
- import { exec } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import { type CLIAdapter, isUsageLimit } from './index.js';
4
- import fs from 'node:fs/promises';
5
- import path from 'node:path';
6
- import os from 'node:os';
1
+ import { exec } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { type CLIAdapter, isUsageLimit } from "./index.js";
7
7
 
8
8
  const execAsync = promisify(exec);
9
9
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
10
10
 
11
11
  export class GeminiAdapter implements CLIAdapter {
12
- name = 'gemini';
13
-
14
- async isAvailable(): Promise<boolean> {
15
- try {
16
- await execAsync('which gemini');
17
- return true;
18
- } catch {
19
- return false;
20
- }
21
- }
22
-
23
- async checkHealth(options?: { checkUsageLimit?: boolean }): Promise<{ available: boolean; status: 'healthy' | 'missing' | 'unhealthy'; message?: string }> {
24
- const available = await this.isAvailable();
25
- if (!available) {
26
- return { available: false, status: 'missing', message: 'Command not found' };
27
- }
28
-
29
- if (options?.checkUsageLimit) {
30
- try {
31
- const { stdout, stderr } = await execAsync('echo "hello" | gemini --sandbox --output-format text', { timeout: 10000 });
32
-
33
- const combined = (stdout || '') + (stderr || '');
34
- if (isUsageLimit(combined)) {
35
- return {
36
- available: true,
37
- status: 'unhealthy',
38
- message: 'Usage limit exceeded'
39
- };
40
- }
41
-
42
- return { available: true, status: 'healthy', message: 'Installed' };
43
- } catch (error: any) {
44
- const stderr = error.stderr || '';
45
- const stdout = error.stdout || '';
46
- const combined = (stderr + stdout);
47
-
48
- if (isUsageLimit(combined)) {
49
- return {
50
- available: true,
51
- status: 'unhealthy',
52
- message: 'Usage limit exceeded'
53
- };
54
- }
55
-
56
- // Since we sent a valid prompt ("hello"), any other error implies the tool is broken
57
- const cleanError = combined.split('\n')[0]?.trim() || error.message || 'Command failed';
58
- return {
59
- available: true,
60
- status: 'unhealthy',
61
- message: `Error: ${cleanError}`
62
- };
63
- }
64
- }
65
-
66
- return {
67
- available,
68
- status: available ? 'healthy' : 'missing',
69
- message: available ? 'Installed' : 'Command not found'
70
- };
71
- }
72
-
73
- getProjectCommandDir(): string | null {
74
- return '.gemini/commands';
75
- }
76
-
77
- getUserCommandDir(): string | null {
78
- // Gemini supports user-level commands at ~/.gemini/commands
79
- return path.join(os.homedir(), '.gemini', 'commands');
80
- }
81
-
82
- getCommandExtension(): string {
83
- return '.toml';
84
- }
85
-
86
- canUseSymlink(): boolean {
87
- // Gemini uses TOML format, needs transformation
88
- return false;
89
- }
90
-
91
- transformCommand(markdownContent: string): string {
92
- // Transform Markdown with YAML frontmatter to Gemini's TOML format
93
- const { frontmatter, body } = this.parseMarkdownWithFrontmatter(markdownContent);
94
-
95
- const description = frontmatter.description || 'Run the gauntlet verification suite';
96
- // Escape the body for TOML multi-line string
97
- const escapedBody = body.trim();
98
-
99
- return `description = ${JSON.stringify(description)}
12
+ name = "gemini";
13
+
14
+ async isAvailable(): Promise<boolean> {
15
+ try {
16
+ await execAsync("which gemini");
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ async checkHealth(options?: { checkUsageLimit?: boolean }): Promise<{
24
+ available: boolean;
25
+ status: "healthy" | "missing" | "unhealthy";
26
+ message?: string;
27
+ }> {
28
+ const available = await this.isAvailable();
29
+ if (!available) {
30
+ return {
31
+ available: false,
32
+ status: "missing",
33
+ message: "Command not found",
34
+ };
35
+ }
36
+
37
+ if (options?.checkUsageLimit) {
38
+ try {
39
+ const { stdout, stderr } = await execAsync(
40
+ 'echo "hello" | gemini --sandbox --output-format text',
41
+ { timeout: 10000 },
42
+ );
43
+
44
+ const combined = (stdout || "") + (stderr || "");
45
+ if (isUsageLimit(combined)) {
46
+ return {
47
+ available: true,
48
+ status: "unhealthy",
49
+ message: "Usage limit exceeded",
50
+ };
51
+ }
52
+
53
+ return { available: true, status: "healthy", message: "Installed" };
54
+ } catch (error: unknown) {
55
+ const execError = error as {
56
+ stderr?: string;
57
+ stdout?: string;
58
+ message?: string;
59
+ };
60
+ const stderr = execError.stderr || "";
61
+ const stdout = execError.stdout || "";
62
+ const combined = stderr + stdout;
63
+
64
+ if (isUsageLimit(combined)) {
65
+ return {
66
+ available: true,
67
+ status: "unhealthy",
68
+ message: "Usage limit exceeded",
69
+ };
70
+ }
71
+
72
+ // Since we sent a valid prompt ("hello"), any other error implies the tool is broken
73
+ const cleanError =
74
+ combined.split("\n")[0]?.trim() ||
75
+ execError.message ||
76
+ "Command failed";
77
+ return {
78
+ available: true,
79
+ status: "unhealthy",
80
+ message: `Error: ${cleanError}`,
81
+ };
82
+ }
83
+ }
84
+
85
+ return {
86
+ available,
87
+ status: available ? "healthy" : "missing",
88
+ message: available ? "Installed" : "Command not found",
89
+ };
90
+ }
91
+
92
+ getProjectCommandDir(): string | null {
93
+ return ".gemini/commands";
94
+ }
95
+
96
+ getUserCommandDir(): string | null {
97
+ // Gemini supports user-level commands at ~/.gemini/commands
98
+ return path.join(os.homedir(), ".gemini", "commands");
99
+ }
100
+
101
+ getCommandExtension(): string {
102
+ return ".toml";
103
+ }
104
+
105
+ canUseSymlink(): boolean {
106
+ // Gemini uses TOML format, needs transformation
107
+ return false;
108
+ }
109
+
110
+ transformCommand(markdownContent: string): string {
111
+ // Transform Markdown with YAML frontmatter to Gemini's TOML format
112
+ const { frontmatter, body } =
113
+ this.parseMarkdownWithFrontmatter(markdownContent);
114
+
115
+ const description =
116
+ frontmatter.description || "Run the gauntlet verification suite";
117
+ // Escape the body for TOML multi-line string
118
+ const escapedBody = body.trim();
119
+
120
+ return `description = ${JSON.stringify(description)}
100
121
  prompt = """
101
122
  ${escapedBody}
102
123
  """
103
124
  `;
104
- }
105
-
106
- private parseMarkdownWithFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
107
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
108
- if (!frontmatterMatch) {
109
- return { frontmatter: {}, body: content };
110
- }
111
-
112
- const frontmatterStr = frontmatterMatch[1] ?? '';
113
- const body = frontmatterMatch[2] ?? '';
114
-
115
- // Simple YAML parsing for key: value pairs
116
- const frontmatter: Record<string, string> = {};
117
- for (const line of frontmatterStr.split('\n')) {
118
- const kvMatch = line.match(/^([^:]+):\s*(.*)$/);
119
- if (kvMatch && kvMatch[1] && kvMatch[2] !== undefined) {
120
- frontmatter[kvMatch[1].trim()] = kvMatch[2].trim();
121
- }
122
- }
123
-
124
- return { frontmatter, body };
125
- }
126
-
127
- async execute(opts: { prompt: string; diff: string; model?: string; timeoutMs?: number }): Promise<string> {
128
- // Construct the full prompt content
129
- const fullContent = opts.prompt + "\n\n--- DIFF ---\n" + opts.diff;
130
-
131
- // Write to a temporary file to avoid shell escaping issues
132
- const tmpDir = os.tmpdir();
133
- const tmpFile = path.join(tmpDir, `gauntlet-gemini-${Date.now()}.txt`);
134
- await fs.writeFile(tmpFile, fullContent);
135
-
136
- try {
137
- // Use gemini CLI with file input
138
- // --sandbox: enables the execution sandbox
139
- // --allowed-tools: whitelists read-only tools for non-interactive execution
140
- // --output-format text: ensures plain text output
141
- // Use < for stdin redirection instead of cat pipe (cleaner)
142
- const cmd = `gemini --sandbox --allowed-tools read_file,list_directory,glob,search_file_content --output-format text < "${tmpFile}"`;
143
- const { stdout } = await execAsync(cmd, { timeout: opts.timeoutMs, maxBuffer: MAX_BUFFER_BYTES });
144
- return stdout;
145
- } finally {
146
- await fs.unlink(tmpFile).catch(() => {});
147
- }
148
- }
125
+ }
126
+
127
+ private parseMarkdownWithFrontmatter(content: string): {
128
+ frontmatter: Record<string, string>;
129
+ body: string;
130
+ } {
131
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
132
+ if (!frontmatterMatch) {
133
+ return { frontmatter: {}, body: content };
134
+ }
135
+
136
+ const frontmatterStr = frontmatterMatch[1] ?? "";
137
+ const body = frontmatterMatch[2] ?? "";
138
+
139
+ // Simple YAML parsing for key: value pairs
140
+ const frontmatter: Record<string, string> = {};
141
+ for (const line of frontmatterStr.split("\n")) {
142
+ const kvMatch = line.match(/^([^:]+):\s*(.*)$/);
143
+ if (kvMatch?.[1] && kvMatch[2] !== undefined) {
144
+ frontmatter[kvMatch[1].trim()] = kvMatch[2].trim();
145
+ }
146
+ }
147
+
148
+ return { frontmatter, body };
149
+ }
150
+
151
+ async execute(opts: {
152
+ prompt: string;
153
+ diff: string;
154
+ model?: string;
155
+ timeoutMs?: number;
156
+ }): Promise<string> {
157
+ // Construct the full prompt content
158
+ const fullContent = `${opts.prompt}\n\n--- DIFF ---\n${opts.diff}`;
159
+
160
+ // Write to a temporary file to avoid shell escaping issues
161
+ const tmpDir = os.tmpdir();
162
+ const tmpFile = path.join(tmpDir, `gauntlet-gemini-${Date.now()}.txt`);
163
+ await fs.writeFile(tmpFile, fullContent);
164
+
165
+ try {
166
+ // Use gemini CLI with file input
167
+ // --sandbox: enables the execution sandbox
168
+ // --allowed-tools: whitelists read-only tools for non-interactive execution
169
+ // --output-format text: ensures plain text output
170
+ // Use < for stdin redirection instead of cat pipe (cleaner)
171
+ const cmd = `gemini --sandbox --allowed-tools read_file,list_directory,glob,search_file_content --output-format text < "${tmpFile}"`;
172
+ const { stdout } = await execAsync(cmd, {
173
+ timeout: opts.timeoutMs,
174
+ maxBuffer: MAX_BUFFER_BYTES,
175
+ });
176
+ return stdout;
177
+ } finally {
178
+ await fs.unlink(tmpFile).catch(() => {});
179
+ }
180
+ }
149
181
  }
@@ -0,0 +1,153 @@
1
+ import { exec } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { type CLIAdapter, isUsageLimit } from "./index.js";
7
+
8
+ const execAsync = promisify(exec);
9
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
10
+
11
+ export class GitHubCopilotAdapter implements CLIAdapter {
12
+ name = "github-copilot";
13
+
14
+ async isAvailable(): Promise<boolean> {
15
+ try {
16
+ await execAsync("which copilot");
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ async checkHealth(options?: { checkUsageLimit?: boolean }): Promise<{
24
+ available: boolean;
25
+ status: "healthy" | "missing" | "unhealthy";
26
+ message?: string;
27
+ }> {
28
+ const available = await this.isAvailable();
29
+ if (!available) {
30
+ return {
31
+ available: false,
32
+ status: "missing",
33
+ message: "Command not found",
34
+ };
35
+ }
36
+
37
+ if (options?.checkUsageLimit) {
38
+ try {
39
+ // Try a lightweight command to check if we're rate limited
40
+ // Use minimal tool permissions for health check
41
+ const { stdout, stderr } = await execAsync(
42
+ 'echo "hello" | copilot --allow-tool "shell(echo)"',
43
+ { timeout: 10000 },
44
+ );
45
+
46
+ const combined = (stdout || "") + (stderr || "");
47
+ if (isUsageLimit(combined)) {
48
+ return {
49
+ available: true,
50
+ status: "unhealthy",
51
+ message: "Usage limit exceeded",
52
+ };
53
+ }
54
+
55
+ return { available: true, status: "healthy", message: "Ready" };
56
+ } catch (error: unknown) {
57
+ const execError = error as {
58
+ stderr?: string;
59
+ stdout?: string;
60
+ message?: string;
61
+ };
62
+ const stderr = execError.stderr || "";
63
+ const stdout = execError.stdout || "";
64
+ const combined = stderr + stdout;
65
+
66
+ if (isUsageLimit(combined)) {
67
+ return {
68
+ available: true,
69
+ status: "unhealthy",
70
+ message: "Usage limit exceeded",
71
+ };
72
+ }
73
+
74
+ // Since we sent a valid prompt ("hello"), any other error implies the tool is broken
75
+ const cleanError =
76
+ combined.split("\n")[0]?.trim() ||
77
+ execError.message ||
78
+ "Command failed";
79
+ return {
80
+ available: true,
81
+ status: "unhealthy",
82
+ message: `Error: ${cleanError}`,
83
+ };
84
+ }
85
+ }
86
+
87
+ return { available: true, status: "healthy", message: "Ready" };
88
+ }
89
+
90
+ getProjectCommandDir(): string | null {
91
+ // GitHub Copilot CLI does not support custom commands (feature request #618)
92
+ return null;
93
+ }
94
+
95
+ getUserCommandDir(): string | null {
96
+ // GitHub Copilot CLI does not support custom commands (feature request #618)
97
+ return null;
98
+ }
99
+
100
+ getCommandExtension(): string {
101
+ return ".md";
102
+ }
103
+
104
+ canUseSymlink(): boolean {
105
+ // Not applicable - no command directory support
106
+ return false;
107
+ }
108
+
109
+ transformCommand(markdownContent: string): string {
110
+ // Not applicable - no command directory support
111
+ return markdownContent;
112
+ }
113
+
114
+ async execute(opts: {
115
+ prompt: string;
116
+ diff: string;
117
+ model?: string;
118
+ timeoutMs?: number;
119
+ }): Promise<string> {
120
+ const fullContent = `${opts.prompt}\n\n--- DIFF ---\n${opts.diff}`;
121
+
122
+ const tmpDir = os.tmpdir();
123
+ // Include process.pid for uniqueness across concurrent processes
124
+ const tmpFile = path.join(
125
+ tmpDir,
126
+ `gauntlet-copilot-${process.pid}-${Date.now()}.txt`,
127
+ );
128
+ await fs.writeFile(tmpFile, fullContent);
129
+
130
+ try {
131
+ // Copilot reads from stdin when no -p flag is provided
132
+ // Tool whitelist: cat/grep/ls/find/head/tail are required for the AI to read
133
+ // and analyze code files during review. While these tools can access files,
134
+ // they are read-only and necessary for code review functionality.
135
+ // The copilot CLI is scoped to the repo directory by default.
136
+ // git is excluded to prevent access to commit history (review should only see diff).
137
+ //
138
+ // Shell command construction: We use exec() with shell piping instead of execFile()
139
+ // because copilot requires stdin input. The tmpFile path is system-controlled
140
+ // (os.tmpdir() + Date.now() + process.pid), not user-supplied, eliminating injection risk.
141
+ // Double quotes handle paths with spaces. This pattern matches claude.ts:131.
142
+ const cmd = `cat "${tmpFile}" | copilot --allow-tool shell(cat) --allow-tool shell(grep) --allow-tool shell(ls) --allow-tool shell(find) --allow-tool shell(head) --allow-tool shell(tail)`;
143
+ const { stdout } = await execAsync(cmd, {
144
+ timeout: opts.timeoutMs,
145
+ maxBuffer: MAX_BUFFER_BYTES,
146
+ });
147
+ return stdout;
148
+ } finally {
149
+ // Cleanup errors are intentionally ignored - the tmp file will be cleaned up by OS
150
+ await fs.unlink(tmpFile).catch(() => {});
151
+ }
152
+ }
153
+ }