firegoat 0.5.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +26 -8
  2. package/dist/cli.js +658 -334
  3. package/package.json +1 -1
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.5.0",
6950
+ version: "0.7.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/team2027/firegoat-cli.git"
@@ -6987,9 +6987,411 @@ var package_default = {
6987
6987
  packageManager: "bun@1.4.3"
6988
6988
  };
6989
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/help.ts
7212
+ var EXIT_CODES = `Exit codes:
7213
+ 0 every run's script checks passed
7214
+ 1 a script check failed
7215
+ 2 at least one run was not graded (claude missing or logged out, crash, timeout,
7216
+ setup failed); wins over 1
7217
+ 3 usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml)`;
7218
+ function topHelp(version) {
7219
+ return `firegoat ${version}: local AX evals. A coding agent gets one prompt in a fresh,
7220
+ sandboxed folder; firegoat records the transcript and runs your script checks.
7221
+
7222
+ Typical flow (for an agent driving firegoat on behalf of a human):
7223
+ 1. npx firegoat init --yes write firegoat.yaml (read: npx firegoat init --help)
7224
+ 2. edit firegoat.yaml real user problems as prompts, a goal, checks
7225
+ 3. ask the human to run once: npx firegoat claude login (opens a browser)
7226
+ 4. npx firegoat eval --json run the tests; JSON summary on stdout
7227
+ 5. judge each run's goal + rubric yourself, from its run.json, transcript and
7228
+ workdir: firegoat only runs the script checks
7229
+
7230
+ Commands:
7231
+ init write firegoat.yaml; choose how the agent signs in
7232
+ claude login sign claude in to firegoat's own config dir (interactive, once)
7233
+ eval run the tests
7234
+ open open (or --print) a run's folder
7235
+
7236
+ Run firegoat <command> --help for details and examples.
7237
+
7238
+ ${EXIT_CODES}`;
7239
+ }
7240
+ var INIT_HELP = `Usage: firegoat init [--yes] [--force] [--skills] [--auth claude|api-key]
7241
+
7242
+ Writes firegoat.yaml in the current folder with starter tests from what the repo
7243
+ ships (package.json, pyproject.toml, go.mod, Cargo.toml, README), and adds
7244
+ .firegoat/ to .gitignore. Edit the tests afterwards; they are templates.
7245
+
7246
+ --yes, -y don't ask questions (default when stdin is not a terminal)
7247
+ --force, -f overwrite an existing firegoat.yaml
7248
+ --skills also run: npx skills add team2027/skills --skill firegoat-onboarding
7249
+ --skill suggesting-eval-prompts (into .claude/skills/)
7250
+ --auth claude the agent signs in with a Claude login: runs \`firegoat claude login\`
7251
+ (interactive: a human must complete it in the browser)
7252
+ --auth api-key the agent signs in with an Anthropic API key, read from stdin when
7253
+ piped (else asked for without echo) and stored in
7254
+ ~/.firegoat/anthropic-api-key (mode 0600), never in the repo
7255
+ Without --auth, init asks (unless --yes). In CI, ANTHROPIC_API_KEY in
7256
+ the env wins.
7257
+
7258
+ firegoat.yaml reference:
7259
+ agents: [claude-code] only claude-code for now
7260
+ runs: 1 runs per test
7261
+ setup: <sh> | [<sh>, ...] once per eval, in the repo, on the host; whatever it
7262
+ writes to $FIREGOAT_OUT is copied into each run and put
7263
+ first on the agent's PATH
7264
+ workdir_setup: <sh> | [...] in each fresh workdir before the agent starts
7265
+ (env: $FIREGOAT_OUT, $FIREGOAT_WORKDIR, vars)
7266
+ vars: {KEY: value} defaults for {{vars.KEY}}; --var KEY=value overrides
7267
+ tests:
7268
+ - name: quickstart letters, digits, '.', '_', '-'; unique
7269
+ prompt: ... what a user would ask the agent; one concrete outcome
7270
+ goal: ... one sentence that is true when the problem is solved
7271
+ rubric: [...] optional criteria for whoever grades the run (you)
7272
+ checks: optional shell commands, run in the workdir after the
7273
+ - run: ... agent; exit 0 = pass; they set eval's exit code
7274
+ timeout: 600 optional, seconds
7275
+
7276
+ The agent's folder has no repo files. Checks get $FIREGOAT_WORKDIR,
7277
+ $FIREGOAT_SESSION (stream-json transcript), $FIREGOAT_OUT and the vars.
7278
+ goal and rubric are not run by firegoat: they are handed to the caller to judge.
7279
+
7280
+ Example:
7281
+ setup: npm pack --pack-destination "$FIREGOAT_OUT"
7282
+ workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
7283
+ tests:
7284
+ - name: first-call
7285
+ prompt: >-
7286
+ Using the acme SDK (already installed from the build under test), send a test
7287
+ event and print the event id. Credentials are in ACME_API_KEY. Keep it minimal.
7288
+ goal: A script sends one event and prints its id.
7289
+ rubric:
7290
+ - uses the SDK's client, not raw HTTP
7291
+ - does not install acme from the registry
7292
+ checks:
7293
+ - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_SESSION"
7294
+
7295
+ Next: npx firegoat claude login (a human, once), then npx firegoat eval.
7296
+
7297
+ Exit codes: 0 done, 2 the skills install or claude login failed, 3 usage error.`;
7298
+ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--var KEY=value].. [--json] [--host]
7299
+ [--claude-bin PATH]
7300
+
7301
+ Runs each test headless: claude gets the prompt in a fresh folder outside the repo,
7302
+ sandboxed on macOS (it can't read your home dir or the repo), with setup:'s outputs
7303
+ on PATH. Then the test's script checks run in that folder.
7304
+
7305
+ test.. only these tests (by name)
7306
+ --runs N runs per test (overrides runs: in firegoat.yaml)
7307
+ --var KEY=value fills {{vars.KEY}}; env for setup, workdir_setup and checks
7308
+ --json the summary as JSON on stdout; progress goes to stderr
7309
+ --host no sandbox (required off macOS; use a throwaway container)
7310
+ --claude-bin PATH claude binary (env FIREGOAT_CLAUDE_BIN; default: claude on PATH)
7311
+
7312
+ Sign-in: the one chosen in firegoat init (Claude login or stored API key); in CI,
7313
+ ANTHROPIC_API_KEY from the env. eval prints which. Not logged in = exit 2 with the
7314
+ command to run.
7315
+
7316
+ Output: .firegoat/runs/<runId>/
7317
+ summary.json every run, plus totals and a grading hint
7318
+ <test>/<n>/run.json one run: test, n, status and reason (from the checks),
7319
+ prompt, goal, rubric, checks [{run, code}], and paths:
7320
+ dir, workdir, session, stderr, checksLog
7321
+ <test>/<n>/session.jsonl the agent's transcript, stream-json (one event per line)
7322
+ setup.txt, <test>/<n>/workdir_setup.txt, checks.txt logs
7323
+
7324
+ Grading: firegoat runs only the script checks; they set the exit code. The goal and
7325
+ rubric are yours to judge, with your own process. session.jsonl is stream-json: the
7326
+ last line has type "result" with the agent's final message; tool calls are
7327
+ "tool_use" items in "assistant" messages; tool output is in "user" messages.
7328
+
7329
+ Examples:
7330
+ npx firegoat eval --json all tests, JSON summary on stdout
7331
+ npx firegoat eval quickstart --runs 3 one test, three runs
7332
+ npx firegoat eval --var SHA=$(git rev-parse HEAD)
7333
+
7334
+ firegoat.yaml is looked up in the current folder, then parents up to the git root.
7335
+ A run can take minutes (default timeout 600s per test).
7336
+
7337
+ ${EXIT_CODES}`;
7338
+ var OPEN_HELP = `Usage: firegoat open [runId] [--print]
7339
+
7340
+ Opens a run's folder (.firegoat/runs/<runId>/) with the system opener; the latest
7341
+ run by default. --print only prints the path (use this from an agent).
7342
+
7343
+ Exit codes: 0 done, 2 the opener failed, 3 no such run.`;
7344
+ var CLAUDE_LOGIN_HELP = `Usage: firegoat claude login [--claude-bin PATH]
7345
+
7346
+ Signs claude in to firegoat's own config dir (~/.firegoat/claude), once per machine.
7347
+ Evals then run on that login, never your ~/.claude. Interactive: it opens a browser,
7348
+ so an agent should ask the human to run it. \`firegoat login\` is a deprecated alias.
7349
+
7350
+ Exit codes: 0 logged in, 2 claude missing or the login failed, 3 usage error.`;
7351
+
7352
+ // src/claude-login.ts
7353
+ function claudeLoginCommand(args) {
7354
+ let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
7355
+ for (let i = 0;i < args.length; i++) {
7356
+ const a = args[i];
7357
+ if (a === "--help" || a === "-h") {
7358
+ console.log(CLAUDE_LOGIN_HELP);
7359
+ return EXIT.pass;
7360
+ }
7361
+ if (a === "--claude-bin" && args[i + 1] !== undefined)
7362
+ claudeBin = args[++i];
7363
+ else if (a.startsWith("--claude-bin="))
7364
+ claudeBin = a.slice(13);
7365
+ else {
7366
+ console.error(`firegoat claude login: unknown argument: ${a}`);
7367
+ return EXIT.usage;
7368
+ }
7369
+ }
7370
+ const configDir = claudeConfigDir();
7371
+ mkdirSync3(configDir, { recursive: true });
7372
+ const env = {
7373
+ ...process.env,
7374
+ CLAUDE_CONFIG_DIR: configDir
7375
+ };
7376
+ delete env.ANTHROPIC_API_KEY;
7377
+ console.error(`Signing claude in to ${configDir}. Evals run with this login, never your own ~/.claude.`);
7378
+ const r = spawnSync2(claudeBin, ["auth", "login"], { env, stdio: "inherit" });
7379
+ if (r.error) {
7380
+ console.error(`firegoat claude login: could not run ${claudeBin}: ${r.error.message}`);
7381
+ return EXIT.notGraded;
7382
+ }
7383
+ if (r.status !== 0) {
7384
+ console.error(`firegoat claude login: claude auth login exited ${r.status}`);
7385
+ return EXIT.notGraded;
7386
+ }
7387
+ saveMethod("claude");
7388
+ console.log("logged in; evals now sign in with this Claude login. next: npx firegoat eval");
7389
+ return EXIT.pass;
7390
+ }
7391
+
6990
7392
  // src/config.ts
6991
- import { existsSync, readFileSync } from "node:fs";
6992
- import { dirname, join } from "node:path";
7393
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
7394
+ import { dirname as dirname2, join as join3 } from "node:path";
6993
7395
 
6994
7396
  // node_modules/yaml/dist/index.js
6995
7397
  var composer = require_composer();
@@ -7062,7 +7464,7 @@ function posInt(v, path, fallback) {
7062
7464
  fail(path, "must be a positive integer");
7063
7465
  return v;
7064
7466
  }
7065
- function parseConfig(text, warn = console.error) {
7467
+ function parseConfig(text) {
7066
7468
  let raw;
7067
7469
  try {
7068
7470
  raw = $parse(text);
@@ -7102,20 +7504,21 @@ function parseConfig(text, warn = console.error) {
7102
7504
  if (!Array.isArray(checksRaw))
7103
7505
  fail(`${p}.checks`, "must be a list");
7104
7506
  const checks = [];
7507
+ const rubric = rubricOf(t.rubric, `${p}.rubric`);
7105
7508
  checksRaw.forEach((c, j) => {
7106
7509
  const cp = `${p}.checks[${j}]`;
7107
7510
  if (!isRecord(c))
7108
7511
  fail(cp, "must be a mapping");
7109
- if ("judge" in c) {
7110
- warn(`${CONFIG_FILE}: ${cp}: judge checks need upload, which v1 does not do; skipped`);
7111
- return;
7112
- }
7113
- checks.push({ run: str(c.run, `${cp}.run`) });
7512
+ if ("judge" in c)
7513
+ rubric.push(str(c.judge, `${cp}.judge`));
7514
+ else
7515
+ checks.push({ run: str(c.run, `${cp}.run`) });
7114
7516
  });
7115
7517
  return {
7116
7518
  name,
7117
7519
  prompt: str(t.prompt, `${p}.prompt`),
7118
7520
  goal: str(t.goal, `${p}.goal`),
7521
+ rubric,
7119
7522
  checks,
7120
7523
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
7121
7524
  };
@@ -7130,6 +7533,15 @@ function parseConfig(text, warn = console.error) {
7130
7533
  };
7131
7534
  }
7132
7535
  var NAME = /^[A-Za-z0-9._-]+$/;
7536
+ function rubricOf(raw, path) {
7537
+ if (raw === undefined)
7538
+ return [];
7539
+ if (typeof raw === "string")
7540
+ return [str(raw, path)];
7541
+ if (!Array.isArray(raw))
7542
+ fail(path, "must be a string or a list of strings");
7543
+ return raw.map((r, i) => str(r, `${path}[${i}]`));
7544
+ }
7133
7545
  function commands(raw, path) {
7134
7546
  if (raw === undefined)
7135
7547
  return [];
@@ -7167,19 +7579,19 @@ function render(text, vars) {
7167
7579
  return v;
7168
7580
  });
7169
7581
  }
7170
- function loadConfig(dir, warn) {
7582
+ function loadConfig(dir) {
7171
7583
  const file = `${dir}/${CONFIG_FILE}`;
7172
- if (!existsSync(file))
7584
+ if (!existsSync3(file))
7173
7585
  throw new ConfigError(`${CONFIG_FILE} not found in ${dir}. Run: npx firegoat init`);
7174
- return parseConfig(readFileSync(file, "utf8"), warn);
7586
+ return parseConfig(readFileSync2(file, "utf8"));
7175
7587
  }
7176
7588
  function findConfigDir(cwd) {
7177
7589
  let dir = cwd;
7178
7590
  for (;; ) {
7179
- if (existsSync(join(dir, CONFIG_FILE)))
7591
+ if (existsSync3(join3(dir, CONFIG_FILE)))
7180
7592
  return dir;
7181
- const parent = dirname(dir);
7182
- if (existsSync(join(dir, ".git")) || parent === dir)
7593
+ const parent = dirname2(dir);
7594
+ if (existsSync3(join3(dir, ".git")) || parent === dir)
7183
7595
  return cwd;
7184
7596
  dir = parent;
7185
7597
  }
@@ -7189,164 +7601,20 @@ function findConfigDir(cwd) {
7189
7601
  import { randomBytes } from "node:crypto";
7190
7602
  import {
7191
7603
  createWriteStream as createWriteStream3,
7192
- existsSync as existsSync3,
7193
- mkdirSync as mkdirSync3,
7604
+ existsSync as existsSync4,
7605
+ mkdirSync as mkdirSync5,
7194
7606
  mkdtempSync,
7195
- readFileSync as readFileSync2,
7607
+ readFileSync as readFileSync3,
7196
7608
  realpathSync as realpathSync2,
7197
- writeFileSync
7609
+ writeFileSync as writeFileSync2
7198
7610
  } from "node:fs";
7199
7611
  import { tmpdir } from "node:os";
7200
- import { join as join5, resolve as resolve2 } from "node:path";
7612
+ import { join as join6, resolve as resolve2 } from "node:path";
7201
7613
 
7202
7614
  // src/claude.ts
7203
- import { spawn, spawnSync as spawnSync2 } from "node:child_process";
7615
+ import { spawn, spawnSync as spawnSync3 } from "node:child_process";
7204
7616
  import { createWriteStream } from "node:fs";
7205
7617
  import { createInterface } from "node:readline";
7206
-
7207
- // src/sandbox.ts
7208
- import { spawnSync } from "node:child_process";
7209
- import { existsSync as existsSync2, mkdirSync, realpathSync } from "node:fs";
7210
- import { homedir } from "node:os";
7211
- import { basename, dirname as dirname2, join as join2 } from "node:path";
7212
- var SANDBOX_EXEC = "/usr/bin/sandbox-exec";
7213
- function firegoatHome() {
7214
- return process.env.FIREGOAT_HOME || join2(homedir(), ".firegoat");
7215
- }
7216
- function claudeConfigDir() {
7217
- return join2(firegoatHome(), "claude");
7218
- }
7219
- var TOOLCHAINS = [
7220
- ".nvm",
7221
- ".fnm",
7222
- ".volta",
7223
- ".bun",
7224
- ".deno",
7225
- ".cargo",
7226
- ".rustup",
7227
- ".pyenv",
7228
- ".rbenv",
7229
- ".asdf",
7230
- ".sdkman",
7231
- ".local/share/mise",
7232
- ".local/share/fnm",
7233
- ".local/share/claude",
7234
- ".local/share/uv",
7235
- "go",
7236
- "Library/pnpm"
7237
- ];
7238
- var TOOLCHAIN_SECRETS = [".cargo/credentials", ".cargo/credentials.toml"];
7239
- function real(p) {
7240
- return realpathSync(p);
7241
- }
7242
- function under(p, dir) {
7243
- return p === dir || p.startsWith(`${dir}/`);
7244
- }
7245
- function readableToolDirs(home, claudeBin) {
7246
- const dirs = new Set;
7247
- const add = (p) => {
7248
- if (existsSync2(p))
7249
- dirs.add(real(p));
7250
- };
7251
- for (const t of TOOLCHAINS)
7252
- add(join2(home, t));
7253
- for (const p of (process.env.PATH ?? "").split(":")) {
7254
- if (!p.startsWith("/") || !existsSync2(p))
7255
- continue;
7256
- const r = real(p);
7257
- if (!under(r, home) || r === home)
7258
- continue;
7259
- const prefix = dirname2(r);
7260
- dirs.add(basename(r) === "bin" && prefix !== home && prefix !== join2(home, ".local") ? prefix : r);
7261
- }
7262
- add(dirname2(dirname2(process.execPath)));
7263
- add(dirname2(claudeBin));
7264
- add(dirname2(real(claudeBin)));
7265
- return [...dirs];
7266
- }
7267
- var q = (s) => JSON.stringify(s);
7268
- function sandboxProfile(opts) {
7269
- const home = real(homedir());
7270
- const reads = readableToolDirs(home, opts.claudeBin).map((d) => `(subpath ${q(d)})`);
7271
- return `(version 1)
7272
- (allow default)
7273
-
7274
- (deny file-read* (subpath ${q(home)}))
7275
- (allow file-read-metadata)
7276
- (allow file-read*
7277
- ${reads.join(`
7278
- `)}
7279
- (subpath ${q(join2(home, "Library/Keychains"))})
7280
- (regex ${q(`^${home}/Library/Preferences/com\\.apple\\.security`)})
7281
- (regex ${q(`^${home}/Library/Preferences/\\.GlobalPreferences`)}))
7282
- (deny file-read* file-write*
7283
- ${TOOLCHAIN_SECRETS.map((s) => `(literal ${q(join2(home, s))})`).join(`
7284
- `)})
7285
-
7286
- (deny file-write* (subpath "/"))
7287
- (allow file-write*
7288
- (subpath "/dev")
7289
- (subpath "/private/tmp")
7290
- (subpath "/private/var/folders"))
7291
- (allow file-read* file-write*
7292
- (subpath ${q(real(opts.configDir))})
7293
- (subpath ${q(real(opts.runRoot))}))
7294
-
7295
- (deny file-read* file-write* (subpath ${q(real(opts.repo))}))
7296
- `;
7297
- }
7298
- function agentEnv(runRoot) {
7299
- const env = { ...process.env };
7300
- delete env.ANTHROPIC_API_KEY;
7301
- const cache = join2(runRoot, "cache");
7302
- const tmp = join2(runRoot, "tmp");
7303
- mkdirSync(cache, { recursive: true });
7304
- mkdirSync(tmp, { recursive: true });
7305
- return {
7306
- ...env,
7307
- CLAUDE_CONFIG_DIR: claudeConfigDir(),
7308
- TMPDIR: tmp,
7309
- XDG_CACHE_HOME: cache,
7310
- npm_config_cache: join2(cache, "npm"),
7311
- npm_config_store_dir: join2(cache, "pnpm-store"),
7312
- YARN_CACHE_FOLDER: join2(cache, "yarn"),
7313
- BUN_INSTALL_CACHE_DIR: join2(cache, "bun"),
7314
- PIP_CACHE_DIR: join2(cache, "pip"),
7315
- UV_CACHE_DIR: join2(cache, "uv"),
7316
- GOMODCACHE: join2(cache, "gomod"),
7317
- GOCACHE: join2(cache, "gobuild"),
7318
- CARGO_HOME: join2(cache, "cargo")
7319
- };
7320
- }
7321
- function wrap(profile, cmd, args) {
7322
- if (profile === null)
7323
- return { cmd, args };
7324
- return { cmd: SANDBOX_EXEC, args: ["-p", profile, cmd, ...args] };
7325
- }
7326
- function probeSandbox(opts) {
7327
- const outside = join2(homedir(), `.firegoat-escape-probe-${process.pid}-${Date.now()}`);
7328
- 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"';
7329
- const { cmd, args } = wrap(opts.profile, "/bin/sh", [
7330
- "-c",
7331
- script,
7332
- "probe",
7333
- outside,
7334
- opts.repoFile,
7335
- join2(opts.workdir, ".firegoat-probe")
7336
- ]);
7337
- const r = spawnSync(cmd, args, { encoding: "utf8", cwd: opts.workdir });
7338
- if (r.error)
7339
- return `sandbox probe could not start: ${r.error.message}`;
7340
- const got = r.stdout.trim().split(`
7341
- `).filter(Boolean);
7342
- if (got.includes("home-write") || got.includes("repo-read"))
7343
- return `sandbox does not hold (escaped: ${got.filter((g) => g !== "work-write").join(", ")}); refusing to run the agent`;
7344
- if (!got.includes("work-write"))
7345
- return `sandbox blocks writes in the workdir (stderr: ${r.stderr.trim() || "none"})`;
7346
- return;
7347
- }
7348
-
7349
- // src/claude.ts
7350
7618
  var CLAUDE_FLAGS = [
7351
7619
  "--output-format",
7352
7620
  "stream-json",
@@ -7511,8 +7779,8 @@ async function runClaude(opts) {
7511
7779
  badStart = `claude output did not start with a system/init stream-json line (got: ${line.slice(0, 120)})`;
7512
7780
  return "kill";
7513
7781
  }
7514
- if (msg.apiKeySource !== "none") {
7515
- badStart = `claude would use API key billing (apiKeySource=${JSON.stringify(msg.apiKeySource)}) instead of your Claude subscription. firegoat removes ANTHROPIC_API_KEY from the agent's env; check apiKeyHelper or other API key settings, then run \`claude /login\``;
7782
+ if (msg.apiKeySource !== opts.keySource) {
7783
+ 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")`;
7516
7784
  return "kill";
7517
7785
  }
7518
7786
  return;
@@ -7592,7 +7860,7 @@ async function runShell(opts) {
7592
7860
  }
7593
7861
  function checkLogin(opts) {
7594
7862
  const { cmd, args } = wrap(opts.profile, opts.bin, ["auth", "status"]);
7595
- const r = spawnSync2(cmd, args, {
7863
+ const r = spawnSync3(cmd, args, {
7596
7864
  cwd: opts.cwd,
7597
7865
  env: opts.env,
7598
7866
  encoding: "utf8",
@@ -7607,16 +7875,13 @@ function checkLogin(opts) {
7607
7875
  if (status?.loggedIn === true)
7608
7876
  return;
7609
7877
  if (status?.loggedIn === false)
7610
- return `claude is not logged in to firegoat's config dir (${opts.env.CLAUDE_CONFIG_DIR}). Run: npx firegoat login`;
7878
+ return `claude is not logged in to firegoat's config dir (${opts.env.CLAUDE_CONFIG_DIR}). Run: npx firegoat claude login`;
7611
7879
  return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
7612
7880
  }
7613
7881
 
7614
- // src/exit.ts
7615
- var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7616
-
7617
7882
  // src/setup.ts
7618
- import { cpSync, createWriteStream as createWriteStream2, mkdirSync as mkdirSync2 } from "node:fs";
7619
- import { delimiter, join as join3 } from "node:path";
7883
+ import { cpSync, createWriteStream as createWriteStream2, mkdirSync as mkdirSync4 } from "node:fs";
7884
+ import { delimiter, join as join4 } from "node:path";
7620
7885
  var SETUP_TIMEOUT_MS = 600000;
7621
7886
  async function runAll(commands, cwd, env, logPath, what) {
7622
7887
  const log = createWriteStream2(logPath);
@@ -7638,13 +7903,13 @@ async function runAll(commands, cwd, env, logPath, what) {
7638
7903
  }
7639
7904
  }
7640
7905
  async function setupOnce(opts) {
7641
- mkdirSync2(opts.out, { recursive: true });
7906
+ mkdirSync4(opts.out, { recursive: true });
7642
7907
  return runAll(opts.commands, opts.root, { ...opts.env, FIREGOAT_OUT: opts.out }, opts.logPath, "setup");
7643
7908
  }
7644
7909
  async function prepareRun(opts) {
7645
- const out = join3(opts.runRoot, "out");
7910
+ const out = join4(opts.runRoot, "out");
7646
7911
  if (opts.out === null)
7647
- mkdirSync2(out);
7912
+ mkdirSync4(out);
7648
7913
  else
7649
7914
  cpSync(opts.out, out, { recursive: true });
7650
7915
  const path = [out, opts.env.PATH ?? ""].join(delimiter);
@@ -7659,7 +7924,7 @@ async function prepareRun(opts) {
7659
7924
 
7660
7925
  // src/which.ts
7661
7926
  import { accessSync, constants, statSync } from "node:fs";
7662
- import { delimiter as delimiter2, join as join4, resolve } from "node:path";
7927
+ import { delimiter as delimiter2, join as join5, resolve } from "node:path";
7663
7928
  function resolveBin(bin) {
7664
7929
  const executable = (p) => {
7665
7930
  try {
@@ -7672,14 +7937,39 @@ function resolveBin(bin) {
7672
7937
  if (bin.includes("/"))
7673
7938
  return executable(bin) ? resolve(bin) : undefined;
7674
7939
  for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
7675
- if (dir && executable(join4(dir, bin)))
7676
- return join4(dir, bin);
7940
+ if (dir && executable(join5(dir, bin)))
7941
+ return join5(dir, bin);
7677
7942
  }
7678
7943
  return;
7679
7944
  }
7680
7945
 
7681
7946
  // src/eval.ts
7682
7947
  var CHECK_TIMEOUT_MS = 120000;
7948
+ var GRADING_HINT = "firegoat ran only the script checks. The goal and rubric are for the caller to judge, from each run's session (stream-json transcript) and workdir.";
7949
+ function blankRun(test, n, dir, prompt) {
7950
+ return {
7951
+ test: test.name,
7952
+ n,
7953
+ status: "error",
7954
+ reason: "",
7955
+ prompt,
7956
+ goal: test.goal,
7957
+ rubric: test.rubric,
7958
+ checks: [],
7959
+ dir,
7960
+ workdir: null,
7961
+ session: join6(dir, "session.jsonl"),
7962
+ stderr: join6(dir, "stderr.txt"),
7963
+ checksLog: join6(dir, "checks.txt"),
7964
+ num_turns: null,
7965
+ cost: null,
7966
+ duration_ms: 0
7967
+ };
7968
+ }
7969
+ function writeRunJson(r) {
7970
+ writeFileSync2(join6(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
7971
+ `);
7972
+ }
7683
7973
 
7684
7974
  class UsageError extends Error {
7685
7975
  }
@@ -7735,16 +8025,16 @@ function newRunId() {
7735
8025
  return `${ts}-${randomBytes(2).toString("hex")}`;
7736
8026
  }
7737
8027
  function gitignoreHasFiregoat(cwd) {
7738
- const file = join5(cwd, ".gitignore");
7739
- if (!existsSync3(file))
8028
+ const file = join6(cwd, ".gitignore");
8029
+ if (!existsSync4(file))
7740
8030
  return false;
7741
- return readFileSync2(file, "utf8").split(`
8031
+ return readFileSync3(file, "utf8").split(`
7742
8032
  `).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
7743
8033
  }
7744
8034
  function newRunRoot(label) {
7745
- const runRoot = realpathSync2(mkdtempSync(join5(tmpdir(), `firegoat-${label}-`)));
7746
- const workdir = join5(runRoot, "work");
7747
- mkdirSync3(workdir);
8035
+ const runRoot = realpathSync2(mkdtempSync(join6(tmpdir(), `firegoat-${label}-`)));
8036
+ const workdir = join6(runRoot, "work");
8037
+ mkdirSync5(workdir);
7748
8038
  return { runRoot, workdir };
7749
8039
  }
7750
8040
  function isolate(iso, claudeBin, runRoot, workdir) {
@@ -7762,25 +8052,15 @@ function isolate(iso, claudeBin, runRoot, workdir) {
7762
8052
  async function runOne(opts) {
7763
8053
  const { test, n } = opts;
7764
8054
  const started = Date.now();
7765
- const dir = join5(opts.runDir, test.name, String(n));
7766
- mkdirSync3(dir, { recursive: true });
7767
- const session = join5(dir, "session.jsonl");
7768
- const summary = {
7769
- test: test.name,
7770
- n,
7771
- status: "error",
7772
- reason: "",
7773
- workdir: null,
7774
- session,
7775
- checks: [],
7776
- num_turns: null,
7777
- cost: null,
7778
- duration_ms: 0
7779
- };
8055
+ const dir = join6(opts.runDir, test.name, String(n));
8056
+ mkdirSync5(dir, { recursive: true });
8057
+ const summary = blankRun(test, n, dir, render(test.prompt, opts.vars));
8058
+ const session = summary.session;
7780
8059
  const done = (status, reason) => {
7781
8060
  summary.status = status;
7782
8061
  summary.reason = reason;
7783
8062
  summary.duration_ms = Date.now() - started;
8063
+ writeRunJson(summary);
7784
8064
  return summary;
7785
8065
  };
7786
8066
  const { runRoot, workdir } = newRunRoot(`${test.name}-${n}`);
@@ -7797,7 +8077,7 @@ async function runOne(opts) {
7797
8077
  workdir,
7798
8078
  commands: opts.workdirSetup,
7799
8079
  env: hostEnv,
7800
- logPath: join5(dir, "workdir_setup.txt")
8080
+ logPath: join6(dir, "workdir_setup.txt")
7801
8081
  });
7802
8082
  if ("error" in prepared)
7803
8083
  return done("error", `not graded: ${prepared.error}`);
@@ -7806,13 +8086,17 @@ async function runOne(opts) {
7806
8086
  }
7807
8087
  const agentPath = extra.PATH ? { PATH: extra.PATH } : {};
7808
8088
  const outcome = await runClaude({
7809
- env: { ...agentEnv(runRoot), ...agentPath },
8089
+ env: {
8090
+ ...agentEnv(runRoot, opts.auth.method === "api-key" ? opts.auth.apiKey : undefined),
8091
+ ...agentPath
8092
+ },
8093
+ keySource: opts.auth.method === "api-key" ? "ANTHROPIC_API_KEY" : "none",
7810
8094
  profile: sandbox.profile,
7811
8095
  bin: opts.claudeBin,
7812
- prompt: render(test.prompt, opts.vars),
8096
+ prompt: summary.prompt,
7813
8097
  cwd: workdir,
7814
8098
  sessionPath: session,
7815
- stderrPath: join5(dir, "stderr.txt"),
8099
+ stderrPath: summary.stderr,
7816
8100
  timeoutMs: test.timeout * 1000
7817
8101
  });
7818
8102
  if (outcome.result) {
@@ -7821,11 +8105,9 @@ async function runOne(opts) {
7821
8105
  }
7822
8106
  if (!outcome.ok)
7823
8107
  return done("error", `not graded: ${outcome.reason}`);
7824
- if (test.checks.length === 0) {
7825
- opts.warn(`warning: ${test.name} has no checks, so its goal is unverified; counted as pass`);
7826
- return done("pass", "no checks (goal unverified)");
7827
- }
7828
- const log = createWriteStream3(join5(dir, "checks.txt"));
8108
+ if (test.checks.length === 0)
8109
+ return done("pass", "no script checks; the goal is for the grader");
8110
+ const log = createWriteStream3(summary.checksLog);
7829
8111
  const env = {
7830
8112
  ...process.env,
7831
8113
  ...hostEnv,
@@ -7847,7 +8129,7 @@ async function runOne(opts) {
7847
8129
  } finally {
7848
8130
  await new Promise((r) => log.end(r));
7849
8131
  }
7850
- summary.checks = results.map((r) => ({ run: r.run, code: r.code }));
8132
+ summary.checks = results.map((r) => r.error === undefined ? { run: r.run, code: r.code } : { run: r.run, code: r.code, error: r.error });
7851
8133
  const broken = results.find((r) => r.notStarted);
7852
8134
  if (broken)
7853
8135
  return done("error", `not graded: check ${broken.run}: ${broken.error}`);
@@ -7857,6 +8139,10 @@ async function runOne(opts) {
7857
8139
  return done("pass", `${results.length}/${results.length} checks passed`);
7858
8140
  }
7859
8141
  async function evalCommand(args, cwd) {
8142
+ if (args.includes("--help") || args.includes("-h")) {
8143
+ console.log(EVAL_HELP);
8144
+ return EXIT.pass;
8145
+ }
7860
8146
  let opts;
7861
8147
  try {
7862
8148
  opts = parseArgs(args);
@@ -7870,7 +8156,7 @@ async function evalCommand(args, cwd) {
7870
8156
  const say = opts.json ? console.error : console.log;
7871
8157
  const warn = console.error;
7872
8158
  const root = findConfigDir(cwd);
7873
- const config = loadConfig(root, warn);
8159
+ const config = loadConfig(root);
7874
8160
  let tests = config.tests;
7875
8161
  if (opts.names.length > 0) {
7876
8162
  const known = new Set(tests.map((t) => t.name));
@@ -7897,45 +8183,58 @@ async function evalCommand(args, cwd) {
7897
8183
  }
7898
8184
  if (opts.host)
7899
8185
  warn("warning: --host: the agent runs without isolation and can read and write your files");
7900
- else if (process.platform !== "darwin" || !existsSync3(SANDBOX_EXEC)) {
8186
+ else if (process.platform !== "darwin" || !existsSync4(SANDBOX_EXEC)) {
7901
8187
  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.`);
7902
8188
  return EXIT.usage;
7903
8189
  }
7904
8190
  const iso = {
7905
8191
  host: opts.host,
7906
8192
  repo: root,
7907
- repoFile: join5(root, "firegoat.yaml")
8193
+ repoFile: join6(root, "firegoat.yaml")
7908
8194
  };
7909
- mkdirSync3(claudeConfigDir(), { recursive: true });
8195
+ let auth;
8196
+ try {
8197
+ auth = resolveAuth(process.env);
8198
+ } catch (e) {
8199
+ if (e instanceof AuthError) {
8200
+ console.error(`firegoat eval: ${e.message}`);
8201
+ return EXIT.usage;
8202
+ }
8203
+ throw e;
8204
+ }
8205
+ say(`auth: ${auth.label}`);
8206
+ if (auth.method === "claude" && process.env.ANTHROPIC_API_KEY)
8207
+ 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`)");
8208
+ mkdirSync5(claudeConfigDir(), { recursive: true });
7910
8209
  const pre = newRunRoot("preflight");
7911
8210
  const preSandbox = isolate(iso, claudePath, pre.runRoot, pre.workdir);
7912
- const loginProblem = "error" in preSandbox ? preSandbox.error : checkLogin({
8211
+ const loginProblem = "error" in preSandbox ? preSandbox.error : auth.method === "claude" ? checkLogin({
7913
8212
  bin: claudePath,
7914
8213
  env: agentEnv(pre.runRoot),
7915
8214
  profile: preSandbox.profile,
7916
8215
  cwd: pre.workdir
7917
- });
8216
+ }) : undefined;
7918
8217
  if (loginProblem) {
7919
8218
  console.error(`firegoat eval: not graded: ${loginProblem}`);
7920
8219
  return EXIT.notGraded;
7921
8220
  }
7922
- mkdirSync3(join5(root, ".firegoat"), { recursive: true });
8221
+ mkdirSync5(join6(root, ".firegoat"), { recursive: true });
7923
8222
  if (!gitignoreHasFiregoat(root))
7924
8223
  warn("hint: add .firegoat/ to .gitignore (it holds run traces)");
7925
8224
  const runId = newRunId();
7926
- const runDir = join5(root, ".firegoat", "runs", runId);
7927
- mkdirSync3(runDir, { recursive: true });
7928
- const summaryPath = join5(runDir, "summary.json");
8225
+ const runDir = join6(root, ".firegoat", "runs", runId);
8226
+ mkdirSync5(runDir, { recursive: true });
8227
+ const summaryPath = join6(runDir, "summary.json");
7929
8228
  let out = null;
7930
8229
  if (config.setup.length > 0) {
7931
8230
  say("setup...");
7932
- out = join5(runDir, "out");
8231
+ out = join6(runDir, "out");
7933
8232
  const error = await setupOnce({
7934
8233
  commands: config.setup,
7935
8234
  root,
7936
8235
  out,
7937
8236
  env: { ...process.env, ...vars },
7938
- logPath: join5(runDir, "setup.txt")
8237
+ logPath: join6(runDir, "setup.txt")
7939
8238
  });
7940
8239
  if (error) {
7941
8240
  console.error(`firegoat eval: not graded: ${error}`);
@@ -7951,10 +8250,12 @@ async function evalCommand(args, cwd) {
7951
8250
  runId,
7952
8251
  runsDir: runDir,
7953
8252
  isolation: opts.host ? "host" : "seatbelt",
8253
+ auth: auth.method,
8254
+ grading: GRADING_HINT,
7954
8255
  totals,
7955
8256
  runs: results
7956
8257
  };
7957
- writeFileSync(summaryPath, `${JSON.stringify(doc, null, 2)}
8258
+ writeFileSync2(summaryPath, `${JSON.stringify(doc, null, 2)}
7958
8259
  `);
7959
8260
  return doc;
7960
8261
  };
@@ -7968,24 +8269,18 @@ async function evalCommand(args, cwd) {
7968
8269
  runDir,
7969
8270
  claudeBin: claudePath,
7970
8271
  iso,
8272
+ auth,
7971
8273
  out,
7972
8274
  workdirSetup: config.workdirSetup,
7973
8275
  vars,
7974
8276
  warn
7975
8277
  });
7976
8278
  } catch (e) {
7977
- r = {
7978
- test: test.name,
7979
- n,
7980
- status: "error",
7981
- reason: `not graded: firegoat crashed: ${e.message}`,
7982
- workdir: null,
7983
- session: join5(runDir, test.name, String(n), "session.jsonl"),
7984
- checks: [],
7985
- num_turns: null,
7986
- cost: null,
7987
- duration_ms: 0
7988
- };
8279
+ const dir = join6(runDir, test.name, String(n));
8280
+ mkdirSync5(dir, { recursive: true });
8281
+ r = blankRun(test, n, dir, test.prompt);
8282
+ r.reason = `not graded: firegoat crashed: ${e.message}`;
8283
+ writeRunJson(r);
7989
8284
  }
7990
8285
  results.push(r);
7991
8286
  writeSummary();
@@ -7997,23 +8292,18 @@ async function evalCommand(args, cwd) {
7997
8292
  say(`
7998
8293
  ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
7999
8294
  say(`runs: ${runDir}`);
8295
+ say(`goal and rubric are not graded by firegoat; per run: ${join6(runDir, "<test>", "<n>", "run.json")}`);
8000
8296
  if (opts.json)
8001
8297
  console.log(JSON.stringify(doc, null, 2));
8002
8298
  return error > 0 ? EXIT.notGraded : fail > 0 ? EXIT.fail : EXIT.pass;
8003
8299
  }
8004
8300
 
8005
8301
  // src/init.ts
8006
- import { spawnSync as spawnSync3 } from "node:child_process";
8007
- import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
8008
- import { basename as basename2, join as join6 } from "node:path";
8302
+ import { spawnSync as spawnSync4 } from "node:child_process";
8303
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
8304
+ import { basename as basename2, join as join7 } from "node:path";
8009
8305
  import { createInterface as createInterface2 } from "node:readline/promises";
8010
- var INIT_USAGE = `Usage: firegoat init [--yes] [--force] [--skills]
8011
-
8012
- --yes, -y don't ask questions (default when stdin is not a terminal)
8013
- --force, -f overwrite an existing ${CONFIG_FILE}
8014
- --skills install the firegoat-onboarding and suggesting-eval-prompts skills
8015
- into .claude/skills/ (npx skills add team2027/skills)
8016
- `;
8306
+ import { Writable } from "node:stream";
8017
8307
  var SKILLS = ["firegoat-onboarding", "suggesting-eval-prompts"];
8018
8308
  var SKILLS_ARGV = [
8019
8309
  "skills",
@@ -8027,7 +8317,7 @@ var SKILLS_ARGV = [
8027
8317
  var GITIGNORE_LINE = ".firegoat/";
8028
8318
  function readText(file) {
8029
8319
  try {
8030
- return readFileSync3(file, "utf8");
8320
+ return readFileSync4(file, "utf8");
8031
8321
  } catch {
8032
8322
  return;
8033
8323
  }
@@ -8053,9 +8343,9 @@ function tomlSections(text) {
8053
8343
  return sections;
8054
8344
  }
8055
8345
  function detect(cwd) {
8056
- const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join6(cwd, f)));
8346
+ const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync5(join7(cwd, f)));
8057
8347
  const fallback = { name: basename2(cwd), library: true, readme };
8058
- const pkgText = readText(join6(cwd, "package.json"));
8348
+ const pkgText = readText(join7(cwd, "package.json"));
8059
8349
  if (pkgText !== undefined) {
8060
8350
  try {
8061
8351
  const pkg = JSON.parse(pkgText);
@@ -8070,7 +8360,7 @@ function detect(cwd) {
8070
8360
  return { ecosystem: "npm", name, bin, library, readme, docsUrl };
8071
8361
  } catch {}
8072
8362
  }
8073
- const pyText = readText(join6(cwd, "pyproject.toml"));
8363
+ const pyText = readText(join7(cwd, "pyproject.toml"));
8074
8364
  if (pyText !== undefined) {
8075
8365
  const s = tomlSections(pyText);
8076
8366
  const name = s.get("project")?.get("name") ?? s.get("tool.poetry")?.get("name") ?? fallback.name;
@@ -8080,12 +8370,12 @@ function detect(cwd) {
8080
8370
  const docsUrl = urls?.get("Documentation") ?? urls?.get("documentation") ?? urls?.get("Homepage") ?? urls?.get("homepage");
8081
8371
  return { ecosystem: "python", name, bin, library: true, readme, docsUrl };
8082
8372
  }
8083
- const goText = readText(join6(cwd, "go.mod"));
8373
+ const goText = readText(join7(cwd, "go.mod"));
8084
8374
  if (goText !== undefined) {
8085
8375
  const name = goText.match(/^module\s+(\S+)/m)?.[1] ?? fallback.name;
8086
8376
  return { ecosystem: "go", name, library: true, readme };
8087
8377
  }
8088
- const cargoText = readText(join6(cwd, "Cargo.toml"));
8378
+ const cargoText = readText(join7(cwd, "Cargo.toml"));
8089
8379
  if (cargoText !== undefined) {
8090
8380
  const name = tomlSections(cargoText).get("package")?.get("name");
8091
8381
  return {
@@ -8210,10 +8500,10 @@ function renderYaml(p, tests) {
8210
8500
  `;
8211
8501
  }
8212
8502
  function ensureGitignore(cwd) {
8213
- const file = join6(cwd, ".gitignore");
8503
+ const file = join7(cwd, ".gitignore");
8214
8504
  const text = readText(file);
8215
8505
  if (text === undefined) {
8216
- writeFileSync2(file, `${GITIGNORE_LINE}
8506
+ writeFileSync3(file, `${GITIGNORE_LINE}
8217
8507
  `);
8218
8508
  return `created .gitignore with ${GITIGNORE_LINE}`;
8219
8509
  }
@@ -8223,12 +8513,12 @@ function ensureGitignore(cwd) {
8223
8513
  const sep = text === "" || text.endsWith(`
8224
8514
  `) ? "" : `
8225
8515
  `;
8226
- writeFileSync2(file, `${text}${sep}${GITIGNORE_LINE}
8516
+ writeFileSync3(file, `${text}${sep}${GITIGNORE_LINE}
8227
8517
  `);
8228
8518
  return `added ${GITIGNORE_LINE} to .gitignore`;
8229
8519
  }
8230
8520
  function installSkills(cwd) {
8231
- const r = spawnSync3("npx", ["-y", ...SKILLS_ARGV], {
8521
+ const r = spawnSync4("npx", ["-y", ...SKILLS_ARGV], {
8232
8522
  cwd,
8233
8523
  stdio: "inherit"
8234
8524
  });
@@ -8238,6 +8528,69 @@ function installSkills(cwd) {
8238
8528
  return `exit ${r.status ?? "by signal"}`;
8239
8529
  return;
8240
8530
  }
8531
+ async function ask(question) {
8532
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
8533
+ try {
8534
+ return (await rl.question(question)).trim();
8535
+ } finally {
8536
+ rl.close();
8537
+ }
8538
+ }
8539
+ async function readSecret(question) {
8540
+ if (!process.stdin.isTTY) {
8541
+ const chunks = [];
8542
+ for await (const c of process.stdin)
8543
+ chunks.push(c);
8544
+ return Buffer.concat(chunks).toString("utf8").trim();
8545
+ }
8546
+ process.stdout.write(question);
8547
+ const silent = new Writable({ write: (_c, _e, cb) => cb() });
8548
+ const rl = createInterface2({
8549
+ input: process.stdin,
8550
+ output: silent,
8551
+ terminal: true
8552
+ });
8553
+ try {
8554
+ return (await rl.question("")).trim();
8555
+ } finally {
8556
+ rl.close();
8557
+ process.stdout.write(`
8558
+ `);
8559
+ }
8560
+ }
8561
+ async function chooseAuth() {
8562
+ let current;
8563
+ try {
8564
+ current = resolveAuth({}).label;
8565
+ } catch (e) {
8566
+ current = e.message;
8567
+ }
8568
+ console.log(`How should the agent sign in? (now: ${current})
8569
+ 1) Login with Claude (your Claude plan)
8570
+ 2) Paste an Anthropic API key`);
8571
+ for (;; ) {
8572
+ const a = await ask("choose [1/2]: ");
8573
+ if (a === "1")
8574
+ return "claude";
8575
+ if (a === "2")
8576
+ return "api-key";
8577
+ console.log("type 1 or 2");
8578
+ }
8579
+ }
8580
+ async function setUpAuth(method) {
8581
+ if (method === "claude") {
8582
+ const code = claudeLoginCommand([]);
8583
+ return code === 0 ? undefined : code;
8584
+ }
8585
+ const key = await readSecret("Anthropic API key (input hidden): ");
8586
+ if (!looksLikeAnthropicKey(key)) {
8587
+ console.error("firegoat init: that does not look like an Anthropic API key (sk-ant-...); nothing stored");
8588
+ return EXIT.usage;
8589
+ }
8590
+ const file = saveApiKey(key);
8591
+ console.log(`stored the API key in ${file} (mode 0600, not in the repo); evals pass it to the agent as ANTHROPIC_API_KEY`);
8592
+ return;
8593
+ }
8241
8594
  async function askYesNo(question) {
8242
8595
  const rl = createInterface2({ input: process.stdin, output: process.stdout });
8243
8596
  try {
@@ -8261,7 +8614,18 @@ async function initCommand(args, cwd) {
8261
8614
  let yes = false;
8262
8615
  let force = false;
8263
8616
  let skills = false;
8264
- for (const a of args) {
8617
+ let auth;
8618
+ for (let i = 0;i < args.length; i++) {
8619
+ const a = args[i];
8620
+ if (a === "--auth" || a.startsWith("--auth=")) {
8621
+ const v = a === "--auth" ? args[++i] : a.slice(7);
8622
+ if (v !== "claude" && v !== "api-key") {
8623
+ console.error(`firegoat init: --auth must be claude or api-key, got ${v ?? "nothing"}`);
8624
+ return EXIT.usage;
8625
+ }
8626
+ auth = v;
8627
+ continue;
8628
+ }
8265
8629
  switch (a) {
8266
8630
  case "--yes":
8267
8631
  case "-y":
@@ -8276,18 +8640,18 @@ async function initCommand(args, cwd) {
8276
8640
  break;
8277
8641
  case "--help":
8278
8642
  case "-h":
8279
- console.log(INIT_USAGE);
8643
+ console.log(INIT_HELP);
8280
8644
  return 0;
8281
8645
  default:
8282
8646
  console.error(`firegoat init: unknown argument: ${a}
8283
8647
 
8284
- ${INIT_USAGE}`);
8648
+ ${INIT_HELP}`);
8285
8649
  return EXIT.usage;
8286
8650
  }
8287
8651
  }
8288
- const configPath = join6(cwd, CONFIG_FILE);
8289
- const keepConfig = existsSync4(configPath) && !force;
8290
- if (keepConfig && !skills) {
8652
+ const configPath = join7(cwd, CONFIG_FILE);
8653
+ const keepConfig = existsSync5(configPath) && !force;
8654
+ if (keepConfig && !skills && !auth) {
8291
8655
  console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
8292
8656
  return EXIT.usage;
8293
8657
  }
@@ -8297,11 +8661,18 @@ ${INIT_USAGE}`);
8297
8661
  const project = detect(cwd);
8298
8662
  const tests = templates(project);
8299
8663
  console.log(`detected: ${describe(project)}`);
8300
- writeFileSync2(configPath, renderYaml(project, tests));
8664
+ writeFileSync3(configPath, renderYaml(project, tests));
8301
8665
  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`);
8302
8666
  }
8303
8667
  console.log(ensureGitignore(cwd));
8304
8668
  const interactive = !yes && process.stdin.isTTY && process.stdout.isTTY;
8669
+ if (!auth && interactive)
8670
+ auth = await chooseAuth();
8671
+ if (auth) {
8672
+ const failed = await setUpAuth(auth);
8673
+ if (failed !== undefined)
8674
+ return failed;
8675
+ }
8305
8676
  if (!skills && interactive)
8306
8677
  skills = await askYesNo(`Add the ${SKILLS.join(" and ")} skills to .claude/skills/ (help your agent draft better evals)?`);
8307
8678
  if (skills) {
@@ -8318,47 +8689,10 @@ ${INIT_USAGE}`);
8318
8689
  return 0;
8319
8690
  }
8320
8691
 
8321
- // src/login.ts
8322
- import { spawnSync as spawnSync4 } from "node:child_process";
8323
- import { mkdirSync as mkdirSync4 } from "node:fs";
8324
- function loginCommand(args) {
8325
- let claudeBin = process.env.FIREGOAT_CLAUDE_BIN || "claude";
8326
- for (let i = 0;i < args.length; i++) {
8327
- const a = args[i];
8328
- if (a === "--claude-bin" && args[i + 1] !== undefined)
8329
- claudeBin = args[++i];
8330
- else if (a.startsWith("--claude-bin="))
8331
- claudeBin = a.slice(13);
8332
- else {
8333
- console.error(`firegoat login: unknown argument: ${a}`);
8334
- return EXIT.usage;
8335
- }
8336
- }
8337
- const configDir = claudeConfigDir();
8338
- mkdirSync4(configDir, { recursive: true });
8339
- const env = {
8340
- ...process.env,
8341
- CLAUDE_CONFIG_DIR: configDir
8342
- };
8343
- delete env.ANTHROPIC_API_KEY;
8344
- console.error(`Signing claude in to ${configDir}. Evals run with this login, never your own ~/.claude.`);
8345
- const r = spawnSync4(claudeBin, ["auth", "login"], { env, stdio: "inherit" });
8346
- if (r.error) {
8347
- console.error(`firegoat login: could not run ${claudeBin}: ${r.error.message}`);
8348
- return EXIT.notGraded;
8349
- }
8350
- if (r.status !== 0) {
8351
- console.error(`firegoat login: claude auth login exited ${r.status}`);
8352
- return EXIT.notGraded;
8353
- }
8354
- console.log("logged in. next: npx firegoat eval");
8355
- return EXIT.pass;
8356
- }
8357
-
8358
8692
  // src/open.ts
8359
8693
  import { spawnSync as spawnSync5 } from "node:child_process";
8360
- import { existsSync as existsSync5, readdirSync } from "node:fs";
8361
- import { join as join7 } from "node:path";
8694
+ import { existsSync as existsSync6, readdirSync } from "node:fs";
8695
+ import { join as join8 } from "node:path";
8362
8696
  var OPENER = {
8363
8697
  darwin: "open",
8364
8698
  linux: "xdg-open"
@@ -8367,6 +8701,10 @@ function openCommand(args, cwd) {
8367
8701
  let print = false;
8368
8702
  const ids = [];
8369
8703
  for (const a of args) {
8704
+ if (a === "--help" || a === "-h") {
8705
+ console.log(OPEN_HELP);
8706
+ return EXIT.pass;
8707
+ }
8370
8708
  if (a === "--print")
8371
8709
  print = true;
8372
8710
  else if (a.startsWith("-")) {
@@ -8379,8 +8717,8 @@ function openCommand(args, cwd) {
8379
8717
  console.error("firegoat open: takes at most one run id");
8380
8718
  return EXIT.usage;
8381
8719
  }
8382
- const base = join7(findConfigDir(cwd), ".firegoat", "runs");
8383
- const runs = existsSync5(base) ? readdirSync(base).sort() : [];
8720
+ const base = join8(findConfigDir(cwd), ".firegoat", "runs");
8721
+ const runs = existsSync6(base) ? readdirSync(base).sort() : [];
8384
8722
  const id = ids[0] ?? runs.at(-1);
8385
8723
  if (!id) {
8386
8724
  console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
@@ -8390,7 +8728,7 @@ function openCommand(args, cwd) {
8390
8728
  console.error(`firegoat open: no run ${JSON.stringify(id)} in ${base} (latest: ${runs.at(-1) ?? "none"})`);
8391
8729
  return EXIT.usage;
8392
8730
  }
8393
- const dir = join7(base, id);
8731
+ const dir = join8(base, id);
8394
8732
  console.log(dir);
8395
8733
  const opener = OPENER[process.platform];
8396
8734
  if (print || !opener)
@@ -8404,32 +8742,6 @@ function openCommand(args, cwd) {
8404
8742
  }
8405
8743
 
8406
8744
  // src/cli.ts
8407
- var USAGE = `firegoat ${package_default.version}: local-first AX evals
8408
-
8409
- Usage:
8410
- firegoat init [--yes] [--force] [--skills] write firegoat.yaml with starter evals
8411
- firegoat login [--claude-bin PATH] sign claude in to firegoat's own config dir (once)
8412
- firegoat eval [test..] [--runs N] [--var KEY=value].. [--json] [--host] [--claude-bin PATH]
8413
- run evals with claude in a sandboxed fresh workdir
8414
- firegoat open [runId] [--print] open a run's folder (default: the latest)
8415
- firegoat --version
8416
-
8417
- eval options:
8418
- test.. only run these tests (by name)
8419
- --runs N runs per test (overrides runs: in firegoat.yaml)
8420
- --var KEY=value fills {{vars.KEY}} in prompts and checks; env for setup, workdir_setup and checks
8421
- --json JSON summary on stdout, progress on stderr
8422
- --host no sandbox (needed off macOS; use a throwaway container)
8423
- --claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
8424
-
8425
- firegoat.yaml is looked up in the current dir, then parents up to the git root.
8426
-
8427
- Exit codes:
8428
- 0 every run passed
8429
- 1 a goal or check failed
8430
- 2 at least one run was not graded (claude missing, auth, crash, timeout); wins over 1
8431
- 3 usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml)
8432
- `;
8433
8745
  async function main(argv) {
8434
8746
  const [cmd, ...rest] = argv;
8435
8747
  switch (cmd) {
@@ -8440,12 +8752,24 @@ async function main(argv) {
8440
8752
  case undefined:
8441
8753
  case "--help":
8442
8754
  case "-h":
8443
- console.log(USAGE);
8755
+ console.log(topHelp(package_default.version));
8444
8756
  return 0;
8445
8757
  case "init":
8446
8758
  return initCommand(rest, process.cwd());
8759
+ case "claude":
8760
+ if (rest[0] === "login")
8761
+ return claudeLoginCommand(rest.slice(1));
8762
+ if (rest[0] === "--help" || rest[0] === "-h") {
8763
+ console.log(CLAUDE_LOGIN_HELP);
8764
+ return 0;
8765
+ }
8766
+ console.error(`firegoat claude: unknown subcommand
8767
+
8768
+ ${CLAUDE_LOGIN_HELP}`);
8769
+ return EXIT.usage;
8447
8770
  case "login":
8448
- return loginCommand(rest);
8771
+ console.error("firegoat login is deprecated for the Claude sign-in: use `firegoat claude login`");
8772
+ return claudeLoginCommand(rest);
8449
8773
  case "eval":
8450
8774
  return evalCommand(rest, process.cwd());
8451
8775
  case "open":
@@ -8453,7 +8777,7 @@ async function main(argv) {
8453
8777
  default:
8454
8778
  console.error(`unknown command: ${cmd}
8455
8779
 
8456
- ${USAGE}`);
8780
+ ${topHelp(package_default.version)}`);
8457
8781
  return EXIT.usage;
8458
8782
  }
8459
8783
  }