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.
- package/README.md +1 -1
- package/package.json +4 -2
- package/src/cli-adapters/claude.ts +139 -108
- package/src/cli-adapters/codex.ts +141 -117
- package/src/cli-adapters/cursor.ts +152 -0
- package/src/cli-adapters/gemini.ts +171 -139
- package/src/cli-adapters/github-copilot.ts +153 -0
- package/src/cli-adapters/index.ts +77 -48
- package/src/commands/check.test.ts +24 -20
- package/src/commands/check.ts +65 -59
- package/src/commands/detect.test.ts +38 -32
- package/src/commands/detect.ts +74 -61
- package/src/commands/health.test.ts +67 -53
- package/src/commands/health.ts +167 -145
- package/src/commands/help.test.ts +37 -37
- package/src/commands/help.ts +30 -22
- package/src/commands/index.ts +9 -9
- package/src/commands/init.test.ts +118 -107
- package/src/commands/init.ts +515 -417
- package/src/commands/list.test.ts +87 -70
- package/src/commands/list.ts +28 -24
- package/src/commands/rerun.ts +142 -119
- package/src/commands/review.test.ts +26 -20
- package/src/commands/review.ts +65 -59
- package/src/commands/run.test.ts +22 -20
- package/src/commands/run.ts +64 -58
- package/src/commands/shared.ts +44 -35
- package/src/config/loader.test.ts +112 -90
- package/src/config/loader.ts +132 -123
- package/src/config/schema.ts +49 -47
- package/src/config/types.ts +15 -13
- package/src/config/validator.ts +521 -454
- package/src/core/change-detector.ts +122 -104
- package/src/core/entry-point.test.ts +60 -62
- package/src/core/entry-point.ts +76 -67
- package/src/core/job.ts +69 -59
- package/src/core/runner.ts +261 -221
- package/src/gates/check.ts +78 -69
- package/src/gates/result.ts +7 -6
- package/src/gates/review.test.ts +188 -0
- package/src/gates/review.ts +717 -506
- package/src/index.ts +16 -15
- package/src/output/console.ts +253 -198
- package/src/output/logger.ts +65 -51
- package/src/templates/run_gauntlet.template.md +18 -0
- package/src/utils/diff-parser.ts +64 -62
- package/src/utils/log-parser.ts +227 -206
- 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
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|