firegoat 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -1
  2. package/dist/cli.js +64 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -156,7 +156,20 @@ tests:
156
156
  claude_args: [--chrome] # Claude in Chrome, this test only
157
157
  ```
158
158
 
159
- `claude_args:` (top level, and per test after the top-level ones) is passed to claude after firegoat's own flags and `--model`. Each entry is one argument, as-is, with no shell splitting: `[--plugin-dir, ./plugins/acme]`, not `["--plugin-dir ./plugins/acme"]`. Use it for `--chrome`, `--plugin-dir`, `--mcp-config`, `--agents`, `--append-system-prompt`. The flags firegoat sets itself are config errors, also as `--flag=value`: `-p`, `--print`, `--output-format`, `--verbose`, `--setting-sources`, `--strict-mcp-config`, `--disable-slash-commands`, `--dangerously-skip-permissions`, `--model` (use `model:`), and `--session-id`, `--resume`, `-r`, `--continue`, `-c` (every run is a fresh session). Each run's `run.json` records `claudeArgs`.
159
+ `claude_args:` (top level, and per test after the top-level ones) is passed to claude after firegoat's own flags and `--model`. Each entry is one argument, as-is, with no shell splitting: `[--plugin-dir, ./plugins/acme]`, not `["--plugin-dir ./plugins/acme"]`. Use it for `--chrome`, `--plugin-dir`, `--mcp-config`, `--agents`, `--append-system-prompt`. The flags firegoat sets itself are config errors, also as `--flag=value`: `-p`, `--print`, `--output-format`, `--verbose`, `--setting-sources`, `--strict-mcp-config`, `--disable-slash-commands`, `--dangerously-skip-permissions`, `--model` (use `model:`), and `--session-id`, `--resume`, `-r`, `--continue`, `-c` (every run is a fresh session). Entries expand like `env:` values: `$FIREGOAT_WORKDIR`, `$FIREGOAT_HOME`, `$FIREGOAT_OUT` (also `${...}`), `{{vars.KEY}}`, `{{run.id}}`, `{{run.n}}`, `{{test.name}}`; no other `$` expansion. Each run's `run.json` records the expanded `claudeArgs`.
160
+
161
+ ### Testing an MCP server
162
+
163
+ ```yaml
164
+ install: |
165
+ cat > .mcp.json <<EOF
166
+ {"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
167
+ "env": {"HOME": "$FIREGOAT_HOME"}}}}
168
+ EOF
169
+ claude_args: [--mcp-config, .mcp.json]
170
+ ```
171
+
172
+ Write the MCP config per run in `install:` (or copy a committed one to `$FIREGOAT_OUT` in `build:` and pass `"$FIREGOAT_OUT/mcp.json"`, but then it can't hold the run's home), and pass it with `claude_args: [--mcp-config, .mcp.json]`: relative paths are from the run's folder, claude's cwd. `--strict-mcp-config` is always on, so only the configs you pass load. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "<the run's $FIREGOAT_HOME>"}` on the server, as above, or it writes to your real home.
160
173
 
161
174
  With `--chrome`, claude points the Claude in Chrome extension's native host (`~/Library/Application Support/<browser>/NativeMessagingHosts/com.anthropic.claude_code_browser_extension.json`) at `.firegoat/.claude`. If you delete `.firegoat/`, run a normal `claude --chrome` once to point it back.
162
175
 
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.19.0",
6950
+ version: "0.20.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7211,6 +7211,7 @@ function parseClaudeArgs(raw, path) {
7211
7211
  for (const f of flags)
7212
7212
  if (arg === f || arg.startsWith(`${f}=`))
7213
7213
  fail(p, `${f} is not allowed: ${why}`);
7214
+ checkPlaceholders(arg, p);
7214
7215
  return arg;
7215
7216
  });
7216
7217
  }
@@ -7266,15 +7267,18 @@ function parseEnv(raw, path) {
7266
7267
  fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
7267
7268
  if (typeof v !== "string")
7268
7269
  fail(p, "must be a string (quote numbers and booleans)");
7269
- for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7270
- const name = m[1];
7271
- if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7272
- fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7273
- }
7270
+ checkPlaceholders(v, p);
7274
7271
  out[k] = v;
7275
7272
  }
7276
7273
  return out;
7277
7274
  }
7275
+ function checkPlaceholders(v, p) {
7276
+ for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7277
+ const name = m[1];
7278
+ if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7279
+ fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
7280
+ }
7281
+ }
7278
7282
  function isEvalLevel(value) {
7279
7283
  const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
7280
7284
  return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
@@ -7282,16 +7286,19 @@ function isEvalLevel(value) {
7282
7286
  return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
7283
7287
  });
7284
7288
  }
7289
+ function renderValue(value, vars, run, where) {
7290
+ return value.replace(ENV_REF, (whole, name, braced, bare) => {
7291
+ const key = name ?? braced ?? bare;
7292
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7293
+ if (filled === undefined)
7294
+ throw new ConfigError(`${where}: no value for ${whole}`);
7295
+ return filled;
7296
+ });
7297
+ }
7285
7298
  function renderEnv(env, vars, run) {
7286
7299
  const out = {};
7287
7300
  for (const [k, v] of Object.entries(env))
7288
- out[k] = v.replace(ENV_REF, (whole, name, braced, bare) => {
7289
- const key = name ?? braced ?? bare;
7290
- const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7291
- if (filled === undefined)
7292
- throw new ConfigError(`env.${k}: no value for ${whole}`);
7293
- return filled;
7294
- });
7301
+ out[k] = renderValue(v, vars, run, `env.${k}`);
7295
7302
  return out;
7296
7303
  }
7297
7304
  var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
@@ -7957,7 +7964,10 @@ firegoat.yaml reference:
7957
7964
  as-is (no shell splitting): [--chrome] (Claude in
7958
7965
  Chrome, for tests that need a browser),
7959
7966
  [--plugin-dir, ./plugins/x], --mcp-config, --agents,
7960
- --append-system-prompt. Flags firegoat sets are
7967
+ --append-system-prompt. Entries expand like env:
7968
+ values ($FIREGOAT_WORKDIR, $FIREGOAT_HOME,
7969
+ $FIREGOAT_OUT, {{vars.KEY}}, {{run.*}}); run.json
7970
+ claudeArgs has the result. Flags firegoat sets are
7961
7971
  errors, also as --flag=value: -p, --print,
7962
7972
  --output-format, --verbose, --setting-sources,
7963
7973
  --strict-mcp-config, --disable-slash-commands,
@@ -8000,6 +8010,18 @@ firegoat.yaml reference:
8000
8010
  every env firegoat starts, unless env: sets them. So is every FIREGOAT_* var
8001
8011
  (FIREGOAT_API_KEY above all) firegoat itself was started with.
8002
8012
 
8013
+ Testing an MCP server: write its config per run in install: and pass it with
8014
+ claude_args (relative paths are from the run's folder, claude's cwd). Only the
8015
+ configs you pass load: --strict-mcp-config is always on. An MCP server inherits
8016
+ claude's env, which keeps your real HOME (claude's login is there), so set the
8017
+ server's HOME to the run's home, or it writes to yours:
8018
+ install: |
8019
+ cat > .mcp.json <<EOF
8020
+ {"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
8021
+ "env": {"HOME": "$FIREGOAT_HOME"}}}}
8022
+ EOF
8023
+ claude_args: [--mcp-config, .mcp.json]
8024
+
8003
8025
  Example:
8004
8026
  build: npm pack --pack-destination "$FIREGOAT_OUT"
8005
8027
  install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
@@ -8930,12 +8952,20 @@ async function runOne(opts) {
8930
8952
  const workdir = join6(dir, "work");
8931
8953
  mkdirSync4(workdir, { recursive: true });
8932
8954
  const { home, envFile } = makeRunHome(dir);
8955
+ const fill = {
8956
+ "run.id": opts.runId,
8957
+ "run.n": String(n),
8958
+ "test.name": test.name,
8959
+ FIREGOAT_OUT: opts.out,
8960
+ FIREGOAT_WORKDIR: workdir,
8961
+ FIREGOAT_HOME: home
8962
+ };
8933
8963
  const r = {
8934
8964
  test: test.name,
8935
8965
  n,
8936
8966
  model: opts.model,
8937
8967
  claudeModel: null,
8938
- claudeArgs: test.claudeArgs,
8968
+ claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
8939
8969
  status: "error",
8940
8970
  reason: "",
8941
8971
  prompt: render(test.prompt, opts.vars),
@@ -8956,14 +8986,7 @@ async function runOne(opts) {
8956
8986
  startedAt: new Date(started).toISOString(),
8957
8987
  upload: null
8958
8988
  };
8959
- const env = renderEnv(test.env, opts.vars, {
8960
- "run.id": opts.runId,
8961
- "run.n": String(n),
8962
- "test.name": test.name,
8963
- FIREGOAT_OUT: opts.out,
8964
- FIREGOAT_WORKDIR: workdir,
8965
- FIREGOAT_HOME: home
8966
- });
8989
+ const env = renderEnv(test.env, opts.vars, fill);
8967
8990
  const stepEnv = {
8968
8991
  ...opts.stepEnv,
8969
8992
  ...env,
@@ -9064,6 +9087,20 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9064
9087
  test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
9065
9088
  ];
9066
9089
  }
9090
+ var MAX_LISTED_URLS = 5;
9091
+ function uploadedSummary(origin, results, runDir) {
9092
+ const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
9093
+ if (urls.length === 0)
9094
+ return;
9095
+ const head = `Uploaded ${urls.length} session${urls.length === 1 ? "" : "s"} to ${new URL(origin).host} (private)`;
9096
+ if (urls.length > MAX_LISTED_URLS) {
9097
+ console.log(`${head}: each run's upload.url in ${join6(runDir, "summary.json")}`);
9098
+ return;
9099
+ }
9100
+ console.log(`${head}:`);
9101
+ for (const u of urls)
9102
+ console.log(` ${u}`);
9103
+ }
9067
9104
  async function evalCommand(args, root) {
9068
9105
  if (args.includes("--help") || args.includes("-h")) {
9069
9106
  console.log(EVAL_HELP);
@@ -9116,7 +9153,8 @@ async function evalCommand(args, root) {
9116
9153
  ...new Set(tests.flatMap((t) => [
9117
9154
  t.prompt,
9118
9155
  ...t.checks.map((c) => c.run),
9119
- ...Object.values(t.env)
9156
+ ...Object.values(t.env),
9157
+ ...t.claudeArgs
9120
9158
  ]).flatMap(varRefs).filter((k) => !(k in vars)))
9121
9159
  ];
9122
9160
  if (missing.length > 0)
@@ -9287,6 +9325,8 @@ async function evalCommand(args, root) {
9287
9325
  rows,
9288
9326
  seconds: Math.round((Date.now() - evalStarted) / 1000)
9289
9327
  });
9328
+ if (upload && !opts.json)
9329
+ uploadedSummary(upload.origin, results, runDir);
9290
9330
  for (const r of results)
9291
9331
  for (const c of r.cleanup)
9292
9332
  if (c.code !== 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"