firegoat 0.11.0 → 0.12.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 +20 -1
- package/dist/cli.js +106 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,14 +82,33 @@ tests:
|
|
|
82
82
|
npx firegoat eval --var SHA=$(git rev-parse HEAD)
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
### Env vars for the agent: a home dir per run, a feature flag
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
env:
|
|
89
|
+
ACME_HOME: "/tmp/acme-{{run.id}}-{{run.n}}" # sessions/config never collide across runs
|
|
90
|
+
ACME_TELEMETRY: "off"
|
|
91
|
+
tests:
|
|
92
|
+
- name: login
|
|
93
|
+
prompt: Log in to acme with the CLI and print the current user.
|
|
94
|
+
end_goal: The agent completes acme's login flow and prints the user.
|
|
95
|
+
env:
|
|
96
|
+
ACME_BETA_LOGIN: "1" # this test only, on top of env:
|
|
97
|
+
checks:
|
|
98
|
+
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`env:` goes to the agent, and to `workdir_setup` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs), `{{run.workdir}}`, `{{test.name}}`. `setup` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}` and `{{run.id}}`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set.
|
|
102
|
+
|
|
85
103
|
## What eval does
|
|
86
104
|
|
|
87
105
|
- claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
|
|
88
106
|
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. In `.firegoat/`, git ignores `runs/` and `.claude/`; keep test fixtures (scripts, sample data) in `.firegoat/fixtures/` and commit them with `firegoat.yaml`.
|
|
107
|
+
- The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, setup, workdir_setup, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it.
|
|
89
108
|
- Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
|
|
90
109
|
- Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
|
|
91
110
|
|
|
92
|
-
`firegoat.yaml` keys: `runs`, `setup`, `workdir_setup`, `vars`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
111
|
+
`firegoat.yaml` keys: `runs`, `setup`, `workdir_setup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
93
112
|
|
|
94
113
|
Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See [`examples/local-npm-registry`](examples/local-npm-registry/README.md).
|
|
95
114
|
|
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.
|
|
6950
|
+
version: "0.12.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
|
|
@@ -7072,11 +7072,19 @@ function parseConfig(text) {
|
|
|
7072
7072
|
}
|
|
7073
7073
|
if (!isRecord(raw))
|
|
7074
7074
|
fail("(root)", "must be a mapping");
|
|
7075
|
-
onlyKeys(raw, "(root)", [
|
|
7075
|
+
onlyKeys(raw, "(root)", [
|
|
7076
|
+
"runs",
|
|
7077
|
+
"setup",
|
|
7078
|
+
"workdir_setup",
|
|
7079
|
+
"vars",
|
|
7080
|
+
"env",
|
|
7081
|
+
"tests"
|
|
7082
|
+
]);
|
|
7076
7083
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7077
7084
|
const setup = commands(raw.setup, "setup");
|
|
7078
7085
|
const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
|
|
7079
7086
|
const vars = parseVars(raw.vars);
|
|
7087
|
+
const env = parseEnv(raw.env, "env");
|
|
7080
7088
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
7081
7089
|
fail("tests", "must be a non-empty list");
|
|
7082
7090
|
const names = new Set;
|
|
@@ -7084,7 +7092,7 @@ function parseConfig(text) {
|
|
|
7084
7092
|
const p = `tests[${i}]`;
|
|
7085
7093
|
if (!isRecord(t))
|
|
7086
7094
|
fail(p, "must be a mapping");
|
|
7087
|
-
onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout"]);
|
|
7095
|
+
onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout", "env"]);
|
|
7088
7096
|
const name = str(t.name, `${p}.name`);
|
|
7089
7097
|
if (!NAME.test(name))
|
|
7090
7098
|
fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
|
|
@@ -7107,7 +7115,8 @@ function parseConfig(text) {
|
|
|
7107
7115
|
prompt: str(t.prompt, `${p}.prompt`),
|
|
7108
7116
|
end_goal: str(t.end_goal, `${p}.end_goal`),
|
|
7109
7117
|
checks,
|
|
7110
|
-
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
|
|
7118
|
+
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
|
|
7119
|
+
env: { ...env, ...parseEnv(t.env, `${p}.env`) }
|
|
7111
7120
|
};
|
|
7112
7121
|
});
|
|
7113
7122
|
return {
|
|
@@ -7115,6 +7124,7 @@ function parseConfig(text) {
|
|
|
7115
7124
|
setup,
|
|
7116
7125
|
workdirSetup,
|
|
7117
7126
|
vars,
|
|
7127
|
+
env,
|
|
7118
7128
|
tests
|
|
7119
7129
|
};
|
|
7120
7130
|
}
|
|
@@ -7144,6 +7154,54 @@ function parseVars(raw) {
|
|
|
7144
7154
|
}
|
|
7145
7155
|
return out;
|
|
7146
7156
|
}
|
|
7157
|
+
var RESERVED_ENV = new Set([
|
|
7158
|
+
"PATH",
|
|
7159
|
+
"CLAUDE_CONFIG_DIR",
|
|
7160
|
+
"CLAUDE_SECURESTORAGE_CONFIG_DIR"
|
|
7161
|
+
]);
|
|
7162
|
+
var RUN_PLACEHOLDERS = ["run.id", "run.n", "run.workdir", "test.name"];
|
|
7163
|
+
var EVAL_PLACEHOLDERS = ["run.id"];
|
|
7164
|
+
var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
|
7165
|
+
function parseEnv(raw, path) {
|
|
7166
|
+
if (raw === undefined)
|
|
7167
|
+
return {};
|
|
7168
|
+
if (!isRecord(raw))
|
|
7169
|
+
fail(path, "must be a mapping of NAME to value");
|
|
7170
|
+
const out = {};
|
|
7171
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
7172
|
+
const p = `${path}.${k}`;
|
|
7173
|
+
if (!VAR_KEY.test(k))
|
|
7174
|
+
fail(p, "name must be letters, digits and _, not starting with a digit");
|
|
7175
|
+
if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
|
|
7176
|
+
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7177
|
+
if (typeof v !== "string")
|
|
7178
|
+
fail(p, "must be a string (quote numbers and booleans)");
|
|
7179
|
+
for (const m of v.matchAll(ANY_PLACEHOLDER)) {
|
|
7180
|
+
const name = m[1];
|
|
7181
|
+
if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
|
|
7182
|
+
fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
|
|
7183
|
+
}
|
|
7184
|
+
out[k] = v;
|
|
7185
|
+
}
|
|
7186
|
+
return out;
|
|
7187
|
+
}
|
|
7188
|
+
function isEvalLevel(value) {
|
|
7189
|
+
return [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
|
|
7190
|
+
const name = m[1];
|
|
7191
|
+
return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
|
|
7192
|
+
});
|
|
7193
|
+
}
|
|
7194
|
+
function renderEnv(env, vars, run) {
|
|
7195
|
+
const out = {};
|
|
7196
|
+
for (const [k, v] of Object.entries(env))
|
|
7197
|
+
out[k] = v.replace(ANY_PLACEHOLDER, (whole, name) => {
|
|
7198
|
+
const filled = name.startsWith("vars.") ? vars[name.slice("vars.".length)] : run[name];
|
|
7199
|
+
if (filled === undefined)
|
|
7200
|
+
throw new ConfigError(`env.${k}: no value for ${whole}`);
|
|
7201
|
+
return filled;
|
|
7202
|
+
});
|
|
7203
|
+
return out;
|
|
7204
|
+
}
|
|
7147
7205
|
var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
|
|
7148
7206
|
function varRefs(text) {
|
|
7149
7207
|
return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
|
|
@@ -7186,9 +7244,14 @@ var CLAUDE_FLAGS = [
|
|
|
7186
7244
|
"",
|
|
7187
7245
|
"--disable-slash-commands"
|
|
7188
7246
|
];
|
|
7189
|
-
|
|
7247
|
+
var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
|
|
7248
|
+
function hostEnv() {
|
|
7249
|
+
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k)));
|
|
7250
|
+
}
|
|
7251
|
+
function claudeEnv(configDir, env = {}) {
|
|
7190
7252
|
return {
|
|
7191
|
-
...
|
|
7253
|
+
...hostEnv(),
|
|
7254
|
+
...env,
|
|
7192
7255
|
CLAUDE_CONFIG_DIR: configDir,
|
|
7193
7256
|
CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
|
|
7194
7257
|
};
|
|
@@ -7404,6 +7467,12 @@ firegoat.yaml reference:
|
|
|
7404
7467
|
workdir_setup: <sh> | [...] in each fresh folder before the agent starts
|
|
7405
7468
|
vars: {KEY: value} defaults for {{vars.KEY}} in prompts and checks;
|
|
7406
7469
|
--var KEY=value overrides
|
|
7470
|
+
env: {NAME: value} env vars for the agent, and for workdir_setup and
|
|
7471
|
+
checks of the same run; values are strings and can
|
|
7472
|
+
use {{vars.KEY}}, {{run.id}}, {{run.n}},
|
|
7473
|
+
{{run.workdir}}, {{test.name}}. setup gets only the
|
|
7474
|
+
entries that use nothing but {{vars.*}}/{{run.id}}.
|
|
7475
|
+
PATH, CLAUDE_CONFIG_DIR and FIREGOAT_* are reserved.
|
|
7407
7476
|
tests:
|
|
7408
7477
|
- name: quickstart letters, digits, '.', '_', '-'; unique
|
|
7409
7478
|
prompt: ... what a user would ask the agent; one concrete outcome
|
|
@@ -7412,6 +7481,7 @@ firegoat.yaml reference:
|
|
|
7412
7481
|
checks: optional shell commands, run in the agent's folder
|
|
7413
7482
|
- run: ... afterwards; exit 0 = pass; they set eval's exit code
|
|
7414
7483
|
timeout: 600 optional, seconds
|
|
7484
|
+
env: {NAME: value} optional, on top of the top-level env:
|
|
7415
7485
|
|
|
7416
7486
|
Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
|
|
7417
7487
|
.firegoat/fixtures/ and copy them in setup or workdir_setup. setup, workdir_setup and checks get the vars as env vars,
|
|
@@ -7420,6 +7490,11 @@ firegoat.yaml reference:
|
|
|
7420
7490
|
each output), and
|
|
7421
7491
|
$FIREGOAT_SESSION (claude's full session file, JSON lines).
|
|
7422
7492
|
|
|
7493
|
+
The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
|
|
7494
|
+
(npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
|
|
7495
|
+
npm_command, INIT_CWD; e.g. npm_config_yes=true from npx -y) are removed from
|
|
7496
|
+
every env firegoat starts, unless env: sets them.
|
|
7497
|
+
|
|
7423
7498
|
Example:
|
|
7424
7499
|
setup: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
7425
7500
|
workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
@@ -7942,7 +8017,13 @@ async function runOne(opts) {
|
|
|
7942
8017
|
`);
|
|
7943
8018
|
return r;
|
|
7944
8019
|
};
|
|
7945
|
-
const
|
|
8020
|
+
const env = renderEnv(test.env, opts.vars, {
|
|
8021
|
+
"run.id": opts.runId,
|
|
8022
|
+
"run.n": String(n),
|
|
8023
|
+
"run.workdir": workdir,
|
|
8024
|
+
"test.name": test.name
|
|
8025
|
+
});
|
|
8026
|
+
const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
|
|
7946
8027
|
if (opts.workdirSetup.length > 0) {
|
|
7947
8028
|
const error = await runSteps({
|
|
7948
8029
|
what: "workdir_setup",
|
|
@@ -7959,7 +8040,7 @@ async function runOne(opts) {
|
|
|
7959
8040
|
bin: opts.claudeBin,
|
|
7960
8041
|
prompt: r.prompt,
|
|
7961
8042
|
cwd: workdir,
|
|
7962
|
-
env: { ...claudeEnv(opts.configDir), PATH: opts.stepEnv.PATH },
|
|
8043
|
+
env: { ...claudeEnv(opts.configDir, env), PATH: opts.stepEnv.PATH },
|
|
7963
8044
|
stderr,
|
|
7964
8045
|
timeoutMs: test.timeout * 1000,
|
|
7965
8046
|
onEvent: opts.onEvent
|
|
@@ -8044,7 +8125,11 @@ async function evalCommand(args, root) {
|
|
|
8044
8125
|
const runs = opts.runs ?? config.runs;
|
|
8045
8126
|
const vars = { ...config.vars, ...opts.vars };
|
|
8046
8127
|
const missing = [
|
|
8047
|
-
...new Set(tests.flatMap((t) => [
|
|
8128
|
+
...new Set(tests.flatMap((t) => [
|
|
8129
|
+
t.prompt,
|
|
8130
|
+
...t.checks.map((c) => c.run),
|
|
8131
|
+
...Object.values(t.env)
|
|
8132
|
+
]).flatMap(varRefs).filter((k) => !(k in vars)))
|
|
8048
8133
|
];
|
|
8049
8134
|
if (missing.length > 0)
|
|
8050
8135
|
return fatal(EXIT.usage, `no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
|
|
@@ -8067,7 +8152,7 @@ async function evalCommand(args, root) {
|
|
|
8067
8152
|
const out = join4(runDir, "out");
|
|
8068
8153
|
mkdirSync2(out, { recursive: true });
|
|
8069
8154
|
const stepEnv = {
|
|
8070
|
-
...
|
|
8155
|
+
...hostEnv(),
|
|
8071
8156
|
...vars,
|
|
8072
8157
|
FIREGOAT_OUT: out,
|
|
8073
8158
|
PATH: [out, process.env.PATH ?? ""].join(delimiter2)
|
|
@@ -8085,7 +8170,10 @@ async function evalCommand(args, root) {
|
|
|
8085
8170
|
what: "setup",
|
|
8086
8171
|
commands: config.setup,
|
|
8087
8172
|
cwd: root,
|
|
8088
|
-
env:
|
|
8173
|
+
env: {
|
|
8174
|
+
...stepEnv,
|
|
8175
|
+
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId })
|
|
8176
|
+
},
|
|
8089
8177
|
logPath: join4(runDir, "setup.txt")
|
|
8090
8178
|
});
|
|
8091
8179
|
const secs = Math.round((Date.now() - setupStarted) / 1000);
|
|
@@ -8117,6 +8205,7 @@ async function evalCommand(args, root) {
|
|
|
8117
8205
|
},
|
|
8118
8206
|
test,
|
|
8119
8207
|
n,
|
|
8208
|
+
runId,
|
|
8120
8209
|
runDir,
|
|
8121
8210
|
claudeBin: claudePath,
|
|
8122
8211
|
configDir,
|
|
@@ -8195,6 +8284,12 @@ runs: 1
|
|
|
8195
8284
|
# setup runs in this folder, so: setup: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
|
|
8196
8285
|
# then e.g. workdir_setup: cp "$FIREGOAT_OUT/wizard.mjs" .
|
|
8197
8286
|
|
|
8287
|
+
# Optional: env vars for the agent (and for workdir_setup and checks of the same
|
|
8288
|
+
# run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
|
|
8289
|
+
# {{run.id}}, {{run.n}}, {{run.workdir}}, {{test.name}}. A test can add its own env:.
|
|
8290
|
+
# env:
|
|
8291
|
+
# MYTOOL_HOME: "/tmp/mytool-{{run.id}}-{{run.n}}"
|
|
8292
|
+
|
|
8198
8293
|
tests:
|
|
8199
8294
|
- name: quickstart
|
|
8200
8295
|
# What a user would ask their agent. Edit this.
|