firegoat 0.4.0 → 0.6.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 +15 -5
- package/dist/cli.js +480 -275
- package/package.json +2 -3
- package/skills/firegoat-onboarding/SKILL.md +0 -115
- package/skills/firegoat-onboarding/references/examples.md +0 -114
package/README.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Local-first AX evals: how well coding agents use your SDK, CLI, API and docs.
|
|
4
4
|
|
|
5
|
-
Status: early (v0.
|
|
5
|
+
Status: early (v0.6). See `plan.md`.
|
|
6
6
|
|
|
7
7
|
## Use
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx firegoat init # writes firegoat.yaml with starter evals
|
|
11
|
-
|
|
10
|
+
npx firegoat init # writes firegoat.yaml with starter evals, asks how the agent signs in;
|
|
11
|
+
# --skills adds the firegoat-onboarding skill (from team2027/skills)
|
|
12
|
+
npx firegoat claude login # sign-in option 1, once per machine: claude in ~/.firegoat/claude
|
|
12
13
|
npx firegoat eval # runs each test with your local `claude`, sandboxed, in a fresh folder
|
|
13
14
|
npx firegoat open # opens the latest run's folder (--print to only print it)
|
|
14
15
|
```
|
|
@@ -36,14 +37,23 @@ Then say so in the prompt: "The package is already installed from the build unde
|
|
|
36
37
|
- Checks also get `$FIREGOAT_OUT` (the run's copy).
|
|
37
38
|
- A failed `setup:` or `workdir_setup:` means not graded (exit 2); logs are `.firegoat/runs/<runId>/setup.txt` and `<test>/<n>/workdir_setup.txt`.
|
|
38
39
|
|
|
40
|
+
### How the agent signs in
|
|
41
|
+
|
|
42
|
+
`firegoat init` asks (or pass `--auth`):
|
|
43
|
+
|
|
44
|
+
1. **Login with Claude** (`--auth claude`, same as `npx firegoat claude login`): your Claude plan, in firegoat's own config dir `~/.firegoat/claude`, never your `~/.claude`.
|
|
45
|
+
2. **Paste an Anthropic API key** (`--auth api-key`, piped on stdin or typed hidden): stored for firegoat only in `~/.firegoat/anthropic-api-key` (mode 0600), never in `firegoat.yaml` or the repo, and passed to the sandboxed claude as `ANTHROPIC_API_KEY`.
|
|
46
|
+
|
|
47
|
+
`eval` prints which one it uses. In CI (`CI` set), `ANTHROPIC_API_KEY` from the env wins. Outside CI, a key in your shell is not passed to the agent, so it never silently replaces the Claude login. `firegoat login` still works as a deprecated alias of `firegoat claude login`; the name is reserved for firegoat.ai.
|
|
48
|
+
|
|
39
49
|
### Isolation
|
|
40
50
|
|
|
41
51
|
On macOS, `eval` runs `claude` inside a Seatbelt (`sandbox-exec`) profile:
|
|
42
52
|
|
|
43
|
-
- it signs in
|
|
53
|
+
- it signs in as chosen above, never with your `~/.claude`;
|
|
44
54
|
- it cannot read your home dir or the repo, except toolchains (node, bun, cargo, ...) it needs to run;
|
|
45
55
|
- it can write only in its run dir (`work/`, its own `TMPDIR` and package caches) and system temp;
|
|
46
|
-
- `ANTHROPIC_API_KEY` is
|
|
56
|
+
- `ANTHROPIC_API_KEY` is set only for the API-key sign-in; firegoat checks claude's reported key source on every run.
|
|
47
57
|
|
|
48
58
|
A probe checks the sandbox before every run; if it does not hold, the run is not graded. Network is open.
|
|
49
59
|
|
package/dist/cli.js
CHANGED
|
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS(function(exports) {
|
|
|
6947
6947
|
// package.json
|
|
6948
6948
|
var package_default = {
|
|
6949
6949
|
name: "firegoat",
|
|
6950
|
-
version: "0.
|
|
6950
|
+
version: "0.6.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/team2027/firegoat-cli.git"
|
|
@@ -6968,8 +6968,7 @@ var package_default = {
|
|
|
6968
6968
|
node: ">=18"
|
|
6969
6969
|
},
|
|
6970
6970
|
files: [
|
|
6971
|
-
"dist"
|
|
6972
|
-
"skills"
|
|
6971
|
+
"dist"
|
|
6973
6972
|
],
|
|
6974
6973
|
homepage: "https://github.com/team2027/firegoat-cli#readme",
|
|
6975
6974
|
license: "MIT",
|
|
@@ -6988,9 +6987,266 @@ var package_default = {
|
|
|
6988
6987
|
packageManager: "bun@1.4.3"
|
|
6989
6988
|
};
|
|
6990
6989
|
|
|
6990
|
+
// src/claude-login.ts
|
|
6991
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
6992
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
6993
|
+
|
|
6994
|
+
// src/auth.ts
|
|
6995
|
+
import {
|
|
6996
|
+
chmodSync,
|
|
6997
|
+
existsSync as existsSync2,
|
|
6998
|
+
mkdirSync as mkdirSync2,
|
|
6999
|
+
readFileSync,
|
|
7000
|
+
writeFileSync
|
|
7001
|
+
} from "node:fs";
|
|
7002
|
+
import { join as join2 } from "node:path";
|
|
7003
|
+
|
|
7004
|
+
// src/sandbox.ts
|
|
7005
|
+
import { spawnSync } from "node:child_process";
|
|
7006
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
7007
|
+
import { homedir } from "node:os";
|
|
7008
|
+
import { basename, dirname, join } from "node:path";
|
|
7009
|
+
var SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
7010
|
+
function firegoatHome() {
|
|
7011
|
+
return process.env.FIREGOAT_HOME || join(homedir(), ".firegoat");
|
|
7012
|
+
}
|
|
7013
|
+
function claudeConfigDir() {
|
|
7014
|
+
return join(firegoatHome(), "claude");
|
|
7015
|
+
}
|
|
7016
|
+
var TOOLCHAINS = [
|
|
7017
|
+
".nvm",
|
|
7018
|
+
".fnm",
|
|
7019
|
+
".volta",
|
|
7020
|
+
".bun",
|
|
7021
|
+
".deno",
|
|
7022
|
+
".cargo",
|
|
7023
|
+
".rustup",
|
|
7024
|
+
".pyenv",
|
|
7025
|
+
".rbenv",
|
|
7026
|
+
".asdf",
|
|
7027
|
+
".sdkman",
|
|
7028
|
+
".local/share/mise",
|
|
7029
|
+
".local/share/fnm",
|
|
7030
|
+
".local/share/claude",
|
|
7031
|
+
".local/share/uv",
|
|
7032
|
+
"go",
|
|
7033
|
+
"Library/pnpm"
|
|
7034
|
+
];
|
|
7035
|
+
var TOOLCHAIN_SECRETS = [".cargo/credentials", ".cargo/credentials.toml"];
|
|
7036
|
+
function real(p) {
|
|
7037
|
+
return realpathSync(p);
|
|
7038
|
+
}
|
|
7039
|
+
function under(p, dir) {
|
|
7040
|
+
return p === dir || p.startsWith(`${dir}/`);
|
|
7041
|
+
}
|
|
7042
|
+
function readableToolDirs(home, claudeBin) {
|
|
7043
|
+
const dirs = new Set;
|
|
7044
|
+
const add = (p) => {
|
|
7045
|
+
if (existsSync(p))
|
|
7046
|
+
dirs.add(real(p));
|
|
7047
|
+
};
|
|
7048
|
+
for (const t of TOOLCHAINS)
|
|
7049
|
+
add(join(home, t));
|
|
7050
|
+
for (const p of (process.env.PATH ?? "").split(":")) {
|
|
7051
|
+
if (!p.startsWith("/") || !existsSync(p))
|
|
7052
|
+
continue;
|
|
7053
|
+
const r = real(p);
|
|
7054
|
+
if (!under(r, home) || r === home)
|
|
7055
|
+
continue;
|
|
7056
|
+
const prefix = dirname(r);
|
|
7057
|
+
dirs.add(basename(r) === "bin" && prefix !== home && prefix !== join(home, ".local") ? prefix : r);
|
|
7058
|
+
}
|
|
7059
|
+
add(dirname(dirname(process.execPath)));
|
|
7060
|
+
add(dirname(claudeBin));
|
|
7061
|
+
add(dirname(real(claudeBin)));
|
|
7062
|
+
return [...dirs];
|
|
7063
|
+
}
|
|
7064
|
+
var q = (s) => JSON.stringify(s);
|
|
7065
|
+
function sandboxProfile(opts) {
|
|
7066
|
+
const home = real(homedir());
|
|
7067
|
+
const reads = readableToolDirs(home, opts.claudeBin).map((d) => `(subpath ${q(d)})`);
|
|
7068
|
+
return `(version 1)
|
|
7069
|
+
(allow default)
|
|
7070
|
+
|
|
7071
|
+
(deny file-read* (subpath ${q(home)}))
|
|
7072
|
+
(allow file-read-metadata)
|
|
7073
|
+
(allow file-read*
|
|
7074
|
+
${reads.join(`
|
|
7075
|
+
`)}
|
|
7076
|
+
(subpath ${q(join(home, "Library/Keychains"))})
|
|
7077
|
+
(regex ${q(`^${home}/Library/Preferences/com\\.apple\\.security`)})
|
|
7078
|
+
(regex ${q(`^${home}/Library/Preferences/\\.GlobalPreferences`)}))
|
|
7079
|
+
(deny file-read* file-write*
|
|
7080
|
+
${TOOLCHAIN_SECRETS.map((s) => `(literal ${q(join(home, s))})`).join(`
|
|
7081
|
+
`)})
|
|
7082
|
+
|
|
7083
|
+
(deny file-write* (subpath "/"))
|
|
7084
|
+
(allow file-write*
|
|
7085
|
+
(subpath "/dev")
|
|
7086
|
+
(subpath "/private/tmp")
|
|
7087
|
+
(subpath "/private/var/folders"))
|
|
7088
|
+
(allow file-read* file-write*
|
|
7089
|
+
(subpath ${q(real(opts.configDir))})
|
|
7090
|
+
(subpath ${q(real(opts.runRoot))}))
|
|
7091
|
+
|
|
7092
|
+
(deny file-read* file-write* (subpath ${q(real(opts.repo))}))
|
|
7093
|
+
`;
|
|
7094
|
+
}
|
|
7095
|
+
function agentEnv(runRoot, apiKey) {
|
|
7096
|
+
const env = { ...process.env };
|
|
7097
|
+
delete env.ANTHROPIC_API_KEY;
|
|
7098
|
+
if (apiKey)
|
|
7099
|
+
env.ANTHROPIC_API_KEY = apiKey;
|
|
7100
|
+
const cache = join(runRoot, "cache");
|
|
7101
|
+
const tmp = join(runRoot, "tmp");
|
|
7102
|
+
mkdirSync(cache, { recursive: true });
|
|
7103
|
+
mkdirSync(tmp, { recursive: true });
|
|
7104
|
+
return {
|
|
7105
|
+
...env,
|
|
7106
|
+
CLAUDE_CONFIG_DIR: claudeConfigDir(),
|
|
7107
|
+
TMPDIR: tmp,
|
|
7108
|
+
XDG_CACHE_HOME: cache,
|
|
7109
|
+
npm_config_cache: join(cache, "npm"),
|
|
7110
|
+
npm_config_store_dir: join(cache, "pnpm-store"),
|
|
7111
|
+
YARN_CACHE_FOLDER: join(cache, "yarn"),
|
|
7112
|
+
BUN_INSTALL_CACHE_DIR: join(cache, "bun"),
|
|
7113
|
+
PIP_CACHE_DIR: join(cache, "pip"),
|
|
7114
|
+
UV_CACHE_DIR: join(cache, "uv"),
|
|
7115
|
+
GOMODCACHE: join(cache, "gomod"),
|
|
7116
|
+
GOCACHE: join(cache, "gobuild"),
|
|
7117
|
+
CARGO_HOME: join(cache, "cargo")
|
|
7118
|
+
};
|
|
7119
|
+
}
|
|
7120
|
+
function wrap(profile, cmd, args) {
|
|
7121
|
+
if (profile === null)
|
|
7122
|
+
return { cmd, args };
|
|
7123
|
+
return { cmd: SANDBOX_EXEC, args: ["-p", profile, cmd, ...args] };
|
|
7124
|
+
}
|
|
7125
|
+
function probeSandbox(opts) {
|
|
7126
|
+
const outside = join(homedir(), `.firegoat-escape-probe-${process.pid}-${Date.now()}`);
|
|
7127
|
+
const script = 'touch "$1" 2>/dev/null && echo home-write; cat "$2" >/dev/null 2>&1 && echo repo-read; touch "$3" 2>/dev/null && echo work-write; rm -f "$1" "$3"';
|
|
7128
|
+
const { cmd, args } = wrap(opts.profile, "/bin/sh", [
|
|
7129
|
+
"-c",
|
|
7130
|
+
script,
|
|
7131
|
+
"probe",
|
|
7132
|
+
outside,
|
|
7133
|
+
opts.repoFile,
|
|
7134
|
+
join(opts.workdir, ".firegoat-probe")
|
|
7135
|
+
]);
|
|
7136
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", cwd: opts.workdir });
|
|
7137
|
+
if (r.error)
|
|
7138
|
+
return `sandbox probe could not start: ${r.error.message}`;
|
|
7139
|
+
const got = r.stdout.trim().split(`
|
|
7140
|
+
`).filter(Boolean);
|
|
7141
|
+
if (got.includes("home-write") || got.includes("repo-read"))
|
|
7142
|
+
return `sandbox does not hold (escaped: ${got.filter((g) => g !== "work-write").join(", ")}); refusing to run the agent`;
|
|
7143
|
+
if (!got.includes("work-write"))
|
|
7144
|
+
return `sandbox blocks writes in the workdir (stderr: ${r.stderr.trim() || "none"})`;
|
|
7145
|
+
return;
|
|
7146
|
+
}
|
|
7147
|
+
|
|
7148
|
+
// src/auth.ts
|
|
7149
|
+
var authFile = () => join2(firegoatHome(), "auth.json");
|
|
7150
|
+
var keyFile = () => join2(firegoatHome(), "anthropic-api-key");
|
|
7151
|
+
|
|
7152
|
+
class AuthError extends Error {
|
|
7153
|
+
}
|
|
7154
|
+
function readMethod() {
|
|
7155
|
+
const file = authFile();
|
|
7156
|
+
if (!existsSync2(file))
|
|
7157
|
+
return;
|
|
7158
|
+
let doc;
|
|
7159
|
+
try {
|
|
7160
|
+
doc = JSON.parse(readFileSync(file, "utf8"));
|
|
7161
|
+
} catch (e) {
|
|
7162
|
+
throw new AuthError(`${file} is not valid JSON: ${e.message}`);
|
|
7163
|
+
}
|
|
7164
|
+
const method = doc?.method;
|
|
7165
|
+
if (method !== "claude" && method !== "api-key")
|
|
7166
|
+
throw new AuthError(`${file}: method must be "claude" or "api-key", got ${JSON.stringify(method)}. Run: npx firegoat init`);
|
|
7167
|
+
return method;
|
|
7168
|
+
}
|
|
7169
|
+
function saveMethod(method) {
|
|
7170
|
+
mkdirSync2(firegoatHome(), { recursive: true });
|
|
7171
|
+
writeFileSync(authFile(), `${JSON.stringify({ method }, null, 2)}
|
|
7172
|
+
`);
|
|
7173
|
+
}
|
|
7174
|
+
function saveApiKey(apiKey) {
|
|
7175
|
+
mkdirSync2(firegoatHome(), { recursive: true });
|
|
7176
|
+
const file = keyFile();
|
|
7177
|
+
writeFileSync(file, `${apiKey}
|
|
7178
|
+
`, { mode: 384 });
|
|
7179
|
+
chmodSync(file, 384);
|
|
7180
|
+
saveMethod("api-key");
|
|
7181
|
+
return file;
|
|
7182
|
+
}
|
|
7183
|
+
function looksLikeAnthropicKey(s) {
|
|
7184
|
+
return /^sk-ant-[A-Za-z0-9_-]{20,}$/.test(s);
|
|
7185
|
+
}
|
|
7186
|
+
function resolveAuth(env) {
|
|
7187
|
+
if (env.CI && env.ANTHROPIC_API_KEY)
|
|
7188
|
+
return {
|
|
7189
|
+
method: "api-key",
|
|
7190
|
+
label: "Anthropic API key from ANTHROPIC_API_KEY (CI)",
|
|
7191
|
+
apiKey: env.ANTHROPIC_API_KEY
|
|
7192
|
+
};
|
|
7193
|
+
const method = readMethod() ?? "claude";
|
|
7194
|
+
if (method === "claude")
|
|
7195
|
+
return {
|
|
7196
|
+
method,
|
|
7197
|
+
label: `Claude login in ${claudeConfigDir()}`
|
|
7198
|
+
};
|
|
7199
|
+
const file = keyFile();
|
|
7200
|
+
if (!existsSync2(file))
|
|
7201
|
+
throw new AuthError(`auth is set to api-key but ${file} is missing. Run: npx firegoat init`);
|
|
7202
|
+
const apiKey = readFileSync(file, "utf8").trim();
|
|
7203
|
+
if (!apiKey)
|
|
7204
|
+
throw new AuthError(`${file} is empty. Run: npx firegoat init`);
|
|
7205
|
+
return { method, label: `Anthropic API key in ${file}`, apiKey };
|
|
7206
|
+
}
|
|
7207
|
+
|
|
7208
|
+
// src/exit.ts
|
|
7209
|
+
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7210
|
+
|
|
7211
|
+
// src/claude-login.ts
|
|
7212
|
+
function claudeLoginCommand(args) {
|
|
7213
|
+
let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
|
|
7214
|
+
for (let i = 0;i < args.length; i++) {
|
|
7215
|
+
const a = args[i];
|
|
7216
|
+
if (a === "--claude-bin" && args[i + 1] !== undefined)
|
|
7217
|
+
claudeBin = args[++i];
|
|
7218
|
+
else if (a.startsWith("--claude-bin="))
|
|
7219
|
+
claudeBin = a.slice(13);
|
|
7220
|
+
else {
|
|
7221
|
+
console.error(`firegoat claude login: unknown argument: ${a}`);
|
|
7222
|
+
return EXIT.usage;
|
|
7223
|
+
}
|
|
7224
|
+
}
|
|
7225
|
+
const configDir = claudeConfigDir();
|
|
7226
|
+
mkdirSync3(configDir, { recursive: true });
|
|
7227
|
+
const env = {
|
|
7228
|
+
...process.env,
|
|
7229
|
+
CLAUDE_CONFIG_DIR: configDir
|
|
7230
|
+
};
|
|
7231
|
+
delete env.ANTHROPIC_API_KEY;
|
|
7232
|
+
console.error(`Signing claude in to ${configDir}. Evals run with this login, never your own ~/.claude.`);
|
|
7233
|
+
const r = spawnSync2(claudeBin, ["auth", "login"], { env, stdio: "inherit" });
|
|
7234
|
+
if (r.error) {
|
|
7235
|
+
console.error(`firegoat claude login: could not run ${claudeBin}: ${r.error.message}`);
|
|
7236
|
+
return EXIT.notGraded;
|
|
7237
|
+
}
|
|
7238
|
+
if (r.status !== 0) {
|
|
7239
|
+
console.error(`firegoat claude login: claude auth login exited ${r.status}`);
|
|
7240
|
+
return EXIT.notGraded;
|
|
7241
|
+
}
|
|
7242
|
+
saveMethod("claude");
|
|
7243
|
+
console.log("logged in; evals now sign in with this Claude login. next: npx firegoat eval");
|
|
7244
|
+
return EXIT.pass;
|
|
7245
|
+
}
|
|
7246
|
+
|
|
6991
7247
|
// src/config.ts
|
|
6992
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
6993
|
-
import { dirname, join } from "node:path";
|
|
7248
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
|
7249
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
6994
7250
|
|
|
6995
7251
|
// node_modules/yaml/dist/index.js
|
|
6996
7252
|
var composer = require_composer();
|
|
@@ -7170,17 +7426,17 @@ function render(text, vars) {
|
|
|
7170
7426
|
}
|
|
7171
7427
|
function loadConfig(dir, warn) {
|
|
7172
7428
|
const file = `${dir}/${CONFIG_FILE}`;
|
|
7173
|
-
if (!
|
|
7429
|
+
if (!existsSync3(file))
|
|
7174
7430
|
throw new ConfigError(`${CONFIG_FILE} not found in ${dir}. Run: npx firegoat init`);
|
|
7175
|
-
return parseConfig(
|
|
7431
|
+
return parseConfig(readFileSync2(file, "utf8"), warn);
|
|
7176
7432
|
}
|
|
7177
7433
|
function findConfigDir(cwd) {
|
|
7178
7434
|
let dir = cwd;
|
|
7179
7435
|
for (;; ) {
|
|
7180
|
-
if (
|
|
7436
|
+
if (existsSync3(join3(dir, CONFIG_FILE)))
|
|
7181
7437
|
return dir;
|
|
7182
|
-
const parent =
|
|
7183
|
-
if (
|
|
7438
|
+
const parent = dirname2(dir);
|
|
7439
|
+
if (existsSync3(join3(dir, ".git")) || parent === dir)
|
|
7184
7440
|
return cwd;
|
|
7185
7441
|
dir = parent;
|
|
7186
7442
|
}
|
|
@@ -7190,164 +7446,20 @@ function findConfigDir(cwd) {
|
|
|
7190
7446
|
import { randomBytes } from "node:crypto";
|
|
7191
7447
|
import {
|
|
7192
7448
|
createWriteStream as createWriteStream3,
|
|
7193
|
-
existsSync as
|
|
7194
|
-
mkdirSync as
|
|
7449
|
+
existsSync as existsSync4,
|
|
7450
|
+
mkdirSync as mkdirSync5,
|
|
7195
7451
|
mkdtempSync,
|
|
7196
|
-
readFileSync as
|
|
7452
|
+
readFileSync as readFileSync3,
|
|
7197
7453
|
realpathSync as realpathSync2,
|
|
7198
|
-
writeFileSync
|
|
7454
|
+
writeFileSync as writeFileSync2
|
|
7199
7455
|
} from "node:fs";
|
|
7200
7456
|
import { tmpdir } from "node:os";
|
|
7201
|
-
import { join as
|
|
7457
|
+
import { join as join6, resolve as resolve2 } from "node:path";
|
|
7202
7458
|
|
|
7203
7459
|
// src/claude.ts
|
|
7204
|
-
import { spawn, spawnSync as
|
|
7460
|
+
import { spawn, spawnSync as spawnSync3 } from "node:child_process";
|
|
7205
7461
|
import { createWriteStream } from "node:fs";
|
|
7206
7462
|
import { createInterface } from "node:readline";
|
|
7207
|
-
|
|
7208
|
-
// src/sandbox.ts
|
|
7209
|
-
import { spawnSync } from "node:child_process";
|
|
7210
|
-
import { existsSync as existsSync2, mkdirSync, realpathSync } from "node:fs";
|
|
7211
|
-
import { homedir } from "node:os";
|
|
7212
|
-
import { basename, dirname as dirname2, join as join2 } from "node:path";
|
|
7213
|
-
var SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
7214
|
-
function firegoatHome() {
|
|
7215
|
-
return process.env.FIREGOAT_HOME || join2(homedir(), ".firegoat");
|
|
7216
|
-
}
|
|
7217
|
-
function claudeConfigDir() {
|
|
7218
|
-
return join2(firegoatHome(), "claude");
|
|
7219
|
-
}
|
|
7220
|
-
var TOOLCHAINS = [
|
|
7221
|
-
".nvm",
|
|
7222
|
-
".fnm",
|
|
7223
|
-
".volta",
|
|
7224
|
-
".bun",
|
|
7225
|
-
".deno",
|
|
7226
|
-
".cargo",
|
|
7227
|
-
".rustup",
|
|
7228
|
-
".pyenv",
|
|
7229
|
-
".rbenv",
|
|
7230
|
-
".asdf",
|
|
7231
|
-
".sdkman",
|
|
7232
|
-
".local/share/mise",
|
|
7233
|
-
".local/share/fnm",
|
|
7234
|
-
".local/share/claude",
|
|
7235
|
-
".local/share/uv",
|
|
7236
|
-
"go",
|
|
7237
|
-
"Library/pnpm"
|
|
7238
|
-
];
|
|
7239
|
-
var TOOLCHAIN_SECRETS = [".cargo/credentials", ".cargo/credentials.toml"];
|
|
7240
|
-
function real(p) {
|
|
7241
|
-
return realpathSync(p);
|
|
7242
|
-
}
|
|
7243
|
-
function under(p, dir) {
|
|
7244
|
-
return p === dir || p.startsWith(`${dir}/`);
|
|
7245
|
-
}
|
|
7246
|
-
function readableToolDirs(home, claudeBin) {
|
|
7247
|
-
const dirs = new Set;
|
|
7248
|
-
const add = (p) => {
|
|
7249
|
-
if (existsSync2(p))
|
|
7250
|
-
dirs.add(real(p));
|
|
7251
|
-
};
|
|
7252
|
-
for (const t of TOOLCHAINS)
|
|
7253
|
-
add(join2(home, t));
|
|
7254
|
-
for (const p of (process.env.PATH ?? "").split(":")) {
|
|
7255
|
-
if (!p.startsWith("/") || !existsSync2(p))
|
|
7256
|
-
continue;
|
|
7257
|
-
const r = real(p);
|
|
7258
|
-
if (!under(r, home) || r === home)
|
|
7259
|
-
continue;
|
|
7260
|
-
const prefix = dirname2(r);
|
|
7261
|
-
dirs.add(basename(r) === "bin" && prefix !== home && prefix !== join2(home, ".local") ? prefix : r);
|
|
7262
|
-
}
|
|
7263
|
-
add(dirname2(dirname2(process.execPath)));
|
|
7264
|
-
add(dirname2(claudeBin));
|
|
7265
|
-
add(dirname2(real(claudeBin)));
|
|
7266
|
-
return [...dirs];
|
|
7267
|
-
}
|
|
7268
|
-
var q = (s) => JSON.stringify(s);
|
|
7269
|
-
function sandboxProfile(opts) {
|
|
7270
|
-
const home = real(homedir());
|
|
7271
|
-
const reads = readableToolDirs(home, opts.claudeBin).map((d) => `(subpath ${q(d)})`);
|
|
7272
|
-
return `(version 1)
|
|
7273
|
-
(allow default)
|
|
7274
|
-
|
|
7275
|
-
(deny file-read* (subpath ${q(home)}))
|
|
7276
|
-
(allow file-read-metadata)
|
|
7277
|
-
(allow file-read*
|
|
7278
|
-
${reads.join(`
|
|
7279
|
-
`)}
|
|
7280
|
-
(subpath ${q(join2(home, "Library/Keychains"))})
|
|
7281
|
-
(regex ${q(`^${home}/Library/Preferences/com\\.apple\\.security`)})
|
|
7282
|
-
(regex ${q(`^${home}/Library/Preferences/\\.GlobalPreferences`)}))
|
|
7283
|
-
(deny file-read* file-write*
|
|
7284
|
-
${TOOLCHAIN_SECRETS.map((s) => `(literal ${q(join2(home, s))})`).join(`
|
|
7285
|
-
`)})
|
|
7286
|
-
|
|
7287
|
-
(deny file-write* (subpath "/"))
|
|
7288
|
-
(allow file-write*
|
|
7289
|
-
(subpath "/dev")
|
|
7290
|
-
(subpath "/private/tmp")
|
|
7291
|
-
(subpath "/private/var/folders"))
|
|
7292
|
-
(allow file-read* file-write*
|
|
7293
|
-
(subpath ${q(real(opts.configDir))})
|
|
7294
|
-
(subpath ${q(real(opts.runRoot))}))
|
|
7295
|
-
|
|
7296
|
-
(deny file-read* file-write* (subpath ${q(real(opts.repo))}))
|
|
7297
|
-
`;
|
|
7298
|
-
}
|
|
7299
|
-
function agentEnv(runRoot) {
|
|
7300
|
-
const env = { ...process.env };
|
|
7301
|
-
delete env.ANTHROPIC_API_KEY;
|
|
7302
|
-
const cache = join2(runRoot, "cache");
|
|
7303
|
-
const tmp = join2(runRoot, "tmp");
|
|
7304
|
-
mkdirSync(cache, { recursive: true });
|
|
7305
|
-
mkdirSync(tmp, { recursive: true });
|
|
7306
|
-
return {
|
|
7307
|
-
...env,
|
|
7308
|
-
CLAUDE_CONFIG_DIR: claudeConfigDir(),
|
|
7309
|
-
TMPDIR: tmp,
|
|
7310
|
-
XDG_CACHE_HOME: cache,
|
|
7311
|
-
npm_config_cache: join2(cache, "npm"),
|
|
7312
|
-
npm_config_store_dir: join2(cache, "pnpm-store"),
|
|
7313
|
-
YARN_CACHE_FOLDER: join2(cache, "yarn"),
|
|
7314
|
-
BUN_INSTALL_CACHE_DIR: join2(cache, "bun"),
|
|
7315
|
-
PIP_CACHE_DIR: join2(cache, "pip"),
|
|
7316
|
-
UV_CACHE_DIR: join2(cache, "uv"),
|
|
7317
|
-
GOMODCACHE: join2(cache, "gomod"),
|
|
7318
|
-
GOCACHE: join2(cache, "gobuild"),
|
|
7319
|
-
CARGO_HOME: join2(cache, "cargo")
|
|
7320
|
-
};
|
|
7321
|
-
}
|
|
7322
|
-
function wrap(profile, cmd, args) {
|
|
7323
|
-
if (profile === null)
|
|
7324
|
-
return { cmd, args };
|
|
7325
|
-
return { cmd: SANDBOX_EXEC, args: ["-p", profile, cmd, ...args] };
|
|
7326
|
-
}
|
|
7327
|
-
function probeSandbox(opts) {
|
|
7328
|
-
const outside = join2(homedir(), `.firegoat-escape-probe-${process.pid}-${Date.now()}`);
|
|
7329
|
-
const script = 'touch "$1" 2>/dev/null && echo home-write; cat "$2" >/dev/null 2>&1 && echo repo-read; touch "$3" 2>/dev/null && echo work-write; rm -f "$1" "$3"';
|
|
7330
|
-
const { cmd, args } = wrap(opts.profile, "/bin/sh", [
|
|
7331
|
-
"-c",
|
|
7332
|
-
script,
|
|
7333
|
-
"probe",
|
|
7334
|
-
outside,
|
|
7335
|
-
opts.repoFile,
|
|
7336
|
-
join2(opts.workdir, ".firegoat-probe")
|
|
7337
|
-
]);
|
|
7338
|
-
const r = spawnSync(cmd, args, { encoding: "utf8", cwd: opts.workdir });
|
|
7339
|
-
if (r.error)
|
|
7340
|
-
return `sandbox probe could not start: ${r.error.message}`;
|
|
7341
|
-
const got = r.stdout.trim().split(`
|
|
7342
|
-
`).filter(Boolean);
|
|
7343
|
-
if (got.includes("home-write") || got.includes("repo-read"))
|
|
7344
|
-
return `sandbox does not hold (escaped: ${got.filter((g) => g !== "work-write").join(", ")}); refusing to run the agent`;
|
|
7345
|
-
if (!got.includes("work-write"))
|
|
7346
|
-
return `sandbox blocks writes in the workdir (stderr: ${r.stderr.trim() || "none"})`;
|
|
7347
|
-
return;
|
|
7348
|
-
}
|
|
7349
|
-
|
|
7350
|
-
// src/claude.ts
|
|
7351
7463
|
var CLAUDE_FLAGS = [
|
|
7352
7464
|
"--output-format",
|
|
7353
7465
|
"stream-json",
|
|
@@ -7512,8 +7624,8 @@ async function runClaude(opts) {
|
|
|
7512
7624
|
badStart = `claude output did not start with a system/init stream-json line (got: ${line.slice(0, 120)})`;
|
|
7513
7625
|
return "kill";
|
|
7514
7626
|
}
|
|
7515
|
-
if (msg.apiKeySource !==
|
|
7516
|
-
badStart = `claude would use API key billing (apiKeySource=${JSON.stringify(msg.apiKeySource)}) instead of your Claude
|
|
7627
|
+
if (msg.apiKeySource !== opts.keySource) {
|
|
7628
|
+
badStart = opts.keySource === "none" ? `claude would use API key billing (apiKeySource=${JSON.stringify(msg.apiKeySource)}) instead of your Claude login. firegoat removes ANTHROPIC_API_KEY from the agent's env; check apiKeyHelper or other API key settings, then run \`npx firegoat claude login\`` : `claude did not use the configured API key (apiKeySource=${JSON.stringify(msg.apiKeySource)}, expected "ANTHROPIC_API_KEY")`;
|
|
7517
7629
|
return "kill";
|
|
7518
7630
|
}
|
|
7519
7631
|
return;
|
|
@@ -7593,7 +7705,7 @@ async function runShell(opts) {
|
|
|
7593
7705
|
}
|
|
7594
7706
|
function checkLogin(opts) {
|
|
7595
7707
|
const { cmd, args } = wrap(opts.profile, opts.bin, ["auth", "status"]);
|
|
7596
|
-
const r =
|
|
7708
|
+
const r = spawnSync3(cmd, args, {
|
|
7597
7709
|
cwd: opts.cwd,
|
|
7598
7710
|
env: opts.env,
|
|
7599
7711
|
encoding: "utf8",
|
|
@@ -7608,16 +7720,13 @@ function checkLogin(opts) {
|
|
|
7608
7720
|
if (status?.loggedIn === true)
|
|
7609
7721
|
return;
|
|
7610
7722
|
if (status?.loggedIn === false)
|
|
7611
|
-
return `claude is not logged in to firegoat's config dir (${opts.env.CLAUDE_CONFIG_DIR}). Run: npx firegoat login`;
|
|
7723
|
+
return `claude is not logged in to firegoat's config dir (${opts.env.CLAUDE_CONFIG_DIR}). Run: npx firegoat claude login`;
|
|
7612
7724
|
return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
|
|
7613
7725
|
}
|
|
7614
7726
|
|
|
7615
|
-
// src/exit.ts
|
|
7616
|
-
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7617
|
-
|
|
7618
7727
|
// src/setup.ts
|
|
7619
|
-
import { cpSync, createWriteStream as createWriteStream2, mkdirSync as
|
|
7620
|
-
import { delimiter, join as
|
|
7728
|
+
import { cpSync, createWriteStream as createWriteStream2, mkdirSync as mkdirSync4 } from "node:fs";
|
|
7729
|
+
import { delimiter, join as join4 } from "node:path";
|
|
7621
7730
|
var SETUP_TIMEOUT_MS = 600000;
|
|
7622
7731
|
async function runAll(commands, cwd, env, logPath, what) {
|
|
7623
7732
|
const log = createWriteStream2(logPath);
|
|
@@ -7639,13 +7748,13 @@ async function runAll(commands, cwd, env, logPath, what) {
|
|
|
7639
7748
|
}
|
|
7640
7749
|
}
|
|
7641
7750
|
async function setupOnce(opts) {
|
|
7642
|
-
|
|
7751
|
+
mkdirSync4(opts.out, { recursive: true });
|
|
7643
7752
|
return runAll(opts.commands, opts.root, { ...opts.env, FIREGOAT_OUT: opts.out }, opts.logPath, "setup");
|
|
7644
7753
|
}
|
|
7645
7754
|
async function prepareRun(opts) {
|
|
7646
|
-
const out =
|
|
7755
|
+
const out = join4(opts.runRoot, "out");
|
|
7647
7756
|
if (opts.out === null)
|
|
7648
|
-
|
|
7757
|
+
mkdirSync4(out);
|
|
7649
7758
|
else
|
|
7650
7759
|
cpSync(opts.out, out, { recursive: true });
|
|
7651
7760
|
const path = [out, opts.env.PATH ?? ""].join(delimiter);
|
|
@@ -7660,7 +7769,7 @@ async function prepareRun(opts) {
|
|
|
7660
7769
|
|
|
7661
7770
|
// src/which.ts
|
|
7662
7771
|
import { accessSync, constants, statSync } from "node:fs";
|
|
7663
|
-
import { delimiter as delimiter2, join as
|
|
7772
|
+
import { delimiter as delimiter2, join as join5, resolve } from "node:path";
|
|
7664
7773
|
function resolveBin(bin) {
|
|
7665
7774
|
const executable = (p) => {
|
|
7666
7775
|
try {
|
|
@@ -7673,8 +7782,8 @@ function resolveBin(bin) {
|
|
|
7673
7782
|
if (bin.includes("/"))
|
|
7674
7783
|
return executable(bin) ? resolve(bin) : undefined;
|
|
7675
7784
|
for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
|
|
7676
|
-
if (dir && executable(
|
|
7677
|
-
return
|
|
7785
|
+
if (dir && executable(join5(dir, bin)))
|
|
7786
|
+
return join5(dir, bin);
|
|
7678
7787
|
}
|
|
7679
7788
|
return;
|
|
7680
7789
|
}
|
|
@@ -7736,16 +7845,16 @@ function newRunId() {
|
|
|
7736
7845
|
return `${ts}-${randomBytes(2).toString("hex")}`;
|
|
7737
7846
|
}
|
|
7738
7847
|
function gitignoreHasFiregoat(cwd) {
|
|
7739
|
-
const file =
|
|
7740
|
-
if (!
|
|
7848
|
+
const file = join6(cwd, ".gitignore");
|
|
7849
|
+
if (!existsSync4(file))
|
|
7741
7850
|
return false;
|
|
7742
|
-
return
|
|
7851
|
+
return readFileSync3(file, "utf8").split(`
|
|
7743
7852
|
`).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
|
|
7744
7853
|
}
|
|
7745
7854
|
function newRunRoot(label) {
|
|
7746
|
-
const runRoot = realpathSync2(mkdtempSync(
|
|
7747
|
-
const workdir =
|
|
7748
|
-
|
|
7855
|
+
const runRoot = realpathSync2(mkdtempSync(join6(tmpdir(), `firegoat-${label}-`)));
|
|
7856
|
+
const workdir = join6(runRoot, "work");
|
|
7857
|
+
mkdirSync5(workdir);
|
|
7749
7858
|
return { runRoot, workdir };
|
|
7750
7859
|
}
|
|
7751
7860
|
function isolate(iso, claudeBin, runRoot, workdir) {
|
|
@@ -7763,9 +7872,9 @@ function isolate(iso, claudeBin, runRoot, workdir) {
|
|
|
7763
7872
|
async function runOne(opts) {
|
|
7764
7873
|
const { test, n } = opts;
|
|
7765
7874
|
const started = Date.now();
|
|
7766
|
-
const dir =
|
|
7767
|
-
|
|
7768
|
-
const session =
|
|
7875
|
+
const dir = join6(opts.runDir, test.name, String(n));
|
|
7876
|
+
mkdirSync5(dir, { recursive: true });
|
|
7877
|
+
const session = join6(dir, "session.jsonl");
|
|
7769
7878
|
const summary = {
|
|
7770
7879
|
test: test.name,
|
|
7771
7880
|
n,
|
|
@@ -7798,7 +7907,7 @@ async function runOne(opts) {
|
|
|
7798
7907
|
workdir,
|
|
7799
7908
|
commands: opts.workdirSetup,
|
|
7800
7909
|
env: hostEnv,
|
|
7801
|
-
logPath:
|
|
7910
|
+
logPath: join6(dir, "workdir_setup.txt")
|
|
7802
7911
|
});
|
|
7803
7912
|
if ("error" in prepared)
|
|
7804
7913
|
return done("error", `not graded: ${prepared.error}`);
|
|
@@ -7807,13 +7916,17 @@ async function runOne(opts) {
|
|
|
7807
7916
|
}
|
|
7808
7917
|
const agentPath = extra.PATH ? { PATH: extra.PATH } : {};
|
|
7809
7918
|
const outcome = await runClaude({
|
|
7810
|
-
env: {
|
|
7919
|
+
env: {
|
|
7920
|
+
...agentEnv(runRoot, opts.auth.method === "api-key" ? opts.auth.apiKey : undefined),
|
|
7921
|
+
...agentPath
|
|
7922
|
+
},
|
|
7923
|
+
keySource: opts.auth.method === "api-key" ? "ANTHROPIC_API_KEY" : "none",
|
|
7811
7924
|
profile: sandbox.profile,
|
|
7812
7925
|
bin: opts.claudeBin,
|
|
7813
7926
|
prompt: render(test.prompt, opts.vars),
|
|
7814
7927
|
cwd: workdir,
|
|
7815
7928
|
sessionPath: session,
|
|
7816
|
-
stderrPath:
|
|
7929
|
+
stderrPath: join6(dir, "stderr.txt"),
|
|
7817
7930
|
timeoutMs: test.timeout * 1000
|
|
7818
7931
|
});
|
|
7819
7932
|
if (outcome.result) {
|
|
@@ -7826,7 +7939,7 @@ async function runOne(opts) {
|
|
|
7826
7939
|
opts.warn(`warning: ${test.name} has no checks, so its goal is unverified; counted as pass`);
|
|
7827
7940
|
return done("pass", "no checks (goal unverified)");
|
|
7828
7941
|
}
|
|
7829
|
-
const log = createWriteStream3(
|
|
7942
|
+
const log = createWriteStream3(join6(dir, "checks.txt"));
|
|
7830
7943
|
const env = {
|
|
7831
7944
|
...process.env,
|
|
7832
7945
|
...hostEnv,
|
|
@@ -7898,45 +8011,58 @@ async function evalCommand(args, cwd) {
|
|
|
7898
8011
|
}
|
|
7899
8012
|
if (opts.host)
|
|
7900
8013
|
warn("warning: --host: the agent runs without isolation and can read and write your files");
|
|
7901
|
-
else if (process.platform !== "darwin" || !
|
|
8014
|
+
else if (process.platform !== "darwin" || !existsSync4(SANDBOX_EXEC)) {
|
|
7902
8015
|
console.error(`firegoat eval: isolation needs macOS (${SANDBOX_EXEC}); this is ${process.platform}. In CI or Linux, run inside a throwaway container and pass --host.`);
|
|
7903
8016
|
return EXIT.usage;
|
|
7904
8017
|
}
|
|
7905
8018
|
const iso = {
|
|
7906
8019
|
host: opts.host,
|
|
7907
8020
|
repo: root,
|
|
7908
|
-
repoFile:
|
|
8021
|
+
repoFile: join6(root, "firegoat.yaml")
|
|
7909
8022
|
};
|
|
7910
|
-
|
|
8023
|
+
let auth;
|
|
8024
|
+
try {
|
|
8025
|
+
auth = resolveAuth(process.env);
|
|
8026
|
+
} catch (e) {
|
|
8027
|
+
if (e instanceof AuthError) {
|
|
8028
|
+
console.error(`firegoat eval: ${e.message}`);
|
|
8029
|
+
return EXIT.usage;
|
|
8030
|
+
}
|
|
8031
|
+
throw e;
|
|
8032
|
+
}
|
|
8033
|
+
say(`auth: ${auth.label}`);
|
|
8034
|
+
if (auth.method === "claude" && process.env.ANTHROPIC_API_KEY)
|
|
8035
|
+
warn("note: ANTHROPIC_API_KEY in your shell is not passed to the agent (it is used only in CI, or after `firegoat init --auth api-key`)");
|
|
8036
|
+
mkdirSync5(claudeConfigDir(), { recursive: true });
|
|
7911
8037
|
const pre = newRunRoot("preflight");
|
|
7912
8038
|
const preSandbox = isolate(iso, claudePath, pre.runRoot, pre.workdir);
|
|
7913
|
-
const loginProblem = "error" in preSandbox ? preSandbox.error : checkLogin({
|
|
8039
|
+
const loginProblem = "error" in preSandbox ? preSandbox.error : auth.method === "claude" ? checkLogin({
|
|
7914
8040
|
bin: claudePath,
|
|
7915
8041
|
env: agentEnv(pre.runRoot),
|
|
7916
8042
|
profile: preSandbox.profile,
|
|
7917
8043
|
cwd: pre.workdir
|
|
7918
|
-
});
|
|
8044
|
+
}) : undefined;
|
|
7919
8045
|
if (loginProblem) {
|
|
7920
8046
|
console.error(`firegoat eval: not graded: ${loginProblem}`);
|
|
7921
8047
|
return EXIT.notGraded;
|
|
7922
8048
|
}
|
|
7923
|
-
|
|
8049
|
+
mkdirSync5(join6(root, ".firegoat"), { recursive: true });
|
|
7924
8050
|
if (!gitignoreHasFiregoat(root))
|
|
7925
8051
|
warn("hint: add .firegoat/ to .gitignore (it holds run traces)");
|
|
7926
8052
|
const runId = newRunId();
|
|
7927
|
-
const runDir =
|
|
7928
|
-
|
|
7929
|
-
const summaryPath =
|
|
8053
|
+
const runDir = join6(root, ".firegoat", "runs", runId);
|
|
8054
|
+
mkdirSync5(runDir, { recursive: true });
|
|
8055
|
+
const summaryPath = join6(runDir, "summary.json");
|
|
7930
8056
|
let out = null;
|
|
7931
8057
|
if (config.setup.length > 0) {
|
|
7932
8058
|
say("setup...");
|
|
7933
|
-
out =
|
|
8059
|
+
out = join6(runDir, "out");
|
|
7934
8060
|
const error = await setupOnce({
|
|
7935
8061
|
commands: config.setup,
|
|
7936
8062
|
root,
|
|
7937
8063
|
out,
|
|
7938
8064
|
env: { ...process.env, ...vars },
|
|
7939
|
-
logPath:
|
|
8065
|
+
logPath: join6(runDir, "setup.txt")
|
|
7940
8066
|
});
|
|
7941
8067
|
if (error) {
|
|
7942
8068
|
console.error(`firegoat eval: not graded: ${error}`);
|
|
@@ -7952,10 +8078,11 @@ async function evalCommand(args, cwd) {
|
|
|
7952
8078
|
runId,
|
|
7953
8079
|
runsDir: runDir,
|
|
7954
8080
|
isolation: opts.host ? "host" : "seatbelt",
|
|
8081
|
+
auth: auth.method,
|
|
7955
8082
|
totals,
|
|
7956
8083
|
runs: results
|
|
7957
8084
|
};
|
|
7958
|
-
|
|
8085
|
+
writeFileSync2(summaryPath, `${JSON.stringify(doc, null, 2)}
|
|
7959
8086
|
`);
|
|
7960
8087
|
return doc;
|
|
7961
8088
|
};
|
|
@@ -7969,6 +8096,7 @@ async function evalCommand(args, cwd) {
|
|
|
7969
8096
|
runDir,
|
|
7970
8097
|
claudeBin: claudePath,
|
|
7971
8098
|
iso,
|
|
8099
|
+
auth,
|
|
7972
8100
|
out,
|
|
7973
8101
|
workdirSetup: config.workdirSetup,
|
|
7974
8102
|
vars,
|
|
@@ -7981,7 +8109,7 @@ async function evalCommand(args, cwd) {
|
|
|
7981
8109
|
status: "error",
|
|
7982
8110
|
reason: `not graded: firegoat crashed: ${e.message}`,
|
|
7983
8111
|
workdir: null,
|
|
7984
|
-
session:
|
|
8112
|
+
session: join6(runDir, test.name, String(n), "session.jsonl"),
|
|
7985
8113
|
checks: [],
|
|
7986
8114
|
num_turns: null,
|
|
7987
8115
|
cost: null,
|
|
@@ -8004,21 +8132,36 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
|
|
|
8004
8132
|
}
|
|
8005
8133
|
|
|
8006
8134
|
// src/init.ts
|
|
8007
|
-
import {
|
|
8008
|
-
import {
|
|
8135
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
8136
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
8137
|
+
import { basename as basename2, join as join7 } from "node:path";
|
|
8009
8138
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
8010
|
-
import {
|
|
8011
|
-
var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills]
|
|
8139
|
+
import { Writable } from "node:stream";
|
|
8140
|
+
var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills] [--auth claude|api-key]
|
|
8012
8141
|
|
|
8013
8142
|
--yes, -y don't ask questions (default when stdin is not a terminal)
|
|
8014
8143
|
--force, -f overwrite an existing ${CONFIG_FILE}
|
|
8015
|
-
--skills
|
|
8144
|
+
--skills install the firegoat-onboarding and suggesting-eval-prompts skills
|
|
8145
|
+
into .claude/skills/ (npx skills add team2027/skills)
|
|
8146
|
+
--auth claude how the agent signs in: run \`firegoat claude login\` (your Claude plan)
|
|
8147
|
+
--auth api-key store an Anthropic API key for firegoat only (~/.firegoat, mode 0600),
|
|
8148
|
+
read from stdin when piped, else asked for without echo
|
|
8149
|
+
Without --auth, init asks (unless --yes). In CI, ANTHROPIC_API_KEY in env wins.
|
|
8016
8150
|
`;
|
|
8017
|
-
var
|
|
8151
|
+
var SKILLS = ["firegoat-onboarding", "suggesting-eval-prompts"];
|
|
8152
|
+
var SKILLS_ARGV = [
|
|
8153
|
+
"skills",
|
|
8154
|
+
"add",
|
|
8155
|
+
"team2027/skills",
|
|
8156
|
+
...SKILLS.flatMap((s) => ["--skill", s]),
|
|
8157
|
+
"--agent",
|
|
8158
|
+
"claude-code",
|
|
8159
|
+
"-y"
|
|
8160
|
+
];
|
|
8018
8161
|
var GITIGNORE_LINE = ".firegoat/";
|
|
8019
8162
|
function readText(file) {
|
|
8020
8163
|
try {
|
|
8021
|
-
return
|
|
8164
|
+
return readFileSync4(file, "utf8");
|
|
8022
8165
|
} catch {
|
|
8023
8166
|
return;
|
|
8024
8167
|
}
|
|
@@ -8044,9 +8187,9 @@ function tomlSections(text) {
|
|
|
8044
8187
|
return sections;
|
|
8045
8188
|
}
|
|
8046
8189
|
function detect(cwd) {
|
|
8047
|
-
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) =>
|
|
8190
|
+
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync5(join7(cwd, f)));
|
|
8048
8191
|
const fallback = { name: basename2(cwd), library: true, readme };
|
|
8049
|
-
const pkgText = readText(
|
|
8192
|
+
const pkgText = readText(join7(cwd, "package.json"));
|
|
8050
8193
|
if (pkgText !== undefined) {
|
|
8051
8194
|
try {
|
|
8052
8195
|
const pkg = JSON.parse(pkgText);
|
|
@@ -8061,7 +8204,7 @@ function detect(cwd) {
|
|
|
8061
8204
|
return { ecosystem: "npm", name, bin, library, readme, docsUrl };
|
|
8062
8205
|
} catch {}
|
|
8063
8206
|
}
|
|
8064
|
-
const pyText = readText(
|
|
8207
|
+
const pyText = readText(join7(cwd, "pyproject.toml"));
|
|
8065
8208
|
if (pyText !== undefined) {
|
|
8066
8209
|
const s = tomlSections(pyText);
|
|
8067
8210
|
const name = s.get("project")?.get("name") ?? s.get("tool.poetry")?.get("name") ?? fallback.name;
|
|
@@ -8071,12 +8214,12 @@ function detect(cwd) {
|
|
|
8071
8214
|
const docsUrl = urls?.get("Documentation") ?? urls?.get("documentation") ?? urls?.get("Homepage") ?? urls?.get("homepage");
|
|
8072
8215
|
return { ecosystem: "python", name, bin, library: true, readme, docsUrl };
|
|
8073
8216
|
}
|
|
8074
|
-
const goText = readText(
|
|
8217
|
+
const goText = readText(join7(cwd, "go.mod"));
|
|
8075
8218
|
if (goText !== undefined) {
|
|
8076
8219
|
const name = goText.match(/^module\s+(\S+)/m)?.[1] ?? fallback.name;
|
|
8077
8220
|
return { ecosystem: "go", name, library: true, readme };
|
|
8078
8221
|
}
|
|
8079
|
-
const cargoText = readText(
|
|
8222
|
+
const cargoText = readText(join7(cwd, "Cargo.toml"));
|
|
8080
8223
|
if (cargoText !== undefined) {
|
|
8081
8224
|
const name = tomlSections(cargoText).get("package")?.get("name");
|
|
8082
8225
|
return {
|
|
@@ -8201,10 +8344,10 @@ function renderYaml(p, tests) {
|
|
|
8201
8344
|
`;
|
|
8202
8345
|
}
|
|
8203
8346
|
function ensureGitignore(cwd) {
|
|
8204
|
-
const file =
|
|
8347
|
+
const file = join7(cwd, ".gitignore");
|
|
8205
8348
|
const text = readText(file);
|
|
8206
8349
|
if (text === undefined) {
|
|
8207
|
-
|
|
8350
|
+
writeFileSync3(file, `${GITIGNORE_LINE}
|
|
8208
8351
|
`);
|
|
8209
8352
|
return `created .gitignore with ${GITIGNORE_LINE}`;
|
|
8210
8353
|
}
|
|
@@ -8214,12 +8357,83 @@ function ensureGitignore(cwd) {
|
|
|
8214
8357
|
const sep = text === "" || text.endsWith(`
|
|
8215
8358
|
`) ? "" : `
|
|
8216
8359
|
`;
|
|
8217
|
-
|
|
8360
|
+
writeFileSync3(file, `${text}${sep}${GITIGNORE_LINE}
|
|
8218
8361
|
`);
|
|
8219
8362
|
return `added ${GITIGNORE_LINE} to .gitignore`;
|
|
8220
8363
|
}
|
|
8221
|
-
function
|
|
8222
|
-
|
|
8364
|
+
function installSkills(cwd) {
|
|
8365
|
+
const r = spawnSync4("npx", ["-y", ...SKILLS_ARGV], {
|
|
8366
|
+
cwd,
|
|
8367
|
+
stdio: "inherit"
|
|
8368
|
+
});
|
|
8369
|
+
if (r.error)
|
|
8370
|
+
return r.error.message;
|
|
8371
|
+
if (r.status !== 0)
|
|
8372
|
+
return `exit ${r.status ?? "by signal"}`;
|
|
8373
|
+
return;
|
|
8374
|
+
}
|
|
8375
|
+
async function ask(question) {
|
|
8376
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
8377
|
+
try {
|
|
8378
|
+
return (await rl.question(question)).trim();
|
|
8379
|
+
} finally {
|
|
8380
|
+
rl.close();
|
|
8381
|
+
}
|
|
8382
|
+
}
|
|
8383
|
+
async function readSecret(question) {
|
|
8384
|
+
if (!process.stdin.isTTY) {
|
|
8385
|
+
const chunks = [];
|
|
8386
|
+
for await (const c of process.stdin)
|
|
8387
|
+
chunks.push(c);
|
|
8388
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
8389
|
+
}
|
|
8390
|
+
process.stdout.write(question);
|
|
8391
|
+
const silent = new Writable({ write: (_c, _e, cb) => cb() });
|
|
8392
|
+
const rl = createInterface2({
|
|
8393
|
+
input: process.stdin,
|
|
8394
|
+
output: silent,
|
|
8395
|
+
terminal: true
|
|
8396
|
+
});
|
|
8397
|
+
try {
|
|
8398
|
+
return (await rl.question("")).trim();
|
|
8399
|
+
} finally {
|
|
8400
|
+
rl.close();
|
|
8401
|
+
process.stdout.write(`
|
|
8402
|
+
`);
|
|
8403
|
+
}
|
|
8404
|
+
}
|
|
8405
|
+
async function chooseAuth() {
|
|
8406
|
+
let current;
|
|
8407
|
+
try {
|
|
8408
|
+
current = resolveAuth({}).label;
|
|
8409
|
+
} catch (e) {
|
|
8410
|
+
current = e.message;
|
|
8411
|
+
}
|
|
8412
|
+
console.log(`How should the agent sign in? (now: ${current})
|
|
8413
|
+
1) Login with Claude (your Claude plan)
|
|
8414
|
+
2) Paste an Anthropic API key`);
|
|
8415
|
+
for (;; ) {
|
|
8416
|
+
const a = await ask("choose [1/2]: ");
|
|
8417
|
+
if (a === "1")
|
|
8418
|
+
return "claude";
|
|
8419
|
+
if (a === "2")
|
|
8420
|
+
return "api-key";
|
|
8421
|
+
console.log("type 1 or 2");
|
|
8422
|
+
}
|
|
8423
|
+
}
|
|
8424
|
+
async function setUpAuth(method) {
|
|
8425
|
+
if (method === "claude") {
|
|
8426
|
+
const code = claudeLoginCommand([]);
|
|
8427
|
+
return code === 0 ? undefined : code;
|
|
8428
|
+
}
|
|
8429
|
+
const key = await readSecret("Anthropic API key (input hidden): ");
|
|
8430
|
+
if (!looksLikeAnthropicKey(key)) {
|
|
8431
|
+
console.error("firegoat init: that does not look like an Anthropic API key (sk-ant-...); nothing stored");
|
|
8432
|
+
return EXIT.usage;
|
|
8433
|
+
}
|
|
8434
|
+
const file = saveApiKey(key);
|
|
8435
|
+
console.log(`stored the API key in ${file} (mode 0600, not in the repo); evals pass it to the agent as ANTHROPIC_API_KEY`);
|
|
8436
|
+
return;
|
|
8223
8437
|
}
|
|
8224
8438
|
async function askYesNo(question) {
|
|
8225
8439
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
@@ -8244,7 +8458,18 @@ async function initCommand(args, cwd) {
|
|
|
8244
8458
|
let yes = false;
|
|
8245
8459
|
let force = false;
|
|
8246
8460
|
let skills = false;
|
|
8247
|
-
|
|
8461
|
+
let auth;
|
|
8462
|
+
for (let i = 0;i < args.length; i++) {
|
|
8463
|
+
const a = args[i];
|
|
8464
|
+
if (a === "--auth" || a.startsWith("--auth=")) {
|
|
8465
|
+
const v = a === "--auth" ? args[++i] : a.slice(7);
|
|
8466
|
+
if (v !== "claude" && v !== "api-key") {
|
|
8467
|
+
console.error(`firegoat init: --auth must be claude or api-key, got ${v ?? "nothing"}`);
|
|
8468
|
+
return EXIT.usage;
|
|
8469
|
+
}
|
|
8470
|
+
auth = v;
|
|
8471
|
+
continue;
|
|
8472
|
+
}
|
|
8248
8473
|
switch (a) {
|
|
8249
8474
|
case "--yes":
|
|
8250
8475
|
case "-y":
|
|
@@ -8268,9 +8493,9 @@ ${INIT_USAGE}`);
|
|
|
8268
8493
|
return EXIT.usage;
|
|
8269
8494
|
}
|
|
8270
8495
|
}
|
|
8271
|
-
const configPath =
|
|
8272
|
-
const keepConfig =
|
|
8273
|
-
if (keepConfig && !skills) {
|
|
8496
|
+
const configPath = join7(cwd, CONFIG_FILE);
|
|
8497
|
+
const keepConfig = existsSync5(configPath) && !force;
|
|
8498
|
+
if (keepConfig && !skills && !auth) {
|
|
8274
8499
|
console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
|
|
8275
8500
|
return EXIT.usage;
|
|
8276
8501
|
}
|
|
@@ -8280,65 +8505,38 @@ ${INIT_USAGE}`);
|
|
|
8280
8505
|
const project = detect(cwd);
|
|
8281
8506
|
const tests = templates(project);
|
|
8282
8507
|
console.log(`detected: ${describe(project)}`);
|
|
8283
|
-
|
|
8508
|
+
writeFileSync3(configPath, renderYaml(project, tests));
|
|
8284
8509
|
console.log(`wrote ${CONFIG_FILE} (${tests.length} test${tests.length === 1 ? "" : "s"}: ${tests.map((t) => t.name).join(", ")}); edit the prompts to fit your users`);
|
|
8285
8510
|
}
|
|
8286
8511
|
console.log(ensureGitignore(cwd));
|
|
8287
8512
|
const interactive = !yes && process.stdin.isTTY && process.stdout.isTTY;
|
|
8513
|
+
if (!auth && interactive)
|
|
8514
|
+
auth = await chooseAuth();
|
|
8515
|
+
if (auth) {
|
|
8516
|
+
const failed = await setUpAuth(auth);
|
|
8517
|
+
if (failed !== undefined)
|
|
8518
|
+
return failed;
|
|
8519
|
+
}
|
|
8288
8520
|
if (!skills && interactive)
|
|
8289
|
-
skills = await askYesNo(`Add the ${
|
|
8521
|
+
skills = await askYesNo(`Add the ${SKILLS.join(" and ")} skills to .claude/skills/ (help your agent draft better evals)?`);
|
|
8290
8522
|
if (skills) {
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8523
|
+
console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
|
|
8524
|
+
const failed = installSkills(cwd);
|
|
8525
|
+
if (failed) {
|
|
8526
|
+
console.error(`firegoat init: installing the skills failed (${failed}). Run it yourself: npx ${SKILLS_ARGV.join(" ")}`);
|
|
8527
|
+
return EXIT.notGraded;
|
|
8528
|
+
}
|
|
8294
8529
|
} else {
|
|
8295
|
-
console.log(`hint: \`npx firegoat init --skills\` adds the ${
|
|
8530
|
+
console.log(`hint: \`npx firegoat init --skills\` adds the ${SKILLS.join(" and ")} skills to .claude/skills/ (keeps your ${CONFIG_FILE})`);
|
|
8296
8531
|
}
|
|
8297
8532
|
console.log("next: npx firegoat eval");
|
|
8298
8533
|
return 0;
|
|
8299
8534
|
}
|
|
8300
8535
|
|
|
8301
|
-
// src/login.ts
|
|
8302
|
-
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
8303
|
-
import { mkdirSync as mkdirSync4 } from "node:fs";
|
|
8304
|
-
function loginCommand(args) {
|
|
8305
|
-
let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
|
|
8306
|
-
for (let i = 0;i < args.length; i++) {
|
|
8307
|
-
const a = args[i];
|
|
8308
|
-
if (a === "--claude-bin" && args[i + 1] !== undefined)
|
|
8309
|
-
claudeBin = args[++i];
|
|
8310
|
-
else if (a.startsWith("--claude-bin="))
|
|
8311
|
-
claudeBin = a.slice(13);
|
|
8312
|
-
else {
|
|
8313
|
-
console.error(`firegoat login: unknown argument: ${a}`);
|
|
8314
|
-
return EXIT.usage;
|
|
8315
|
-
}
|
|
8316
|
-
}
|
|
8317
|
-
const configDir = claudeConfigDir();
|
|
8318
|
-
mkdirSync4(configDir, { recursive: true });
|
|
8319
|
-
const env = {
|
|
8320
|
-
...process.env,
|
|
8321
|
-
CLAUDE_CONFIG_DIR: configDir
|
|
8322
|
-
};
|
|
8323
|
-
delete env.ANTHROPIC_API_KEY;
|
|
8324
|
-
console.error(`Signing claude in to ${configDir}. Evals run with this login, never your own ~/.claude.`);
|
|
8325
|
-
const r = spawnSync3(claudeBin, ["auth", "login"], { env, stdio: "inherit" });
|
|
8326
|
-
if (r.error) {
|
|
8327
|
-
console.error(`firegoat login: could not run ${claudeBin}: ${r.error.message}`);
|
|
8328
|
-
return EXIT.notGraded;
|
|
8329
|
-
}
|
|
8330
|
-
if (r.status !== 0) {
|
|
8331
|
-
console.error(`firegoat login: claude auth login exited ${r.status}`);
|
|
8332
|
-
return EXIT.notGraded;
|
|
8333
|
-
}
|
|
8334
|
-
console.log("logged in. next: npx firegoat eval");
|
|
8335
|
-
return EXIT.pass;
|
|
8336
|
-
}
|
|
8337
|
-
|
|
8338
8536
|
// src/open.ts
|
|
8339
|
-
import { spawnSync as
|
|
8340
|
-
import { existsSync as
|
|
8341
|
-
import { join as
|
|
8537
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
8538
|
+
import { existsSync as existsSync6, readdirSync } from "node:fs";
|
|
8539
|
+
import { join as join8 } from "node:path";
|
|
8342
8540
|
var OPENER = {
|
|
8343
8541
|
darwin: "open",
|
|
8344
8542
|
linux: "xdg-open"
|
|
@@ -8359,8 +8557,8 @@ function openCommand(args, cwd) {
|
|
|
8359
8557
|
console.error("firegoat open: takes at most one run id");
|
|
8360
8558
|
return EXIT.usage;
|
|
8361
8559
|
}
|
|
8362
|
-
const base =
|
|
8363
|
-
const runs =
|
|
8560
|
+
const base = join8(findConfigDir(cwd), ".firegoat", "runs");
|
|
8561
|
+
const runs = existsSync6(base) ? readdirSync(base).sort() : [];
|
|
8364
8562
|
const id = ids[0] ?? runs.at(-1);
|
|
8365
8563
|
if (!id) {
|
|
8366
8564
|
console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
|
|
@@ -8370,12 +8568,12 @@ function openCommand(args, cwd) {
|
|
|
8370
8568
|
console.error(`firegoat open: no run ${JSON.stringify(id)} in ${base} (latest: ${runs.at(-1) ?? "none"})`);
|
|
8371
8569
|
return EXIT.usage;
|
|
8372
8570
|
}
|
|
8373
|
-
const dir =
|
|
8571
|
+
const dir = join8(base, id);
|
|
8374
8572
|
console.log(dir);
|
|
8375
8573
|
const opener = OPENER[process.platform];
|
|
8376
8574
|
if (print || !opener)
|
|
8377
8575
|
return EXIT.pass;
|
|
8378
|
-
const r =
|
|
8576
|
+
const r = spawnSync5(opener, [dir], { stdio: "inherit" });
|
|
8379
8577
|
if (r.error || r.status !== 0) {
|
|
8380
8578
|
console.error(`firegoat open: ${opener} failed (${r.error?.message ?? `exit ${r.status}`}); the run is at the path above`);
|
|
8381
8579
|
return EXIT.notGraded;
|
|
@@ -8387,8 +8585,9 @@ function openCommand(args, cwd) {
|
|
|
8387
8585
|
var USAGE = `firegoat ${package_default.version}: local-first AX evals
|
|
8388
8586
|
|
|
8389
8587
|
Usage:
|
|
8390
|
-
firegoat init [--yes] [--force] [--skills]
|
|
8391
|
-
|
|
8588
|
+
firegoat init [--yes] [--force] [--skills] [--auth claude|api-key]
|
|
8589
|
+
write firegoat.yaml, choose how the agent signs in
|
|
8590
|
+
firegoat claude login [--claude-bin PATH] sign claude in to firegoat's own config dir (once)
|
|
8392
8591
|
firegoat eval [test..] [--runs N] [--var KEY=value].. [--json] [--host] [--claude-bin PATH]
|
|
8393
8592
|
run evals with claude in a sandboxed fresh workdir
|
|
8394
8593
|
firegoat open [runId] [--print] open a run's folder (default: the latest)
|
|
@@ -8424,8 +8623,14 @@ async function main(argv) {
|
|
|
8424
8623
|
return 0;
|
|
8425
8624
|
case "init":
|
|
8426
8625
|
return initCommand(rest, process.cwd());
|
|
8626
|
+
case "claude":
|
|
8627
|
+
if (rest[0] === "login")
|
|
8628
|
+
return claudeLoginCommand(rest.slice(1));
|
|
8629
|
+
console.error(`usage: firegoat claude login [--claude-bin PATH]`);
|
|
8630
|
+
return EXIT.usage;
|
|
8427
8631
|
case "login":
|
|
8428
|
-
|
|
8632
|
+
console.error("firegoat login is deprecated for the Claude sign-in: use `firegoat claude login`");
|
|
8633
|
+
return claudeLoginCommand(rest);
|
|
8429
8634
|
case "eval":
|
|
8430
8635
|
return evalCommand(rest, process.cwd());
|
|
8431
8636
|
case "open":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firegoat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/team2027/firegoat-cli.git"
|
|
@@ -21,8 +21,7 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
|
-
"dist"
|
|
25
|
-
"skills"
|
|
24
|
+
"dist"
|
|
26
25
|
],
|
|
27
26
|
"homepage": "https://github.com/team2027/firegoat-cli#readme",
|
|
28
27
|
"license": "MIT",
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: firegoat-onboarding
|
|
3
|
-
description: Draft 2-3 firegoat evals (firegoat.yaml) for the current repo by reading its README, docs and package manifests, phrasing each test as a real user's problem with a gradeable goal and script checks. Use when the user says "write firegoat evals", "draft evals", "set up firegoat", "firegoat init", or asks for agent-experience (AX) evals of their SDK, CLI, API or docs.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Draft firegoat evals
|
|
7
|
-
|
|
8
|
-
firegoat runs a coding agent (Claude Code) in an empty, isolated workdir with one prompt,
|
|
9
|
-
then grades the run. Your job: write 2-3 tests into `firegoat.yaml` that tell the
|
|
10
|
-
maintainer whether an agent can succeed with their product the way a new user would.
|
|
11
|
-
|
|
12
|
-
## 1. Learn the product (read, don't run)
|
|
13
|
-
|
|
14
|
-
Read, in this order, and stop when you can answer the questions below:
|
|
15
|
-
- `README*`, then the docs dir (`docs/`, `website/`, `*.mdx`), quickstart/getting-started pages first.
|
|
16
|
-
- Package manifests: `package.json` (`name`, `bin`, `exports`), `pyproject.toml`, `go.mod`,
|
|
17
|
-
`Cargo.toml`, `*.gemspec`, `Package.swift`. They tell you the install name and entry points.
|
|
18
|
-
- `examples/`, `templates/`, `samples/`: the shapes users actually build.
|
|
19
|
-
- An existing `firegoat.yaml`, if any: keep its tests (see step 5).
|
|
20
|
-
|
|
21
|
-
Answer: What is it (SDK, CLI, API, framework)? Who uses it? What is the first thing a new
|
|
22
|
-
user wants to have working? What is the public docs URL? Which credentials does it need
|
|
23
|
-
(env var names only)?
|
|
24
|
-
|
|
25
|
-
## 2. Pick 2-3 outcomes
|
|
26
|
-
|
|
27
|
-
Choose the most important first-time user outcomes, e.g. "first successful API call",
|
|
28
|
-
"the core feature end to end", "the thing users most often ask about". Prefer outcomes
|
|
29
|
-
that leave observable proof. Skip admin, billing and anything needing a browser login
|
|
30
|
-
unless credentials can come from env vars.
|
|
31
|
-
|
|
32
|
-
## 3. Write each prompt as a user's problem
|
|
33
|
-
|
|
34
|
-
A prompt is what a user would type to their agent, not a recipe. Each prompt has:
|
|
35
|
-
- **What they want, not how.** No numbered implementation steps, no "use template X, add package Y".
|
|
36
|
-
The eval measures whether the agent finds its way with your docs.
|
|
37
|
-
- **One concrete outcome**, small enough to finish in a few minutes.
|
|
38
|
-
- **Observable proof**: print a marker to stdout, write a file, or take a screenshot.
|
|
39
|
-
- **"Keep it minimal."** so the agent doesn't build a demo app.
|
|
40
|
-
- **The docs URL as the only hint** (omit it if the test is about discovery).
|
|
41
|
-
- **Credentials as env var names**, never values: "Credentials are in env vars ACME_API_KEY and ACME_API_URL."
|
|
42
|
-
- If the test should use the build under test, say so: "The package is already installed
|
|
43
|
-
from the build under test. Do not install or upgrade it from the registry."
|
|
44
|
-
|
|
45
|
-
## 4. Write the goal and checks
|
|
46
|
-
|
|
47
|
-
`goal`: one sentence a judge could check from the transcript and workdir, e.g. "A minimal
|
|
48
|
-
project runs and prints the answer, a numeric score and a reason."
|
|
49
|
-
|
|
50
|
-
`checks`: shell scripts, run in the agent's workdir after the run. Exit code 0 = pass.
|
|
51
|
-
Env: `FIREGOAT_WORKDIR` (the workdir) and `FIREGOAT_SESSION` (the agent's stream-json
|
|
52
|
-
transcript, one JSON event per line). Checks should prove the outcome and catch cheating:
|
|
53
|
-
- Output exists with the expected marker: `grep -rq hello-from-acme "$FIREGOAT_WORKDIR"`
|
|
54
|
-
or `test -s "$FIREGOAT_WORKDIR/screenshot.png"`.
|
|
55
|
-
- The real thing was used, not faked: the transcript shows the SDK/CLI being called,
|
|
56
|
-
e.g. `grep -q '"command":"[^"]*acme ' "$FIREGOAT_SESSION"`.
|
|
57
|
-
- Cleanup happened: the transcript shows the delete call after the create call.
|
|
58
|
-
- The local build was used, not a registry install:
|
|
59
|
-
`! grep -Eq '"command":"[^"]*(pip install|npm (install|i) [^"]*acme@)' "$FIREGOAT_SESSION"`.
|
|
60
|
-
- Only local docs were used (when that's the point): `! grep -q '"name":"WebFetch"' "$FIREGOAT_SESSION"`.
|
|
61
|
-
|
|
62
|
-
Match tool-call JSON (`"command":"...`) rather than plain words, so the prompt text or the
|
|
63
|
-
agent saying "I won't pip install" doesn't trip the check. Checks run in the agent's
|
|
64
|
-
workdir, which does not contain the repo, so keep them self-contained: one-liners, or a
|
|
65
|
-
`run: |` block for longer scripts. Never reference repo files by relative path.
|
|
66
|
-
|
|
67
|
-
Do not emit `judge:` checks: v1 skips them with a warning.
|
|
68
|
-
|
|
69
|
-
## 5. Write firegoat.yaml
|
|
70
|
-
|
|
71
|
-
Schema (v1):
|
|
72
|
-
|
|
73
|
-
```yaml
|
|
74
|
-
agents: [claude-code] # only claude-code in v1
|
|
75
|
-
runs: 1 # runs per test, positive integer
|
|
76
|
-
setup: npm pack --pack-destination "$FIREGOAT_OUT" # optional: once per eval, in the repo;
|
|
77
|
-
# $FIREGOAT_OUT is copied into each run and put on PATH
|
|
78
|
-
workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
79
|
-
# optional: in each fresh workdir before the agent starts
|
|
80
|
-
vars: # optional: defaults for {{vars.KEY}}; `--var KEY=value` overrides
|
|
81
|
-
SHA: main
|
|
82
|
-
tests:
|
|
83
|
-
- name: quickstart # letters, digits, '.', '_', '-'; unique
|
|
84
|
-
prompt: >-
|
|
85
|
-
...
|
|
86
|
-
goal: ...
|
|
87
|
-
checks: # optional
|
|
88
|
-
- run: grep -rq some-marker "$FIREGOAT_WORKDIR"
|
|
89
|
-
timeout: 600 # optional, seconds
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
Add `setup:` / `workdir_setup:` when the tests should use the repo's local build (python:
|
|
93
|
-
`uv build --wheel --out-dir "$FIREGOAT_OUT"` and `uv venv && uv pip install "$FIREGOAT_OUT"/*.whl`);
|
|
94
|
-
then the prompt can say "The package is already installed from the build under test."
|
|
95
|
-
|
|
96
|
-
If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append
|
|
97
|
-
yours, and rename yours (`-2` suffix) if a name collides. Never delete or rewrite user tests.
|
|
98
|
-
Quote prompts with `>-` folded blocks so colons and quotes stay valid YAML.
|
|
99
|
-
|
|
100
|
-
Before finishing, re-read the file and check: it parses as YAML, every test has a non-empty
|
|
101
|
-
`name`, `prompt` and `goal`, names are unique and match `^[A-Za-z0-9._-]+$`, and no credential
|
|
102
|
-
values appear anywhere.
|
|
103
|
-
|
|
104
|
-
## 6. Hand off
|
|
105
|
-
|
|
106
|
-
Tell the user, in a few lines: which tests you added and why, which env vars they must export,
|
|
107
|
-
and to run `npx firegoat eval`.
|
|
108
|
-
|
|
109
|
-
## Examples
|
|
110
|
-
|
|
111
|
-
See `references/examples.md` for good and bad tests with their YAML. Short version:
|
|
112
|
-
|
|
113
|
-
Good: "Create a new app - a coffee timer. Run it on the simulator and get the screenshot."
|
|
114
|
-
Bad: "Create a sandbox. https://docs.acme.dev" with no goal and no checks: every run passes,
|
|
115
|
-
so it measures nothing.
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# Example firegoat tests
|
|
2
|
-
|
|
3
|
-
Product names are fictional. Copy the shape, not the words.
|
|
4
|
-
|
|
5
|
-
## Good
|
|
6
|
-
|
|
7
|
-
### 1. Core feature end to end, printed proof
|
|
8
|
-
|
|
9
|
-
An agent framework with scorers. The prompt names the outcome and what to print; the agent
|
|
10
|
-
has to find how in the docs.
|
|
11
|
-
|
|
12
|
-
```yaml
|
|
13
|
-
tests:
|
|
14
|
-
- name: agent-with-scorer
|
|
15
|
-
prompt: >-
|
|
16
|
-
Build an agent with a scorer using https://docs.acme.dev/agents. Ask the agent
|
|
17
|
-
to explain recursion in one sentence, then run a scorer that grades whether the
|
|
18
|
-
answer is concise and accurate. Print the agent answer, the numeric score and
|
|
19
|
-
the scorer reason to stdout. Keep it minimal.
|
|
20
|
-
goal: >-
|
|
21
|
-
A minimal project runs and prints the agent's answer, a numeric score and the
|
|
22
|
-
scorer's reason.
|
|
23
|
-
checks:
|
|
24
|
-
- run: grep -Eq '"command":"[^"]*(node|npx|tsx|bun) ' "$FIREGOAT_SESSION"
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### 2. Multi-step workflow, one realistic input
|
|
28
|
-
|
|
29
|
-
```yaml
|
|
30
|
-
tests:
|
|
31
|
-
- name: support-workflow
|
|
32
|
-
prompt: >-
|
|
33
|
-
Using https://docs.acme.dev/workflows, build a three-step workflow: extract the
|
|
34
|
-
issue type from a support message, have an agent draft a response, then add a
|
|
35
|
-
priority label. Run it once on a customer asking for a refund and print the
|
|
36
|
-
final result. Keep it minimal.
|
|
37
|
-
goal: >-
|
|
38
|
-
The workflow runs once on a refund request and prints a result containing the
|
|
39
|
-
issue type, a drafted response and a priority label.
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### 3. Local build under test, cleanup, no registry install
|
|
43
|
-
|
|
44
|
-
```yaml
|
|
45
|
-
tests:
|
|
46
|
-
- name: sandbox-hello
|
|
47
|
-
prompt: >-
|
|
48
|
-
Using the Acme Python SDK, create a sandbox, run `echo hello-from-acme` inside
|
|
49
|
-
it, print the output, then delete the sandbox. The package is already installed
|
|
50
|
-
from the build under test. Do not install or upgrade it from PyPI.
|
|
51
|
-
Docs: https://docs.acme.dev/python. Credentials are in env vars ACME_API_KEY
|
|
52
|
-
and ACME_API_URL.
|
|
53
|
-
goal: >-
|
|
54
|
-
The script prints hello-from-acme from inside a sandbox and deletes the sandbox
|
|
55
|
-
at the end.
|
|
56
|
-
checks:
|
|
57
|
-
- run: grep -Eq '\.(delete|remove)\(' -r "$FIREGOAT_WORKDIR" --include='*.py'
|
|
58
|
-
- run: "! grep -Eq '\"command\":\"[^\"]*pip3? install[^\"]*acme' \"$FIREGOAT_SESSION\""
|
|
59
|
-
timeout: 900
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### 4. Pure user need, discovery
|
|
63
|
-
|
|
64
|
-
No docs URL, no package names: the test is whether the agent finds the right packages.
|
|
65
|
-
|
|
66
|
-
```yaml
|
|
67
|
-
tests:
|
|
68
|
-
- name: which-packages
|
|
69
|
-
prompt: >-
|
|
70
|
-
I'm building a mobile app with Acme. It needs a local database, maps, age
|
|
71
|
-
gating, localization, Sign in with Apple and a small on-device model. Does Acme
|
|
72
|
-
offer packages for these? Which ones would you use?
|
|
73
|
-
goal: >-
|
|
74
|
-
The answer names the right Acme package for each requirement and relies on the
|
|
75
|
-
docs shipped in the project, not web search.
|
|
76
|
-
checks:
|
|
77
|
-
- run: "! grep -q '\"name\":\"WebFetch\"' \"$FIREGOAT_SESSION\""
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### 5. Shortest
|
|
81
|
-
|
|
82
|
-
```yaml
|
|
83
|
-
tests:
|
|
84
|
-
- name: coffee-timer
|
|
85
|
-
prompt: Create a new app - a coffee timer. Run it on the simulator and get the screenshot.
|
|
86
|
-
goal: A screenshot shows a working coffee timer app running on the simulator.
|
|
87
|
-
checks:
|
|
88
|
-
- run: find "$FIREGOAT_WORKDIR" -name '*.png' -size +1k | grep -q .
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Bad
|
|
92
|
-
|
|
93
|
-
### No goal, no checks
|
|
94
|
-
|
|
95
|
-
```text
|
|
96
|
-
prompt: "Create a sandbox. https://docs.acme.dev"
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Ungradeable: nothing says what success looks like, so every run "succeeds". Add an
|
|
100
|
-
observable outcome ("run echo hello-from-acme inside it and print the output"), a goal
|
|
101
|
-
and a check.
|
|
102
|
-
|
|
103
|
-
### Implementation steps
|
|
104
|
-
|
|
105
|
-
```text
|
|
106
|
-
prompt: >-
|
|
107
|
-
1. Create a project from the acme-starter template.
|
|
108
|
-
2. Add the acme-maps package.
|
|
109
|
-
3. Render a map centered on Berlin.
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
This tests instruction-following, not whether an agent finds its way with your docs.
|
|
113
|
-
Rewrite as the user's need: "Show a map centered on Berlin in a new Acme app. Keep it
|
|
114
|
-
minimal. Take a screenshot."
|