firegoat 0.4.0 → 0.6.0

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