firegoat 0.1.0 → 0.3.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 +37 -2
- package/dist/cli.js +622 -76
- package/package.json +1 -1
- package/skills/firegoat-onboarding/SKILL.md +9 -0
package/README.md
CHANGED
|
@@ -2,15 +2,50 @@
|
|
|
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.3). See `plan.md`.
|
|
6
6
|
|
|
7
7
|
## Use
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx firegoat init # writes firegoat.yaml with starter evals; --skills adds the onboarding skill
|
|
11
|
-
npx firegoat
|
|
11
|
+
npx firegoat login # once per machine: signs claude in to ~/.firegoat/claude
|
|
12
|
+
npx firegoat eval # runs each test with your local `claude`, sandboxed, in a fresh folder
|
|
13
|
+
npx firegoat open # opens the latest run's folder (--print to only print it)
|
|
12
14
|
```
|
|
13
15
|
|
|
16
|
+
### Local builds
|
|
17
|
+
|
|
18
|
+
`build:` builds your packages once per `eval` (on the host, from source) and preinstalls them into every run's fresh folder. The agent gets the build, never the source.
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
build:
|
|
22
|
+
sdk: ./packages/sdk # package.json: npm pack, then npm install into node_modules
|
|
23
|
+
py: ./python # pyproject.toml: wheel (uv build, or python3 -m build) into .venv, on PATH
|
|
24
|
+
cli: # anything else: your command writes outputs to $FIREGOAT_OUT,
|
|
25
|
+
run: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme # which go on the agent's PATH
|
|
26
|
+
tests:
|
|
27
|
+
- name: quickstart
|
|
28
|
+
prompt: "Deploy commit {{vars.SHA}} with the acme CLI. The SDK is already installed; don't install it from the registry."
|
|
29
|
+
goal: ...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `{{build.NAME}}` in a prompt or check is the path of that build's artifact inside the run's folder.
|
|
33
|
+
- `{{vars.KEY}}` comes from `--var KEY=value` or `vars:` in `firegoat.yaml`; vars are also in the env of `build:` commands. A missing var is a usage error. Other `{{...}}` text is left alone.
|
|
34
|
+
- A failed build or install means not graded (exit 2); logs are in `.firegoat/runs/<runId>/build/build.txt` and `<test>/<n>/setup.txt`.
|
|
35
|
+
|
|
36
|
+
### Isolation
|
|
37
|
+
|
|
38
|
+
On macOS, `eval` runs `claude` inside a Seatbelt (`sandbox-exec`) profile:
|
|
39
|
+
|
|
40
|
+
- it signs in with `~/.firegoat/claude` (from `firegoat login`), never your `~/.claude`;
|
|
41
|
+
- it cannot read your home dir or the repo, except toolchains (node, bun, cargo, ...) it needs to run;
|
|
42
|
+
- it can write only in its run dir (`work/`, its own `TMPDIR` and package caches) and system temp;
|
|
43
|
+
- `ANTHROPIC_API_KEY` is removed, so runs bill your subscription, not the API.
|
|
44
|
+
|
|
45
|
+
A probe checks the sandbox before every run; if it does not hold, the run is not graded. Network is open.
|
|
46
|
+
|
|
47
|
+
Off macOS there is no sandbox yet: run inside a throwaway container and pass `--host`.
|
|
48
|
+
|
|
14
49
|
`eval` saves each run's transcript to `.firegoat/runs/<runId>/<test>/<n>/session.jsonl` and a `summary.json`, then runs the test's `checks` in the agent's folder. Nothing is uploaded. `firegoat.yaml` is looked up in the current directory, then parents up to the git root.
|
|
15
50
|
|
|
16
51
|
Exit codes:
|
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.3.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/team2027/firegoat-cli.git"
|
|
@@ -7081,6 +7081,8 @@ function parseConfig(text, warn = console.error) {
|
|
|
7081
7081
|
}
|
|
7082
7082
|
});
|
|
7083
7083
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7084
|
+
const build = parseBuild(raw.build);
|
|
7085
|
+
const vars = parseVars(raw.vars);
|
|
7084
7086
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
7085
7087
|
fail("tests", "must be a non-empty list");
|
|
7086
7088
|
const names = new Set;
|
|
@@ -7089,7 +7091,7 @@ function parseConfig(text, warn = console.error) {
|
|
|
7089
7091
|
if (!isRecord(t))
|
|
7090
7092
|
fail(p, "must be a mapping");
|
|
7091
7093
|
const name = str(t.name, `${p}.name`);
|
|
7092
|
-
if (
|
|
7094
|
+
if (!NAME.test(name))
|
|
7093
7095
|
fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
|
|
7094
7096
|
if (names.has(name))
|
|
7095
7097
|
fail(`${p}.name`, `duplicate test name ${JSON.stringify(name)}`);
|
|
@@ -7116,7 +7118,71 @@ function parseConfig(text, warn = console.error) {
|
|
|
7116
7118
|
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
|
|
7117
7119
|
};
|
|
7118
7120
|
});
|
|
7119
|
-
|
|
7121
|
+
const builds = new Set(build.map((b) => b.name));
|
|
7122
|
+
tests.forEach((t, i) => {
|
|
7123
|
+
const texts = [t.prompt, ...t.checks.map((c) => c.run)];
|
|
7124
|
+
for (const ref of texts.flatMap(placeholders)) {
|
|
7125
|
+
if (ref.kind === "build" && !builds.has(ref.key))
|
|
7126
|
+
fail(`tests[${i}]`, `{{build.${ref.key}}} names no entry under build: (known: ${[...builds].join(", ") || "none"})`);
|
|
7127
|
+
}
|
|
7128
|
+
});
|
|
7129
|
+
return { agents, runs, build, vars, tests };
|
|
7130
|
+
}
|
|
7131
|
+
var NAME = /^[A-Za-z0-9._-]+$/;
|
|
7132
|
+
function parseBuild(raw) {
|
|
7133
|
+
if (raw === undefined)
|
|
7134
|
+
return [];
|
|
7135
|
+
if (!isRecord(raw))
|
|
7136
|
+
fail("build", "must be a mapping of name to path or {path, run}");
|
|
7137
|
+
return Object.entries(raw).map(([name, v]) => {
|
|
7138
|
+
const p = `build.${name}`;
|
|
7139
|
+
if (!NAME.test(name))
|
|
7140
|
+
fail(p, "name may only contain letters, digits, '.', '_' and '-'");
|
|
7141
|
+
if (typeof v === "string")
|
|
7142
|
+
return { name, path: str(v, p) };
|
|
7143
|
+
if (!isRecord(v))
|
|
7144
|
+
fail(p, "must be a path or a mapping with path and/or run");
|
|
7145
|
+
if (v.path === undefined && v.run === undefined)
|
|
7146
|
+
fail(p, "needs path, run, or both");
|
|
7147
|
+
const b = {
|
|
7148
|
+
name,
|
|
7149
|
+
path: v.path === undefined ? "." : str(v.path, `${p}.path`)
|
|
7150
|
+
};
|
|
7151
|
+
if (v.run !== undefined)
|
|
7152
|
+
b.run = str(v.run, `${p}.run`);
|
|
7153
|
+
return b;
|
|
7154
|
+
});
|
|
7155
|
+
}
|
|
7156
|
+
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7157
|
+
function parseVars(raw) {
|
|
7158
|
+
if (raw === undefined)
|
|
7159
|
+
return {};
|
|
7160
|
+
if (!isRecord(raw))
|
|
7161
|
+
fail("vars", "must be a mapping of KEY to value");
|
|
7162
|
+
const out = {};
|
|
7163
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
7164
|
+
if (!VAR_KEY.test(k))
|
|
7165
|
+
fail(`vars.${k}`, "key must be letters, digits and _, not starting with a digit");
|
|
7166
|
+
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean")
|
|
7167
|
+
fail(`vars.${k}`, "must be a string, number or boolean");
|
|
7168
|
+
out[k] = String(v);
|
|
7169
|
+
}
|
|
7170
|
+
return out;
|
|
7171
|
+
}
|
|
7172
|
+
var PLACEHOLDER = /\{\{\s*(build|vars)\.([A-Za-z0-9._-]+)\s*\}\}/g;
|
|
7173
|
+
function placeholders(text) {
|
|
7174
|
+
return [...text.matchAll(PLACEHOLDER)].map((m) => ({
|
|
7175
|
+
kind: m[1],
|
|
7176
|
+
key: m[2]
|
|
7177
|
+
}));
|
|
7178
|
+
}
|
|
7179
|
+
function render(text, values) {
|
|
7180
|
+
return text.replace(PLACEHOLDER, (_, kind, key) => {
|
|
7181
|
+
const v = values[kind][key];
|
|
7182
|
+
if (v === undefined)
|
|
7183
|
+
throw new ConfigError(`no value for {{${kind}.${key}}}`);
|
|
7184
|
+
return v;
|
|
7185
|
+
});
|
|
7120
7186
|
}
|
|
7121
7187
|
function loadConfig(dir, warn) {
|
|
7122
7188
|
const file = `${dir}/${CONFIG_FILE}`;
|
|
@@ -7139,23 +7205,177 @@ function findConfigDir(cwd) {
|
|
|
7139
7205
|
// src/eval.ts
|
|
7140
7206
|
import { randomBytes } from "node:crypto";
|
|
7141
7207
|
import {
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
existsSync as existsSync2,
|
|
7146
|
-
mkdirSync,
|
|
7208
|
+
createWriteStream as createWriteStream3,
|
|
7209
|
+
existsSync as existsSync4,
|
|
7210
|
+
mkdirSync as mkdirSync3,
|
|
7147
7211
|
mkdtempSync,
|
|
7148
7212
|
readFileSync as readFileSync2,
|
|
7149
|
-
|
|
7150
|
-
writeFileSync
|
|
7213
|
+
realpathSync as realpathSync2,
|
|
7214
|
+
writeFileSync as writeFileSync2
|
|
7151
7215
|
} from "node:fs";
|
|
7152
7216
|
import { tmpdir } from "node:os";
|
|
7153
|
-
import {
|
|
7217
|
+
import { join as join5, resolve as resolve3 } from "node:path";
|
|
7218
|
+
|
|
7219
|
+
// src/build.ts
|
|
7220
|
+
import {
|
|
7221
|
+
cpSync,
|
|
7222
|
+
createWriteStream as createWriteStream2,
|
|
7223
|
+
existsSync as existsSync3,
|
|
7224
|
+
mkdirSync as mkdirSync2,
|
|
7225
|
+
readdirSync,
|
|
7226
|
+
statSync as statSync2,
|
|
7227
|
+
writeFileSync
|
|
7228
|
+
} from "node:fs";
|
|
7229
|
+
import { delimiter as delimiter2, join as join4, resolve as resolve2 } from "node:path";
|
|
7154
7230
|
|
|
7155
7231
|
// src/claude.ts
|
|
7156
|
-
import { spawn } from "node:child_process";
|
|
7232
|
+
import { spawn, spawnSync as spawnSync2 } from "node:child_process";
|
|
7157
7233
|
import { createWriteStream } from "node:fs";
|
|
7158
7234
|
import { createInterface } from "node:readline";
|
|
7235
|
+
|
|
7236
|
+
// src/sandbox.ts
|
|
7237
|
+
import { spawnSync } from "node:child_process";
|
|
7238
|
+
import { existsSync as existsSync2, mkdirSync, realpathSync } from "node:fs";
|
|
7239
|
+
import { homedir } from "node:os";
|
|
7240
|
+
import { basename, dirname as dirname2, join as join2 } from "node:path";
|
|
7241
|
+
var SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
7242
|
+
function firegoatHome() {
|
|
7243
|
+
return process.env.FIREGOAT_HOME || join2(homedir(), ".firegoat");
|
|
7244
|
+
}
|
|
7245
|
+
function claudeConfigDir() {
|
|
7246
|
+
return join2(firegoatHome(), "claude");
|
|
7247
|
+
}
|
|
7248
|
+
var TOOLCHAINS = [
|
|
7249
|
+
".nvm",
|
|
7250
|
+
".fnm",
|
|
7251
|
+
".volta",
|
|
7252
|
+
".bun",
|
|
7253
|
+
".deno",
|
|
7254
|
+
".cargo",
|
|
7255
|
+
".rustup",
|
|
7256
|
+
".pyenv",
|
|
7257
|
+
".rbenv",
|
|
7258
|
+
".asdf",
|
|
7259
|
+
".sdkman",
|
|
7260
|
+
".local/share/mise",
|
|
7261
|
+
".local/share/fnm",
|
|
7262
|
+
".local/share/claude",
|
|
7263
|
+
".local/share/uv",
|
|
7264
|
+
"go",
|
|
7265
|
+
"Library/pnpm"
|
|
7266
|
+
];
|
|
7267
|
+
var TOOLCHAIN_SECRETS = [".cargo/credentials", ".cargo/credentials.toml"];
|
|
7268
|
+
function real(p) {
|
|
7269
|
+
return realpathSync(p);
|
|
7270
|
+
}
|
|
7271
|
+
function under(p, dir) {
|
|
7272
|
+
return p === dir || p.startsWith(`${dir}/`);
|
|
7273
|
+
}
|
|
7274
|
+
function readableToolDirs(home, claudeBin) {
|
|
7275
|
+
const dirs = new Set;
|
|
7276
|
+
const add = (p) => {
|
|
7277
|
+
if (existsSync2(p))
|
|
7278
|
+
dirs.add(real(p));
|
|
7279
|
+
};
|
|
7280
|
+
for (const t of TOOLCHAINS)
|
|
7281
|
+
add(join2(home, t));
|
|
7282
|
+
for (const p of (process.env.PATH ?? "").split(":")) {
|
|
7283
|
+
if (!p.startsWith("/") || !existsSync2(p))
|
|
7284
|
+
continue;
|
|
7285
|
+
const r = real(p);
|
|
7286
|
+
if (!under(r, home) || r === home)
|
|
7287
|
+
continue;
|
|
7288
|
+
const prefix = dirname2(r);
|
|
7289
|
+
dirs.add(basename(r) === "bin" && prefix !== home && prefix !== join2(home, ".local") ? prefix : r);
|
|
7290
|
+
}
|
|
7291
|
+
add(dirname2(dirname2(process.execPath)));
|
|
7292
|
+
add(dirname2(claudeBin));
|
|
7293
|
+
add(dirname2(real(claudeBin)));
|
|
7294
|
+
return [...dirs];
|
|
7295
|
+
}
|
|
7296
|
+
var q = (s) => JSON.stringify(s);
|
|
7297
|
+
function sandboxProfile(opts) {
|
|
7298
|
+
const home = real(homedir());
|
|
7299
|
+
const reads = readableToolDirs(home, opts.claudeBin).map((d) => `(subpath ${q(d)})`);
|
|
7300
|
+
return `(version 1)
|
|
7301
|
+
(allow default)
|
|
7302
|
+
|
|
7303
|
+
(deny file-read* (subpath ${q(home)}))
|
|
7304
|
+
(allow file-read-metadata)
|
|
7305
|
+
(allow file-read*
|
|
7306
|
+
${reads.join(`
|
|
7307
|
+
`)}
|
|
7308
|
+
(subpath ${q(join2(home, "Library/Keychains"))})
|
|
7309
|
+
(regex ${q(`^${home}/Library/Preferences/com\\.apple\\.security`)})
|
|
7310
|
+
(regex ${q(`^${home}/Library/Preferences/\\.GlobalPreferences`)}))
|
|
7311
|
+
(deny file-read* file-write*
|
|
7312
|
+
${TOOLCHAIN_SECRETS.map((s) => `(literal ${q(join2(home, s))})`).join(`
|
|
7313
|
+
`)})
|
|
7314
|
+
|
|
7315
|
+
(deny file-write* (subpath "/"))
|
|
7316
|
+
(allow file-write*
|
|
7317
|
+
(subpath "/dev")
|
|
7318
|
+
(subpath "/private/tmp")
|
|
7319
|
+
(subpath "/private/var/folders"))
|
|
7320
|
+
(allow file-read* file-write*
|
|
7321
|
+
(subpath ${q(real(opts.configDir))})
|
|
7322
|
+
(subpath ${q(real(opts.runRoot))}))
|
|
7323
|
+
|
|
7324
|
+
(deny file-read* file-write* (subpath ${q(real(opts.repo))}))
|
|
7325
|
+
`;
|
|
7326
|
+
}
|
|
7327
|
+
function agentEnv(runRoot) {
|
|
7328
|
+
const env = { ...process.env };
|
|
7329
|
+
delete env.ANTHROPIC_API_KEY;
|
|
7330
|
+
const cache = join2(runRoot, "cache");
|
|
7331
|
+
const tmp = join2(runRoot, "tmp");
|
|
7332
|
+
mkdirSync(cache, { recursive: true });
|
|
7333
|
+
mkdirSync(tmp, { recursive: true });
|
|
7334
|
+
return {
|
|
7335
|
+
...env,
|
|
7336
|
+
CLAUDE_CONFIG_DIR: claudeConfigDir(),
|
|
7337
|
+
TMPDIR: tmp,
|
|
7338
|
+
XDG_CACHE_HOME: cache,
|
|
7339
|
+
npm_config_cache: join2(cache, "npm"),
|
|
7340
|
+
npm_config_store_dir: join2(cache, "pnpm-store"),
|
|
7341
|
+
YARN_CACHE_FOLDER: join2(cache, "yarn"),
|
|
7342
|
+
BUN_INSTALL_CACHE_DIR: join2(cache, "bun"),
|
|
7343
|
+
PIP_CACHE_DIR: join2(cache, "pip"),
|
|
7344
|
+
UV_CACHE_DIR: join2(cache, "uv"),
|
|
7345
|
+
GOMODCACHE: join2(cache, "gomod"),
|
|
7346
|
+
GOCACHE: join2(cache, "gobuild"),
|
|
7347
|
+
CARGO_HOME: join2(cache, "cargo")
|
|
7348
|
+
};
|
|
7349
|
+
}
|
|
7350
|
+
function wrap(profile, cmd, args) {
|
|
7351
|
+
if (profile === null)
|
|
7352
|
+
return { cmd, args };
|
|
7353
|
+
return { cmd: SANDBOX_EXEC, args: ["-p", profile, cmd, ...args] };
|
|
7354
|
+
}
|
|
7355
|
+
function probeSandbox(opts) {
|
|
7356
|
+
const outside = join2(homedir(), `.firegoat-escape-probe-${process.pid}-${Date.now()}`);
|
|
7357
|
+
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"';
|
|
7358
|
+
const { cmd, args } = wrap(opts.profile, "/bin/sh", [
|
|
7359
|
+
"-c",
|
|
7360
|
+
script,
|
|
7361
|
+
"probe",
|
|
7362
|
+
outside,
|
|
7363
|
+
opts.repoFile,
|
|
7364
|
+
join2(opts.workdir, ".firegoat-probe")
|
|
7365
|
+
]);
|
|
7366
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", cwd: opts.workdir });
|
|
7367
|
+
if (r.error)
|
|
7368
|
+
return `sandbox probe could not start: ${r.error.message}`;
|
|
7369
|
+
const got = r.stdout.trim().split(`
|
|
7370
|
+
`).filter(Boolean);
|
|
7371
|
+
if (got.includes("home-write") || got.includes("repo-read"))
|
|
7372
|
+
return `sandbox does not hold (escaped: ${got.filter((g) => g !== "work-write").join(", ")}); refusing to run the agent`;
|
|
7373
|
+
if (!got.includes("work-write"))
|
|
7374
|
+
return `sandbox blocks writes in the workdir (stderr: ${r.stderr.trim() || "none"})`;
|
|
7375
|
+
return;
|
|
7376
|
+
}
|
|
7377
|
+
|
|
7378
|
+
// src/claude.ts
|
|
7159
7379
|
var CLAUDE_FLAGS = [
|
|
7160
7380
|
"--output-format",
|
|
7161
7381
|
"stream-json",
|
|
@@ -7169,11 +7389,6 @@ var CLAUDE_FLAGS = [
|
|
|
7169
7389
|
function claudeArgv(prompt) {
|
|
7170
7390
|
return ["-p", prompt, ...CLAUDE_FLAGS];
|
|
7171
7391
|
}
|
|
7172
|
-
function agentEnv() {
|
|
7173
|
-
const env = { ...process.env };
|
|
7174
|
-
delete env.ANTHROPIC_API_KEY;
|
|
7175
|
-
return env;
|
|
7176
|
-
}
|
|
7177
7392
|
var live = new Set;
|
|
7178
7393
|
function killGroup(child, signal) {
|
|
7179
7394
|
if (child.pid === undefined)
|
|
@@ -7303,10 +7518,9 @@ async function runClaude(opts) {
|
|
|
7303
7518
|
let badStart;
|
|
7304
7519
|
let result;
|
|
7305
7520
|
const spawned = await spawnWatched({
|
|
7306
|
-
|
|
7307
|
-
args: claudeArgv(opts.prompt),
|
|
7521
|
+
...wrap(opts.profile, opts.bin, claudeArgv(opts.prompt)),
|
|
7308
7522
|
cwd: opts.cwd,
|
|
7309
|
-
env:
|
|
7523
|
+
env: opts.env,
|
|
7310
7524
|
timeoutMs: opts.timeoutMs,
|
|
7311
7525
|
onStderr: (c) => stderr.write(c),
|
|
7312
7526
|
onLine: (line) => {
|
|
@@ -7376,7 +7590,7 @@ async function runClaude(opts) {
|
|
|
7376
7590
|
};
|
|
7377
7591
|
return { ok: true, result };
|
|
7378
7592
|
}
|
|
7379
|
-
async function
|
|
7593
|
+
async function runShell(opts) {
|
|
7380
7594
|
opts.log.write(`$ ${opts.run}
|
|
7381
7595
|
`);
|
|
7382
7596
|
const r = await spawnWatched({
|
|
@@ -7405,6 +7619,162 @@ async function runCheck(opts) {
|
|
|
7405
7619
|
out.notStarted = true;
|
|
7406
7620
|
return out;
|
|
7407
7621
|
}
|
|
7622
|
+
function checkLogin(opts) {
|
|
7623
|
+
const { cmd, args } = wrap(opts.profile, opts.bin, ["auth", "status"]);
|
|
7624
|
+
const r = spawnSync2(cmd, args, {
|
|
7625
|
+
cwd: opts.cwd,
|
|
7626
|
+
env: opts.env,
|
|
7627
|
+
encoding: "utf8",
|
|
7628
|
+
timeout: 30000
|
|
7629
|
+
});
|
|
7630
|
+
if (r.error)
|
|
7631
|
+
return `could not run claude auth status: ${r.error.message}`;
|
|
7632
|
+
let status;
|
|
7633
|
+
try {
|
|
7634
|
+
status = JSON.parse(r.stdout);
|
|
7635
|
+
} catch {}
|
|
7636
|
+
if (status?.loggedIn === true)
|
|
7637
|
+
return;
|
|
7638
|
+
if (status?.loggedIn === false)
|
|
7639
|
+
return `claude is not logged in to firegoat's config dir (${opts.env.CLAUDE_CONFIG_DIR}). Run: npx firegoat login`;
|
|
7640
|
+
return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
|
|
7641
|
+
}
|
|
7642
|
+
|
|
7643
|
+
// src/which.ts
|
|
7644
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
7645
|
+
import { delimiter, join as join3, resolve } from "node:path";
|
|
7646
|
+
function resolveBin(bin) {
|
|
7647
|
+
const executable = (p) => {
|
|
7648
|
+
try {
|
|
7649
|
+
accessSync(p, constants.X_OK);
|
|
7650
|
+
return statSync(p).isFile();
|
|
7651
|
+
} catch {
|
|
7652
|
+
return false;
|
|
7653
|
+
}
|
|
7654
|
+
};
|
|
7655
|
+
if (bin.includes("/"))
|
|
7656
|
+
return executable(bin) ? resolve(bin) : undefined;
|
|
7657
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
7658
|
+
if (dir && executable(join3(dir, bin)))
|
|
7659
|
+
return join3(dir, bin);
|
|
7660
|
+
}
|
|
7661
|
+
return;
|
|
7662
|
+
}
|
|
7663
|
+
|
|
7664
|
+
// src/build.ts
|
|
7665
|
+
var BUILD_TIMEOUT_MS = 600000;
|
|
7666
|
+
|
|
7667
|
+
class BuildError extends Error {
|
|
7668
|
+
}
|
|
7669
|
+
function kindOf(b, dir) {
|
|
7670
|
+
if (b.run)
|
|
7671
|
+
return "run";
|
|
7672
|
+
if (existsSync3(join4(dir, "package.json")))
|
|
7673
|
+
return "npm";
|
|
7674
|
+
if (existsSync3(join4(dir, "pyproject.toml")))
|
|
7675
|
+
return "python";
|
|
7676
|
+
throw new BuildError(`build.${b.name}: no package.json or pyproject.toml in ${dir}; add run: with a build command that writes to $FIREGOAT_OUT`);
|
|
7677
|
+
}
|
|
7678
|
+
function pythonBuildCommand() {
|
|
7679
|
+
if (resolveBin("uv"))
|
|
7680
|
+
return 'uv build --wheel --out-dir "$FIREGOAT_OUT"';
|
|
7681
|
+
return 'python3 -m build --wheel --outdir "$FIREGOAT_OUT"';
|
|
7682
|
+
}
|
|
7683
|
+
var BUILD_COMMAND = {
|
|
7684
|
+
npm: () => 'npm pack --ignore-scripts=false --pack-destination "$FIREGOAT_OUT"',
|
|
7685
|
+
python: pythonBuildCommand
|
|
7686
|
+
};
|
|
7687
|
+
var ARTIFACT = { npm: ".tgz", python: ".whl" };
|
|
7688
|
+
async function sh(run, cwd, env, log, what) {
|
|
7689
|
+
const r = await runShell({ run, cwd, env, timeoutMs: BUILD_TIMEOUT_MS, log });
|
|
7690
|
+
if (r.code !== 0)
|
|
7691
|
+
throw new BuildError(`${what} failed (${r.error ?? `exit ${r.code}`}): ${run}`);
|
|
7692
|
+
}
|
|
7693
|
+
async function buildOne(b, root, buildDir, env, log) {
|
|
7694
|
+
const dir = resolve2(root, b.path);
|
|
7695
|
+
if (!existsSync3(dir) || !statSync2(dir).isDirectory())
|
|
7696
|
+
throw new BuildError(`build.${b.name}: ${dir} is not a directory`);
|
|
7697
|
+
const kind = kindOf(b, dir);
|
|
7698
|
+
const out = join4(buildDir, b.name);
|
|
7699
|
+
mkdirSync2(out, { recursive: true });
|
|
7700
|
+
const run = kind === "run" ? b.run : BUILD_COMMAND[kind]();
|
|
7701
|
+
log.write(`## build.${b.name} (${kind}) in ${dir}
|
|
7702
|
+
`);
|
|
7703
|
+
await sh(run, dir, { ...env, FIREGOAT_OUT: out }, log, `build.${b.name}`);
|
|
7704
|
+
const files = readdirSync(out);
|
|
7705
|
+
if (kind !== "run") {
|
|
7706
|
+
const ext = ARTIFACT[kind];
|
|
7707
|
+
const hits = files.filter((f) => f.endsWith(ext));
|
|
7708
|
+
if (hits.length !== 1)
|
|
7709
|
+
throw new BuildError(`build.${b.name}: expected one ${ext} in ${out}, found ${hits.length ? hits.join(", ") : "none"}`);
|
|
7710
|
+
} else if (files.length === 0)
|
|
7711
|
+
throw new BuildError(`build.${b.name}: run: wrote nothing to $FIREGOAT_OUT (${out})`);
|
|
7712
|
+
return { name: b.name, kind, out };
|
|
7713
|
+
}
|
|
7714
|
+
async function buildAll(builds, root, buildDir, env) {
|
|
7715
|
+
mkdirSync2(buildDir, { recursive: true });
|
|
7716
|
+
const log = createWriteStream2(join4(buildDir, "build.txt"));
|
|
7717
|
+
try {
|
|
7718
|
+
const built = [];
|
|
7719
|
+
for (const b of builds)
|
|
7720
|
+
built.push(await buildOne(b, root, buildDir, env, log));
|
|
7721
|
+
return { built };
|
|
7722
|
+
} catch (e) {
|
|
7723
|
+
if (e instanceof BuildError)
|
|
7724
|
+
return { error: `${e.message} (log: ${join4(buildDir, "build.txt")})` };
|
|
7725
|
+
throw e;
|
|
7726
|
+
} finally {
|
|
7727
|
+
await new Promise((r) => log.end(r));
|
|
7728
|
+
}
|
|
7729
|
+
}
|
|
7730
|
+
async function install(built, runRoot, workdir, env, logPath) {
|
|
7731
|
+
const result = { paths: {}, env: {} };
|
|
7732
|
+
if (built.length === 0)
|
|
7733
|
+
return result;
|
|
7734
|
+
const log = createWriteStream2(logPath);
|
|
7735
|
+
const path = [];
|
|
7736
|
+
const artifacts = { npm: [], python: [] };
|
|
7737
|
+
try {
|
|
7738
|
+
for (const b of built) {
|
|
7739
|
+
const copy = join4(runRoot, "packages", b.name);
|
|
7740
|
+
cpSync(b.out, copy, { recursive: true });
|
|
7741
|
+
const files = readdirSync(copy);
|
|
7742
|
+
if (b.kind === "run") {
|
|
7743
|
+
result.paths[b.name] = files.length === 1 ? join4(copy, files[0]) : copy;
|
|
7744
|
+
path.push(copy);
|
|
7745
|
+
} else {
|
|
7746
|
+
const ext = ARTIFACT[b.kind];
|
|
7747
|
+
const file = join4(copy, files.find((f) => f.endsWith(ext)));
|
|
7748
|
+
result.paths[b.name] = file;
|
|
7749
|
+
artifacts[b.kind].push(file);
|
|
7750
|
+
}
|
|
7751
|
+
}
|
|
7752
|
+
if (artifacts.npm.length > 0) {
|
|
7753
|
+
writeFileSync(join4(workdir, "package.json"), `${JSON.stringify({ name: "app", version: "0.0.0", private: true }, null, 2)}
|
|
7754
|
+
`);
|
|
7755
|
+
await sh(`npm install --no-audit --no-fund ${artifacts.npm.map(q2).join(" ")}`, workdir, env, log, "npm install of the build");
|
|
7756
|
+
path.push(join4(workdir, "node_modules", ".bin"));
|
|
7757
|
+
}
|
|
7758
|
+
if (artifacts.python.length > 0) {
|
|
7759
|
+
const venv = join4(workdir, ".venv");
|
|
7760
|
+
const wheels = artifacts.python.map(q2).join(" ");
|
|
7761
|
+
await sh(resolveBin("uv") ? `uv venv .venv && uv pip install --python .venv/bin/python ${wheels}` : `python3 -m venv .venv && .venv/bin/pip install ${wheels}`, workdir, env, log, "pip install of the build");
|
|
7762
|
+
path.push(join4(venv, "bin"));
|
|
7763
|
+
result.env.VIRTUAL_ENV = venv;
|
|
7764
|
+
}
|
|
7765
|
+
result.env.PATH = [...path, env.PATH ?? ""].join(delimiter2);
|
|
7766
|
+
return result;
|
|
7767
|
+
} catch (e) {
|
|
7768
|
+
if (e instanceof BuildError)
|
|
7769
|
+
return { error: `${e.message} (log: ${logPath})` };
|
|
7770
|
+
throw e;
|
|
7771
|
+
} finally {
|
|
7772
|
+
await new Promise((r) => log.end(r));
|
|
7773
|
+
}
|
|
7774
|
+
}
|
|
7775
|
+
function q2(s) {
|
|
7776
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
7777
|
+
}
|
|
7408
7778
|
|
|
7409
7779
|
// src/exit.ts
|
|
7410
7780
|
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
@@ -7418,6 +7788,8 @@ function parseArgs(args) {
|
|
|
7418
7788
|
const opts = {
|
|
7419
7789
|
names: [],
|
|
7420
7790
|
json: false,
|
|
7791
|
+
host: false,
|
|
7792
|
+
vars: {},
|
|
7421
7793
|
claudeBin: process.env.FIREGOAT_CLAUDE_BIN || "claude"
|
|
7422
7794
|
};
|
|
7423
7795
|
for (let i = 0;i < args.length; i++) {
|
|
@@ -7435,7 +7807,16 @@ function parseArgs(args) {
|
|
|
7435
7807
|
};
|
|
7436
7808
|
if (flag === "--json")
|
|
7437
7809
|
opts.json = true;
|
|
7438
|
-
else if (flag === "--
|
|
7810
|
+
else if (flag === "--host")
|
|
7811
|
+
opts.host = true;
|
|
7812
|
+
else if (flag === "--var") {
|
|
7813
|
+
const kv = value();
|
|
7814
|
+
const at = kv.indexOf("=");
|
|
7815
|
+
const key = kv.slice(0, at);
|
|
7816
|
+
if (at < 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
7817
|
+
throw new UsageError(`--var needs KEY=value, got ${kv}`);
|
|
7818
|
+
opts.vars[key] = kv.slice(at + 1);
|
|
7819
|
+
} else if (flag === "--runs") {
|
|
7439
7820
|
const v = value();
|
|
7440
7821
|
const n = Number(v);
|
|
7441
7822
|
if (!/^\d+$/.test(v) || n < 1)
|
|
@@ -7455,35 +7836,36 @@ function newRunId() {
|
|
|
7455
7836
|
return `${ts}-${randomBytes(2).toString("hex")}`;
|
|
7456
7837
|
}
|
|
7457
7838
|
function gitignoreHasFiregoat(cwd) {
|
|
7458
|
-
const file =
|
|
7459
|
-
if (!
|
|
7839
|
+
const file = join5(cwd, ".gitignore");
|
|
7840
|
+
if (!existsSync4(file))
|
|
7460
7841
|
return false;
|
|
7461
7842
|
return readFileSync2(file, "utf8").split(`
|
|
7462
7843
|
`).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
|
|
7463
7844
|
}
|
|
7464
|
-
function
|
|
7465
|
-
const
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
7477
|
-
|
|
7478
|
-
}
|
|
7479
|
-
|
|
7845
|
+
function newRunRoot(label) {
|
|
7846
|
+
const runRoot = realpathSync2(mkdtempSync(join5(tmpdir(), `firegoat-${label}-`)));
|
|
7847
|
+
const workdir = join5(runRoot, "work");
|
|
7848
|
+
mkdirSync3(workdir);
|
|
7849
|
+
return { runRoot, workdir };
|
|
7850
|
+
}
|
|
7851
|
+
function isolate(iso, claudeBin, runRoot, workdir) {
|
|
7852
|
+
if (iso.host)
|
|
7853
|
+
return { profile: null };
|
|
7854
|
+
const profile = sandboxProfile({
|
|
7855
|
+
claudeBin,
|
|
7856
|
+
runRoot,
|
|
7857
|
+
configDir: claudeConfigDir(),
|
|
7858
|
+
repo: iso.repo
|
|
7859
|
+
});
|
|
7860
|
+
const broken = probeSandbox({ profile, workdir, repoFile: iso.repoFile });
|
|
7861
|
+
return broken ? { error: broken } : { profile };
|
|
7480
7862
|
}
|
|
7481
7863
|
async function runOne(opts) {
|
|
7482
7864
|
const { test, n } = opts;
|
|
7483
7865
|
const started = Date.now();
|
|
7484
|
-
const dir =
|
|
7485
|
-
|
|
7486
|
-
const session =
|
|
7866
|
+
const dir = join5(opts.runDir, test.name, String(n));
|
|
7867
|
+
mkdirSync3(dir, { recursive: true });
|
|
7868
|
+
const session = join5(dir, "session.jsonl");
|
|
7487
7869
|
const summary = {
|
|
7488
7870
|
test: test.name,
|
|
7489
7871
|
n,
|
|
@@ -7502,14 +7884,23 @@ async function runOne(opts) {
|
|
|
7502
7884
|
summary.duration_ms = Date.now() - started;
|
|
7503
7885
|
return summary;
|
|
7504
7886
|
};
|
|
7505
|
-
const workdir =
|
|
7887
|
+
const { runRoot, workdir } = newRunRoot(`${test.name}-${n}`);
|
|
7506
7888
|
summary.workdir = workdir;
|
|
7889
|
+
const sandbox = isolate(opts.iso, opts.claudeBin, runRoot, workdir);
|
|
7890
|
+
if ("error" in sandbox)
|
|
7891
|
+
return done("error", `not graded: ${sandbox.error}`);
|
|
7892
|
+
const installed = await install(opts.built, runRoot, workdir, process.env, join5(dir, "setup.txt"));
|
|
7893
|
+
if ("error" in installed)
|
|
7894
|
+
return done("error", `not graded: setup: ${installed.error}`);
|
|
7895
|
+
const values = { build: installed.paths, vars: opts.vars };
|
|
7507
7896
|
const outcome = await runClaude({
|
|
7897
|
+
env: { ...agentEnv(runRoot), ...installed.env },
|
|
7898
|
+
profile: sandbox.profile,
|
|
7508
7899
|
bin: opts.claudeBin,
|
|
7509
|
-
prompt: test.prompt,
|
|
7900
|
+
prompt: render(test.prompt, values),
|
|
7510
7901
|
cwd: workdir,
|
|
7511
7902
|
sessionPath: session,
|
|
7512
|
-
stderrPath:
|
|
7903
|
+
stderrPath: join5(dir, "stderr.txt"),
|
|
7513
7904
|
timeoutMs: test.timeout * 1000
|
|
7514
7905
|
});
|
|
7515
7906
|
if (outcome.result) {
|
|
@@ -7522,17 +7913,18 @@ async function runOne(opts) {
|
|
|
7522
7913
|
opts.warn(`warning: ${test.name} has no checks, so its goal is unverified; counted as pass`);
|
|
7523
7914
|
return done("pass", "no checks (goal unverified)");
|
|
7524
7915
|
}
|
|
7525
|
-
const log =
|
|
7916
|
+
const log = createWriteStream3(join5(dir, "checks.txt"));
|
|
7526
7917
|
const env = {
|
|
7527
7918
|
...process.env,
|
|
7528
|
-
|
|
7919
|
+
...installed.env,
|
|
7920
|
+
FIREGOAT_SESSION: resolve3(session),
|
|
7529
7921
|
FIREGOAT_WORKDIR: workdir
|
|
7530
7922
|
};
|
|
7531
7923
|
const results = [];
|
|
7532
7924
|
try {
|
|
7533
7925
|
for (const c of test.checks) {
|
|
7534
|
-
results.push(await
|
|
7535
|
-
run: c.run,
|
|
7926
|
+
results.push(await runShell({
|
|
7927
|
+
run: render(c.run, values),
|
|
7536
7928
|
cwd: workdir,
|
|
7537
7929
|
env,
|
|
7538
7930
|
timeoutMs: CHECK_TIMEOUT_MS,
|
|
@@ -7577,25 +7969,77 @@ async function evalCommand(args, cwd) {
|
|
|
7577
7969
|
tests = tests.filter((t) => opts.names.includes(t.name));
|
|
7578
7970
|
}
|
|
7579
7971
|
const runs = opts.runs ?? config.runs;
|
|
7972
|
+
const vars = { ...config.vars, ...opts.vars };
|
|
7973
|
+
const missing = [
|
|
7974
|
+
...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(placeholders).filter((p) => p.kind === "vars" && !(p.key in vars)).map((p) => p.key))
|
|
7975
|
+
];
|
|
7976
|
+
if (missing.length > 0) {
|
|
7977
|
+
console.error(`firegoat eval: no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
|
|
7978
|
+
return EXIT.usage;
|
|
7979
|
+
}
|
|
7580
7980
|
const claudePath = resolveBin(opts.claudeBin);
|
|
7581
7981
|
if (!claudePath) {
|
|
7582
7982
|
console.error(`firegoat eval: not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH / FIREGOAT_CLAUDE_BIN.`);
|
|
7583
7983
|
return EXIT.notGraded;
|
|
7584
7984
|
}
|
|
7585
|
-
|
|
7985
|
+
if (opts.host)
|
|
7986
|
+
warn("warning: --host: the agent runs without isolation and can read and write your files");
|
|
7987
|
+
else if (process.platform !== "darwin" || !existsSync4(SANDBOX_EXEC)) {
|
|
7988
|
+
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.`);
|
|
7989
|
+
return EXIT.usage;
|
|
7990
|
+
}
|
|
7991
|
+
const iso = {
|
|
7992
|
+
host: opts.host,
|
|
7993
|
+
repo: root,
|
|
7994
|
+
repoFile: join5(root, "firegoat.yaml")
|
|
7995
|
+
};
|
|
7996
|
+
mkdirSync3(claudeConfigDir(), { recursive: true });
|
|
7997
|
+
const pre = newRunRoot("preflight");
|
|
7998
|
+
const preSandbox = isolate(iso, claudePath, pre.runRoot, pre.workdir);
|
|
7999
|
+
const loginProblem = "error" in preSandbox ? preSandbox.error : checkLogin({
|
|
8000
|
+
bin: claudePath,
|
|
8001
|
+
env: agentEnv(pre.runRoot),
|
|
8002
|
+
profile: preSandbox.profile,
|
|
8003
|
+
cwd: pre.workdir
|
|
8004
|
+
});
|
|
8005
|
+
if (loginProblem) {
|
|
8006
|
+
console.error(`firegoat eval: not graded: ${loginProblem}`);
|
|
8007
|
+
return EXIT.notGraded;
|
|
8008
|
+
}
|
|
8009
|
+
mkdirSync3(join5(root, ".firegoat"), { recursive: true });
|
|
7586
8010
|
if (!gitignoreHasFiregoat(root))
|
|
7587
8011
|
warn("hint: add .firegoat/ to .gitignore (it holds run traces)");
|
|
7588
8012
|
const runId = newRunId();
|
|
7589
|
-
const runDir =
|
|
7590
|
-
|
|
7591
|
-
const summaryPath =
|
|
8013
|
+
const runDir = join5(root, ".firegoat", "runs", runId);
|
|
8014
|
+
mkdirSync3(runDir, { recursive: true });
|
|
8015
|
+
const summaryPath = join5(runDir, "summary.json");
|
|
8016
|
+
let built = [];
|
|
8017
|
+
if (config.build.length > 0) {
|
|
8018
|
+
say(`building ${config.build.map((b) => b.name).join(", ")}...`);
|
|
8019
|
+
const b = await buildAll(config.build, root, join5(runDir, "build"), {
|
|
8020
|
+
...process.env,
|
|
8021
|
+
...vars
|
|
8022
|
+
});
|
|
8023
|
+
if ("error" in b) {
|
|
8024
|
+
console.error(`firegoat eval: not graded: ${b.error}`);
|
|
8025
|
+
return EXIT.notGraded;
|
|
8026
|
+
}
|
|
8027
|
+
built = b.built;
|
|
8028
|
+
}
|
|
7592
8029
|
const results = [];
|
|
7593
8030
|
const writeSummary = () => {
|
|
7594
8031
|
const totals = { pass: 0, fail: 0, error: 0 };
|
|
7595
8032
|
for (const r of results)
|
|
7596
8033
|
totals[r.status]++;
|
|
7597
|
-
const doc = {
|
|
7598
|
-
|
|
8034
|
+
const doc = {
|
|
8035
|
+
runId,
|
|
8036
|
+
runsDir: runDir,
|
|
8037
|
+
isolation: opts.host ? "host" : "seatbelt",
|
|
8038
|
+
builds: built.map((b) => ({ name: b.name, kind: b.kind })),
|
|
8039
|
+
totals,
|
|
8040
|
+
runs: results
|
|
8041
|
+
};
|
|
8042
|
+
writeFileSync2(summaryPath, `${JSON.stringify(doc, null, 2)}
|
|
7599
8043
|
`);
|
|
7600
8044
|
return doc;
|
|
7601
8045
|
};
|
|
@@ -7603,7 +8047,16 @@ async function evalCommand(args, cwd) {
|
|
|
7603
8047
|
for (let n = 1;n <= runs; n++) {
|
|
7604
8048
|
let r;
|
|
7605
8049
|
try {
|
|
7606
|
-
r = await runOne({
|
|
8050
|
+
r = await runOne({
|
|
8051
|
+
test,
|
|
8052
|
+
n,
|
|
8053
|
+
runDir,
|
|
8054
|
+
claudeBin: claudePath,
|
|
8055
|
+
iso,
|
|
8056
|
+
built,
|
|
8057
|
+
vars,
|
|
8058
|
+
warn
|
|
8059
|
+
});
|
|
7607
8060
|
} catch (e) {
|
|
7608
8061
|
r = {
|
|
7609
8062
|
test: test.name,
|
|
@@ -7611,7 +8064,7 @@ async function evalCommand(args, cwd) {
|
|
|
7611
8064
|
status: "error",
|
|
7612
8065
|
reason: `not graded: firegoat crashed: ${e.message}`,
|
|
7613
8066
|
workdir: null,
|
|
7614
|
-
session:
|
|
8067
|
+
session: join5(runDir, test.name, String(n), "session.jsonl"),
|
|
7615
8068
|
checks: [],
|
|
7616
8069
|
num_turns: null,
|
|
7617
8070
|
cost: null,
|
|
@@ -7634,8 +8087,8 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
|
|
|
7634
8087
|
}
|
|
7635
8088
|
|
|
7636
8089
|
// src/init.ts
|
|
7637
|
-
import { cpSync, existsSync as
|
|
7638
|
-
import { basename, join as
|
|
8090
|
+
import { cpSync as cpSync2, existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
8091
|
+
import { basename as basename2, join as join6 } from "node:path";
|
|
7639
8092
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
7640
8093
|
import { fileURLToPath } from "node:url";
|
|
7641
8094
|
var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills]
|
|
@@ -7674,9 +8127,9 @@ function tomlSections(text) {
|
|
|
7674
8127
|
return sections;
|
|
7675
8128
|
}
|
|
7676
8129
|
function detect(cwd) {
|
|
7677
|
-
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) =>
|
|
7678
|
-
const fallback = { name:
|
|
7679
|
-
const pkgText = readText(
|
|
8130
|
+
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync5(join6(cwd, f)));
|
|
8131
|
+
const fallback = { name: basename2(cwd), library: true, readme };
|
|
8132
|
+
const pkgText = readText(join6(cwd, "package.json"));
|
|
7680
8133
|
if (pkgText !== undefined) {
|
|
7681
8134
|
try {
|
|
7682
8135
|
const pkg = JSON.parse(pkgText);
|
|
@@ -7691,7 +8144,7 @@ function detect(cwd) {
|
|
|
7691
8144
|
return { ecosystem: "npm", name, bin, library, readme, docsUrl };
|
|
7692
8145
|
} catch {}
|
|
7693
8146
|
}
|
|
7694
|
-
const pyText = readText(
|
|
8147
|
+
const pyText = readText(join6(cwd, "pyproject.toml"));
|
|
7695
8148
|
if (pyText !== undefined) {
|
|
7696
8149
|
const s = tomlSections(pyText);
|
|
7697
8150
|
const name = s.get("project")?.get("name") ?? s.get("tool.poetry")?.get("name") ?? fallback.name;
|
|
@@ -7701,12 +8154,12 @@ function detect(cwd) {
|
|
|
7701
8154
|
const docsUrl = urls?.get("Documentation") ?? urls?.get("documentation") ?? urls?.get("Homepage") ?? urls?.get("homepage");
|
|
7702
8155
|
return { ecosystem: "python", name, bin, library: true, readme, docsUrl };
|
|
7703
8156
|
}
|
|
7704
|
-
const goText = readText(
|
|
8157
|
+
const goText = readText(join6(cwd, "go.mod"));
|
|
7705
8158
|
if (goText !== undefined) {
|
|
7706
8159
|
const name = goText.match(/^module\s+(\S+)/m)?.[1] ?? fallback.name;
|
|
7707
8160
|
return { ecosystem: "go", name, library: true, readme };
|
|
7708
8161
|
}
|
|
7709
|
-
const cargoText = readText(
|
|
8162
|
+
const cargoText = readText(join6(cwd, "Cargo.toml"));
|
|
7710
8163
|
if (cargoText !== undefined) {
|
|
7711
8164
|
const name = tomlSections(cargoText).get("package")?.get("name");
|
|
7712
8165
|
return {
|
|
@@ -7809,10 +8262,10 @@ function renderYaml(p, tests) {
|
|
|
7809
8262
|
`;
|
|
7810
8263
|
}
|
|
7811
8264
|
function ensureGitignore(cwd) {
|
|
7812
|
-
const file =
|
|
8265
|
+
const file = join6(cwd, ".gitignore");
|
|
7813
8266
|
const text = readText(file);
|
|
7814
8267
|
if (text === undefined) {
|
|
7815
|
-
|
|
8268
|
+
writeFileSync3(file, `${GITIGNORE_LINE}
|
|
7816
8269
|
`);
|
|
7817
8270
|
return `created .gitignore with ${GITIGNORE_LINE}`;
|
|
7818
8271
|
}
|
|
@@ -7822,7 +8275,7 @@ function ensureGitignore(cwd) {
|
|
|
7822
8275
|
const sep = text === "" || text.endsWith(`
|
|
7823
8276
|
`) ? "" : `
|
|
7824
8277
|
`;
|
|
7825
|
-
|
|
8278
|
+
writeFileSync3(file, `${text}${sep}${GITIGNORE_LINE}
|
|
7826
8279
|
`);
|
|
7827
8280
|
return `added ${GITIGNORE_LINE} to .gitignore`;
|
|
7828
8281
|
}
|
|
@@ -7876,8 +8329,8 @@ ${INIT_USAGE}`);
|
|
|
7876
8329
|
return EXIT.usage;
|
|
7877
8330
|
}
|
|
7878
8331
|
}
|
|
7879
|
-
const configPath =
|
|
7880
|
-
const keepConfig =
|
|
8332
|
+
const configPath = join6(cwd, CONFIG_FILE);
|
|
8333
|
+
const keepConfig = existsSync5(configPath) && !force;
|
|
7881
8334
|
if (keepConfig && !skills) {
|
|
7882
8335
|
console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
|
|
7883
8336
|
return EXIT.usage;
|
|
@@ -7888,7 +8341,7 @@ ${INIT_USAGE}`);
|
|
|
7888
8341
|
const project = detect(cwd);
|
|
7889
8342
|
const tests = templates(project);
|
|
7890
8343
|
console.log(`detected: ${describe(project)}`);
|
|
7891
|
-
|
|
8344
|
+
writeFileSync3(configPath, renderYaml(project, tests));
|
|
7892
8345
|
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`);
|
|
7893
8346
|
}
|
|
7894
8347
|
console.log(ensureGitignore(cwd));
|
|
@@ -7896,8 +8349,8 @@ ${INIT_USAGE}`);
|
|
|
7896
8349
|
if (!skills && interactive)
|
|
7897
8350
|
skills = await askYesNo(`Add the ${SKILL} skill to .claude/skills/ (helps your agent draft better evals)?`);
|
|
7898
8351
|
if (skills) {
|
|
7899
|
-
const dest =
|
|
7900
|
-
|
|
8352
|
+
const dest = join6(cwd, ".claude", "skills", SKILL);
|
|
8353
|
+
cpSync2(skillSource(), dest, { recursive: true });
|
|
7901
8354
|
console.log(`copied ${SKILL} skill to .claude/skills/${SKILL}/`);
|
|
7902
8355
|
} else {
|
|
7903
8356
|
console.log(`hint: \`npx firegoat init --skills\` adds the ${SKILL} skill to .claude/skills/ (keeps your ${CONFIG_FILE})`);
|
|
@@ -7906,19 +8359,108 @@ ${INIT_USAGE}`);
|
|
|
7906
8359
|
return 0;
|
|
7907
8360
|
}
|
|
7908
8361
|
|
|
8362
|
+
// src/login.ts
|
|
8363
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
8364
|
+
import { mkdirSync as mkdirSync4 } from "node:fs";
|
|
8365
|
+
function loginCommand(args) {
|
|
8366
|
+
let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
|
|
8367
|
+
for (let i = 0;i < args.length; i++) {
|
|
8368
|
+
const a = args[i];
|
|
8369
|
+
if (a === "--claude-bin" && args[i + 1] !== undefined)
|
|
8370
|
+
claudeBin = args[++i];
|
|
8371
|
+
else if (a.startsWith("--claude-bin="))
|
|
8372
|
+
claudeBin = a.slice(13);
|
|
8373
|
+
else {
|
|
8374
|
+
console.error(`firegoat login: unknown argument: ${a}`);
|
|
8375
|
+
return EXIT.usage;
|
|
8376
|
+
}
|
|
8377
|
+
}
|
|
8378
|
+
const configDir = claudeConfigDir();
|
|
8379
|
+
mkdirSync4(configDir, { recursive: true });
|
|
8380
|
+
const env = {
|
|
8381
|
+
...process.env,
|
|
8382
|
+
CLAUDE_CONFIG_DIR: configDir
|
|
8383
|
+
};
|
|
8384
|
+
delete env.ANTHROPIC_API_KEY;
|
|
8385
|
+
console.error(`Signing claude in to ${configDir}. Evals run with this login, never your own ~/.claude.`);
|
|
8386
|
+
const r = spawnSync3(claudeBin, ["auth", "login"], { env, stdio: "inherit" });
|
|
8387
|
+
if (r.error) {
|
|
8388
|
+
console.error(`firegoat login: could not run ${claudeBin}: ${r.error.message}`);
|
|
8389
|
+
return EXIT.notGraded;
|
|
8390
|
+
}
|
|
8391
|
+
if (r.status !== 0) {
|
|
8392
|
+
console.error(`firegoat login: claude auth login exited ${r.status}`);
|
|
8393
|
+
return EXIT.notGraded;
|
|
8394
|
+
}
|
|
8395
|
+
console.log("logged in. next: npx firegoat eval");
|
|
8396
|
+
return EXIT.pass;
|
|
8397
|
+
}
|
|
8398
|
+
|
|
8399
|
+
// src/open.ts
|
|
8400
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
8401
|
+
import { existsSync as existsSync6, readdirSync as readdirSync2 } from "node:fs";
|
|
8402
|
+
import { join as join7 } from "node:path";
|
|
8403
|
+
var OPENER = {
|
|
8404
|
+
darwin: "open",
|
|
8405
|
+
linux: "xdg-open"
|
|
8406
|
+
};
|
|
8407
|
+
function openCommand(args, cwd) {
|
|
8408
|
+
let print = false;
|
|
8409
|
+
const ids = [];
|
|
8410
|
+
for (const a of args) {
|
|
8411
|
+
if (a === "--print")
|
|
8412
|
+
print = true;
|
|
8413
|
+
else if (a.startsWith("-")) {
|
|
8414
|
+
console.error(`firegoat open: unknown flag: ${a}`);
|
|
8415
|
+
return EXIT.usage;
|
|
8416
|
+
} else
|
|
8417
|
+
ids.push(a);
|
|
8418
|
+
}
|
|
8419
|
+
if (ids.length > 1) {
|
|
8420
|
+
console.error("firegoat open: takes at most one run id");
|
|
8421
|
+
return EXIT.usage;
|
|
8422
|
+
}
|
|
8423
|
+
const base = join7(findConfigDir(cwd), ".firegoat", "runs");
|
|
8424
|
+
const runs = existsSync6(base) ? readdirSync2(base).sort() : [];
|
|
8425
|
+
const id = ids[0] ?? runs.at(-1);
|
|
8426
|
+
if (!id) {
|
|
8427
|
+
console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
|
|
8428
|
+
return EXIT.usage;
|
|
8429
|
+
}
|
|
8430
|
+
if (!runs.includes(id)) {
|
|
8431
|
+
console.error(`firegoat open: no run ${JSON.stringify(id)} in ${base} (latest: ${runs.at(-1) ?? "none"})`);
|
|
8432
|
+
return EXIT.usage;
|
|
8433
|
+
}
|
|
8434
|
+
const dir = join7(base, id);
|
|
8435
|
+
console.log(dir);
|
|
8436
|
+
const opener = OPENER[process.platform];
|
|
8437
|
+
if (print || !opener)
|
|
8438
|
+
return EXIT.pass;
|
|
8439
|
+
const r = spawnSync4(opener, [dir], { stdio: "inherit" });
|
|
8440
|
+
if (r.error || r.status !== 0) {
|
|
8441
|
+
console.error(`firegoat open: ${opener} failed (${r.error?.message ?? `exit ${r.status}`}); the run is at the path above`);
|
|
8442
|
+
return EXIT.notGraded;
|
|
8443
|
+
}
|
|
8444
|
+
return EXIT.pass;
|
|
8445
|
+
}
|
|
8446
|
+
|
|
7909
8447
|
// src/cli.ts
|
|
7910
8448
|
var USAGE = `firegoat ${package_default.version}: local-first AX evals
|
|
7911
8449
|
|
|
7912
8450
|
Usage:
|
|
7913
8451
|
firegoat init [--yes] [--force] [--skills] write firegoat.yaml with starter evals
|
|
7914
|
-
firegoat
|
|
7915
|
-
|
|
8452
|
+
firegoat login [--claude-bin PATH] sign claude in to firegoat's own config dir (once)
|
|
8453
|
+
firegoat eval [test..] [--runs N] [--var KEY=value].. [--json] [--host] [--claude-bin PATH]
|
|
8454
|
+
run evals with claude in a sandboxed fresh workdir
|
|
8455
|
+
firegoat open [runId] [--print] open a run's folder (default: the latest)
|
|
7916
8456
|
firegoat --version
|
|
7917
8457
|
|
|
7918
8458
|
eval options:
|
|
7919
8459
|
test.. only run these tests (by name)
|
|
7920
8460
|
--runs N runs per test (overrides runs: in firegoat.yaml)
|
|
8461
|
+
--var KEY=value fills {{vars.KEY}} in prompts and checks, and is in the env of build: commands
|
|
7921
8462
|
--json JSON summary on stdout, progress on stderr
|
|
8463
|
+
--host no sandbox (needed off macOS; use a throwaway container)
|
|
7922
8464
|
--claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
|
|
7923
8465
|
|
|
7924
8466
|
firegoat.yaml is looked up in the current dir, then parents up to the git root.
|
|
@@ -7943,8 +8485,12 @@ async function main(argv) {
|
|
|
7943
8485
|
return 0;
|
|
7944
8486
|
case "init":
|
|
7945
8487
|
return initCommand(rest, process.cwd());
|
|
8488
|
+
case "login":
|
|
8489
|
+
return loginCommand(rest);
|
|
7946
8490
|
case "eval":
|
|
7947
8491
|
return evalCommand(rest, process.cwd());
|
|
8492
|
+
case "open":
|
|
8493
|
+
return openCommand(rest, process.cwd());
|
|
7948
8494
|
default:
|
|
7949
8495
|
console.error(`unknown command: ${cmd}
|
|
7950
8496
|
|
package/package.json
CHANGED
|
@@ -73,6 +73,12 @@ Schema (v1):
|
|
|
73
73
|
```yaml
|
|
74
74
|
agents: [claude-code] # only claude-code in v1
|
|
75
75
|
runs: 1 # runs per test, positive integer
|
|
76
|
+
build: # optional: local packages preinstalled in the agent's workdir
|
|
77
|
+
sdk: ./packages/sdk # package.json -> node_modules, pyproject.toml -> .venv on PATH
|
|
78
|
+
cli: # anything else: a command that writes outputs to $FIREGOAT_OUT
|
|
79
|
+
run: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme # outputs go on PATH
|
|
80
|
+
vars: # optional: defaults for {{vars.KEY}}; `--var KEY=value` overrides
|
|
81
|
+
SHA: main
|
|
76
82
|
tests:
|
|
77
83
|
- name: quickstart # letters, digits, '.', '_', '-'; unique
|
|
78
84
|
prompt: >-
|
|
@@ -83,6 +89,9 @@ tests:
|
|
|
83
89
|
timeout: 600 # optional, seconds
|
|
84
90
|
```
|
|
85
91
|
|
|
92
|
+
Add `build:` when the repo ships a package or CLI whose local build the tests should use;
|
|
93
|
+
then the prompt can say "The package is already installed from the build under test."
|
|
94
|
+
|
|
86
95
|
If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append
|
|
87
96
|
yours, and rename yours (`-2` suffix) if a name collides. Never delete or rewrite user tests.
|
|
88
97
|
Quote prompts with `>-` folded blocks so colons and quotes stay valid YAML.
|