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 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.1). See `plan.md`.
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 eval # runs each test with your local `claude` in a fresh empty folder
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.1.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 (!/^[A-Za-z0-9._-]+$/.test(name))
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
- return { agents, runs, tests };
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
- accessSync,
7143
- constants,
7144
- createWriteStream as createWriteStream2,
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
- statSync,
7150
- writeFileSync
7213
+ realpathSync as realpathSync2,
7214
+ writeFileSync as writeFileSync2
7151
7215
  } from "node:fs";
7152
7216
  import { tmpdir } from "node:os";
7153
- import { delimiter, join as join2, resolve } from "node:path";
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
- cmd: opts.bin,
7307
- args: claudeArgv(opts.prompt),
7521
+ ...wrap(opts.profile, opts.bin, claudeArgv(opts.prompt)),
7308
7522
  cwd: opts.cwd,
7309
- env: agentEnv(),
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 runCheck(opts) {
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 === "--runs") {
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 = join2(cwd, ".gitignore");
7459
- if (!existsSync2(file))
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 resolveBin(bin) {
7465
- const executable = (p) => {
7466
- try {
7467
- accessSync(p, constants.X_OK);
7468
- return statSync(p).isFile();
7469
- } catch {
7470
- return false;
7471
- }
7472
- };
7473
- if (bin.includes("/"))
7474
- return executable(bin) ? resolve(bin) : undefined;
7475
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
7476
- if (dir && executable(join2(dir, bin)))
7477
- return join2(dir, bin);
7478
- }
7479
- return;
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 = join2(opts.runDir, test.name, String(n));
7485
- mkdirSync(dir, { recursive: true });
7486
- const session = join2(dir, "session.jsonl");
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 = mkdtempSync(join2(tmpdir(), `firegoat-${test.name}-${n}-`));
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: join2(dir, "stderr.txt"),
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 = createWriteStream2(join2(dir, "checks.txt"));
7916
+ const log = createWriteStream3(join5(dir, "checks.txt"));
7526
7917
  const env = {
7527
7918
  ...process.env,
7528
- FIREGOAT_SESSION: resolve(session),
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 runCheck({
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
- mkdirSync(join2(root, ".firegoat"), { recursive: true });
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 = join2(root, ".firegoat", "runs", runId);
7590
- mkdirSync(runDir, { recursive: true });
7591
- const summaryPath = join2(runDir, "summary.json");
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 = { runId, runsDir: runDir, totals, runs: results };
7598
- writeFileSync(summaryPath, `${JSON.stringify(doc, null, 2)}
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({ test, n, runDir, claudeBin: claudePath, warn });
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: join2(runDir, test.name, String(n), "session.jsonl"),
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 existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
7638
- import { basename, join as join3 } from "node:path";
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) => existsSync3(join3(cwd, f)));
7678
- const fallback = { name: basename(cwd), library: true, readme };
7679
- const pkgText = readText(join3(cwd, "package.json"));
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(join3(cwd, "pyproject.toml"));
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(join3(cwd, "go.mod"));
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(join3(cwd, "Cargo.toml"));
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 = join3(cwd, ".gitignore");
8265
+ const file = join6(cwd, ".gitignore");
7813
8266
  const text = readText(file);
7814
8267
  if (text === undefined) {
7815
- writeFileSync2(file, `${GITIGNORE_LINE}
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
- writeFileSync2(file, `${text}${sep}${GITIGNORE_LINE}
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 = join3(cwd, CONFIG_FILE);
7880
- const keepConfig = existsSync3(configPath) && !force;
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
- writeFileSync2(configPath, renderYaml(project, tests));
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 = join3(cwd, ".claude", "skills", SKILL);
7900
- cpSync(skillSource(), dest, { recursive: true });
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 eval [test..] [--runs N] [--json] [--claude-bin PATH]
7915
- run evals with claude in an empty workdir
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/team2027/firegoat-cli.git"
@@ -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.