firegoat 0.13.0 → 0.14.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 +3 -1
  2. package/dist/cli.js +118 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -88,6 +88,7 @@ npx firegoat eval --var SHA=$(git rev-parse HEAD)
88
88
  env:
89
89
  ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # sessions/config never collide across runs
90
90
  ACME_TELEMETRY: "off"
91
+ UV_FIND_LINKS: "$FIREGOAT_OUT" # resolve the wheel setup built
91
92
  tests:
92
93
  - name: login
93
94
  prompt: Log in to acme with the CLI and print the current user.
@@ -98,12 +99,13 @@ tests:
98
99
  - run: ls "$ACME_HOME"/sessions | grep -q .
99
100
  ```
100
101
 
101
- `env:` goes to the agent, and to `workdir_setup` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs), `{{run.workdir}}`, `{{test.name}}`. `setup` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}` and `{{run.id}}`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set.
102
+ `env:` goes to the agent, and to `workdir_setup` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` (also `${...}`), the same dirs `setup`, `workdir_setup` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `setup` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set.
102
103
 
103
104
  ## What eval does
104
105
 
105
106
  - claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
106
107
  - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores `runs/` and `.claude/`; keep test fixtures (scripts, sample data) in `.firegoat/fixtures/` and commit them with `firegoat.yaml`.
108
+ - In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `setup:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost`) to tell runs from different branches apart.
107
109
  - The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, setup, workdir_setup, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it.
108
110
  - Runs (tests × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
109
111
  - Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
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.13.0",
6950
+ version: "0.14.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7160,9 +7160,11 @@ var RESERVED_ENV = new Set([
7160
7160
  "CLAUDE_CONFIG_DIR",
7161
7161
  "CLAUDE_SECURESTORAGE_CONFIG_DIR"
7162
7162
  ]);
7163
- var RUN_PLACEHOLDERS = ["run.id", "run.n", "run.workdir", "test.name"];
7163
+ var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
7164
7164
  var EVAL_PLACEHOLDERS = ["run.id"];
7165
7165
  var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
7166
+ var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
7167
+ var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
7166
7168
  function parseEnv(raw, path) {
7167
7169
  if (raw === undefined)
7168
7170
  return {};
@@ -7187,7 +7189,8 @@ function parseEnv(raw, path) {
7187
7189
  return out;
7188
7190
  }
7189
7191
  function isEvalLevel(value) {
7190
- return [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7192
+ const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
7193
+ return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
7191
7194
  const name = m[1];
7192
7195
  return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7193
7196
  });
@@ -7195,8 +7198,9 @@ function isEvalLevel(value) {
7195
7198
  function renderEnv(env, vars, run) {
7196
7199
  const out = {};
7197
7200
  for (const [k, v] of Object.entries(env))
7198
- out[k] = v.replace(ANY_PLACEHOLDER, (whole, name) => {
7199
- const filled = name.startsWith("vars.") ? vars[name.slice("vars.".length)] : run[name];
7201
+ out[k] = v.replace(ENV_REF, (whole, name, braced, bare) => {
7202
+ const key = name ?? braced ?? bare;
7203
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7200
7204
  if (filled === undefined)
7201
7205
  throw new ConfigError(`env.${k}: no value for ${whole}`);
7202
7206
  return filled;
@@ -7225,13 +7229,14 @@ function loadConfig(dir) {
7225
7229
  // src/eval.ts
7226
7230
  import { randomBytes } from "node:crypto";
7227
7231
  import {
7232
+ appendFileSync,
7228
7233
  createWriteStream as createWriteStream2,
7229
7234
  existsSync as existsSync2,
7230
7235
  mkdirSync as mkdirSync2,
7231
7236
  readdirSync as readdirSync2,
7232
7237
  writeFileSync as writeFileSync3
7233
7238
  } from "node:fs";
7234
- import { basename, delimiter as delimiter2, join as join4 } from "node:path";
7239
+ import { basename as basename2, delimiter as delimiter2, join as join4 } from "node:path";
7235
7240
 
7236
7241
  // src/claude.ts
7237
7242
  import { spawn, spawnSync } from "node:child_process";
@@ -7425,6 +7430,51 @@ function ensureFiregoatDir(root) {
7425
7430
  return dir;
7426
7431
  }
7427
7432
 
7433
+ // src/git.ts
7434
+ import { spawnSync as spawnSync2 } from "node:child_process";
7435
+ import { basename, dirname } from "node:path";
7436
+ function git(dir, args) {
7437
+ const r = spawnSync2("git", args, { cwd: dir, encoding: "utf8" });
7438
+ if (r.error)
7439
+ throw r.error;
7440
+ return { code: r.status ?? -1, stdout: r.stdout.trim() };
7441
+ }
7442
+ function gitRepo(dir) {
7443
+ const r = spawnSync2("git", ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"], { cwd: dir, encoding: "utf8" });
7444
+ if (r.error?.code === "ENOENT")
7445
+ return null;
7446
+ if (r.error)
7447
+ throw r.error;
7448
+ if (r.status !== 0) {
7449
+ if (/not a git repository/.test(r.stderr))
7450
+ return null;
7451
+ throw new Error(`git rev-parse in ${dir} failed: ${r.stderr.trim()}`);
7452
+ }
7453
+ const [gitDir, commonDir] = r.stdout.trim().split(`
7454
+ `);
7455
+ if (!gitDir || !commonDir)
7456
+ throw new Error(`git rev-parse in ${dir} printed ${r.stdout}`);
7457
+ let mainCheckout = null;
7458
+ if (gitDir !== commonDir) {
7459
+ if (basename(commonDir) !== ".git")
7460
+ throw new Error(`${dir} is a linked git worktree, but its common git dir ${commonDir} is not a checkout's .git (a bare repo?), so there is no main checkout to keep .firegoat/runs in`);
7461
+ mainCheckout = dirname(commonDir);
7462
+ }
7463
+ const branch = git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]);
7464
+ const commit = git(dir, ["rev-parse", "-q", "--verify", "HEAD"]);
7465
+ const status = git(dir, ["status", "--porcelain"]);
7466
+ if (status.code !== 0)
7467
+ throw new Error(`git status in ${dir} failed`);
7468
+ return {
7469
+ info: {
7470
+ branch: branch.code === 0 ? branch.stdout : null,
7471
+ commit: commit.code === 0 ? commit.stdout : null,
7472
+ dirty: status.stdout !== ""
7473
+ },
7474
+ mainCheckout
7475
+ };
7476
+ }
7477
+
7428
7478
  // src/help.ts
7429
7479
  var EXIT_CODES = `Exit codes:
7430
7480
  0 every run's script checks passed
@@ -7472,8 +7522,10 @@ firegoat.yaml reference:
7472
7522
  env: {NAME: value} env vars for the agent, and for workdir_setup and
7473
7523
  checks of the same run; values are strings and can
7474
7524
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
7475
- {{run.workdir}}, {{test.name}}. setup gets only the
7476
- entries that use nothing but {{vars.*}}/{{run.id}}.
7525
+ {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
7526
+ (no other $ expansion: $HOME stays literal).
7527
+ setup gets only the entries that use nothing but
7528
+ {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
7477
7529
  PATH, CLAUDE_CONFIG_DIR and FIREGOAT_* are reserved.
7478
7530
  tests:
7479
7531
  - name: quickstart letters, digits, '.', '_', '-'; unique
@@ -7526,6 +7578,10 @@ with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
7526
7578
  plugins, skills or MCP servers. claude saves its session there. There is no
7527
7579
  sandbox: the agent can reach the rest of your machine, including the repo.
7528
7580
 
7581
+ In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
7582
+ live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
7583
+ fixtures and setup: stay in the current folder.
7584
+
7529
7585
  test.. only these tests (by name)
7530
7586
  --runs N runs per test (overrides runs: in firegoat.yaml)
7531
7587
  --parallel N runs at once (default 4; 1 runs them one after another)
@@ -7535,7 +7591,10 @@ sandbox: the agent can reach the rest of your machine, including the repo.
7535
7591
  --claude-bin PATH claude binary (env FIREGOAT_CLAUDE_BIN; default: claude on PATH)
7536
7592
 
7537
7593
  Output: .firegoat/runs/<runId>/
7538
- summary.json every run, plus totals
7594
+ summary.json every run, plus totals, dir (where eval ran) and
7595
+ git {branch, commit, dirty} (no git outside a repo)
7596
+ ../index.jsonl one line per eval: runId, startedAt, dir, branch,
7597
+ commit, passed, failed, notGraded, cost
7539
7598
  <test>/<n>/run.json one run: status and reason (from the checks), prompt,
7540
7599
  end_goal, checks [{run, code}], and paths: workdir,
7541
7600
  transcript, session (claude's session file), stderr,
@@ -7692,10 +7751,14 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
7692
7751
  }
7693
7752
 
7694
7753
  // src/ui.ts
7695
- import { relative as relative2 } from "node:path";
7754
+ import { isAbsolute, relative as relative2 } from "node:path";
7696
7755
  var RAMP = "▁▂▃▄▅▆▇";
7697
7756
  var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7698
7757
  var SPIN = ["◐", "◓", "◑", "◒"];
7758
+ function shortPath(path) {
7759
+ const rel = relative2(process.cwd(), path);
7760
+ return rel.startsWith("..") || isAbsolute(rel) ? path : rel;
7761
+ }
7699
7762
  function painter(color) {
7700
7763
  return (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
7701
7764
  }
@@ -7891,7 +7954,7 @@ function ttyUI(out, opts) {
7891
7954
  const right = goated ? `GOATED · tier ${tr}` : tr ? `tier ${tr}` : "";
7892
7955
  out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
7893
7956
  out.push(p(C.gray, ` total $${t.cost.toFixed(2)} · ${t.turns} turns · ${duration(finished.seconds)}`));
7894
- out.push(p(C.gray, ` runs ${relative2(process.cwd(), finished.runsDir)}/`));
7957
+ out.push(p(C.gray, ` runs ${shortPath(finished.runsDir)}/`));
7895
7958
  out.push(` checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
7896
7959
  return out;
7897
7960
  };
@@ -8061,8 +8124,9 @@ async function runOne(opts) {
8061
8124
  const env = renderEnv(test.env, opts.vars, {
8062
8125
  "run.id": opts.runId,
8063
8126
  "run.n": String(n),
8064
- "run.workdir": workdir,
8065
- "test.name": test.name
8127
+ "test.name": test.name,
8128
+ FIREGOAT_OUT: opts.out,
8129
+ FIREGOAT_WORKDIR: workdir
8066
8130
  });
8067
8131
  const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8068
8132
  if (opts.workdirSetup.length > 0) {
@@ -8146,7 +8210,7 @@ async function evalCommand(args, root) {
8146
8210
  }
8147
8211
  const say = opts.json ? console.error : console.log;
8148
8212
  const stream = opts.json ? process.stderr : process.stdout;
8149
- const ui = stream.isTTY ? ttyUI(stream, { project: basename(root), color: !process.env.NO_COLOR }) : plainUI(say);
8213
+ const ui = stream.isTTY ? ttyUI(stream, { project: basename2(root), color: !process.env.NO_COLOR }) : plainUI(say);
8150
8214
  let config;
8151
8215
  try {
8152
8216
  config = loadConfig(root);
@@ -8177,7 +8241,13 @@ async function evalCommand(args, root) {
8177
8241
  const claudePath = resolveBin(opts.claudeBin);
8178
8242
  if (!claudePath)
8179
8243
  return fatal(EXIT.notGraded, `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.`);
8180
- const firegoatDir = ensureFiregoatDir(root);
8244
+ let repo;
8245
+ try {
8246
+ repo = gitRepo(root);
8247
+ } catch (e) {
8248
+ return fatal(EXIT.usage, e.message);
8249
+ }
8250
+ const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
8181
8251
  const configDir = join4(firegoatDir, ".claude");
8182
8252
  mkdirSync2(configDir, { recursive: true });
8183
8253
  ui.auth(process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login");
@@ -8190,7 +8260,8 @@ async function evalCommand(args, root) {
8190
8260
  return fatal(EXIT.notGraded, `not graded: ${loginProblem}`);
8191
8261
  const evalStarted = Date.now();
8192
8262
  const runId = newRunId();
8193
- const runDir = join4(firegoatDir, "runs", runId);
8263
+ const runsRoot = join4(firegoatDir, "runs");
8264
+ const runDir = join4(runsRoot, runId);
8194
8265
  const out = join4(runDir, "out");
8195
8266
  mkdirSync2(out, { recursive: true });
8196
8267
  const stepEnv = {
@@ -8214,7 +8285,7 @@ async function evalCommand(args, root) {
8214
8285
  cwd: root,
8215
8286
  env: {
8216
8287
  ...stepEnv,
8217
- ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
8288
+ ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
8218
8289
  },
8219
8290
  logPath: join4(runDir, "setup.txt")
8220
8291
  });
@@ -8252,6 +8323,7 @@ async function evalCommand(args, root) {
8252
8323
  claudeBin: claudePath,
8253
8324
  configDir,
8254
8325
  workdirSetup: config.workdirSetup,
8326
+ out,
8255
8327
  stepEnv,
8256
8328
  vars
8257
8329
  });
@@ -8276,8 +8348,27 @@ async function evalCommand(args, root) {
8276
8348
  const totals = { pass: 0, fail: 0, error: 0 };
8277
8349
  for (const r of results)
8278
8350
  totals[r.status]++;
8279
- const doc = { runId, runsDir: runDir, totals, runs: results };
8351
+ const doc = {
8352
+ runId,
8353
+ runsDir: runDir,
8354
+ dir: root,
8355
+ ...repo && { git: repo.info },
8356
+ totals,
8357
+ runs: results
8358
+ };
8280
8359
  writeFileSync3(join4(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
8360
+ `);
8361
+ appendFileSync(join4(runsRoot, "index.jsonl"), `${JSON.stringify({
8362
+ runId,
8363
+ startedAt: new Date(evalStarted).toISOString(),
8364
+ dir: root,
8365
+ branch: repo?.info.branch ?? null,
8366
+ commit: repo?.info.commit ?? null,
8367
+ passed: totals.pass,
8368
+ failed: totals.fail,
8369
+ notGraded: totals.error,
8370
+ cost: results.reduce((sum, r) => sum + (r.cost ?? 0), 0)
8371
+ })}
8281
8372
  `);
8282
8373
  ui.done({
8283
8374
  runId,
@@ -8466,9 +8557,9 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
8466
8557
  }
8467
8558
 
8468
8559
  // src/init.ts
8469
- import { spawnSync as spawnSync2 } from "node:child_process";
8560
+ import { spawnSync as spawnSync3 } from "node:child_process";
8470
8561
  import { existsSync as existsSync3, writeFileSync as writeFileSync4 } from "node:fs";
8471
- import { basename as basename2, join as join5 } from "node:path";
8562
+ import { basename as basename3, join as join5 } from "node:path";
8472
8563
  import { createInterface } from "node:readline/promises";
8473
8564
  var SKILLS_ARGV = [
8474
8565
  "skills",
@@ -8514,9 +8605,11 @@ runs: 1
8514
8605
 
8515
8606
  # Optional: env vars for the agent (and for workdir_setup and checks of the same
8516
8607
  # run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
8517
- # {{run.id}}, {{run.n}}, {{run.workdir}}, {{test.name}}. A test can add its own env:.
8608
+ # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
8609
+ # other $ expansion: $HOME stays literal). A test can add its own env:.
8518
8610
  # env:
8519
8611
  # MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
8612
+ # UV_FIND_LINKS: "$FIREGOAT_OUT"
8520
8613
 
8521
8614
  tests:
8522
8615
  - name: quickstart
@@ -8545,7 +8638,7 @@ var CLIPBOARD = [
8545
8638
  ];
8546
8639
  function copyToClipboard(text) {
8547
8640
  return CLIPBOARD.some(([cmd, args]) => {
8548
- const r = spawnSync2(cmd, args, { input: text });
8641
+ const r = spawnSync3(cmd, args, { input: text });
8549
8642
  return !r.error && r.status === 0;
8550
8643
  });
8551
8644
  }
@@ -8592,7 +8685,7 @@ ${INIT_HELP}`);
8592
8685
  const configPath = join5(cwd, CONFIG_FILE);
8593
8686
  const keep = existsSync3(configPath) && !force;
8594
8687
  if (!keep)
8595
- writeFileSync4(configPath, template(basename2(cwd)));
8688
+ writeFileSync4(configPath, template(basename3(cwd)));
8596
8689
  if (interactive) {
8597
8690
  step(1, CONFIG_FILE, keep ? "kept (--force replaces it)" : "created");
8598
8691
  console.log(paint("90", " │"));
@@ -8609,7 +8702,7 @@ ${INIT_HELP}`);
8609
8702
  console.log(`installing skills: npx ${SKILLS_ARGV.join(" ")}`);
8610
8703
  const env = { ...process.env };
8611
8704
  delete env.npm_config_package;
8612
- const r = spawnSync2("npx", ["-y", ...SKILLS_ARGV], {
8705
+ const r = spawnSync3("npx", ["-y", ...SKILLS_ARGV], {
8613
8706
  cwd,
8614
8707
  env,
8615
8708
  stdio: "pipe",
@@ -8624,7 +8717,7 @@ ${INIT_HELP}`);
8624
8717
  step(3, "firegoat-onboarding skill", "installed → .claude/skills/");
8625
8718
  else
8626
8719
  console.log("installed skills firegoat-onboarding, suggesting-eval-prompts in .claude/skills/");
8627
- const ignored = spawnSync2("git", ["check-ignore", "-q", ".claude/skills"], {
8720
+ const ignored = spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
8628
8721
  cwd
8629
8722
  });
8630
8723
  if (ignored.status === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"