blastproof 0.2.0 → 0.2.2

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
@@ -25,21 +25,26 @@ git diff → impact mapping → test generation → agentic execution → report
25
25
  ```bash
26
26
  npm install -g blastproof # requires Node.js >= 20.19
27
27
  npx playwright install --with-deps chromium # one-time browser download
28
+
28
29
  cd your-project
29
- blastproof init
30
+ blastproof init # scaffolds .blastproof/
31
+ # start your app, then point base_url at it in .blastproof/config.yaml
30
32
  export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, or a local Ollama model
31
33
  blastproof run # runs .blastproof/tests/**/*.yaml agentically
32
34
  ```
33
35
 
36
+ `init` scaffolds one smoke test that works against any app, plus a commented login template you rename and edit once it describes *your* login. Nothing is written that assumes anything about your application.
37
+
34
38
  Published with [npm provenance](https://docs.npmjs.com/generating-provenance-statements), so the tarball is verifiably built from this repository.
35
39
 
36
- Try it locally without your own app — this repo ships a demo shop:
40
+ Try it without your own app — the demo shop lives in this repository, so clone it:
37
41
 
38
42
  ```bash
39
- node examples/demo-app/serve.mjs 4173 & # home, login and cart + promo-code pages
40
- blastproof init
43
+ git clone https://github.com/hamc/blastproof && cd blastproof
44
+ npm install && npm run build
45
+ node examples/demo-app/serve.mjs 4173 & # home, login, cart, checkout, orders
41
46
  export ANTHROPIC_API_KEY=...
42
- blastproof run
47
+ node dist/cli.js run
43
48
  ```
44
49
 
45
50
  > **Status:** the pipeline is complete and published. `init`, `run` (including `--impacted`), `plan` and `test`, with authentication, JUnit and HTML reports, a merge gate, and a GitHub Action. Pre-1.0: the command surface may still change.
@@ -116,7 +121,7 @@ Drafts are **previews by default** — nothing touches disk until `--write`, and
116
121
 
117
122
  Exit codes: 0 when every route generated (or nothing needed coverage), 1 when a route failed, 2 on usage/config/diff errors. A route that fails to load never aborts the others.
118
123
 
119
- **Known limitation:** a route behind authentication snapshots as the login wall, so its draft describes logging in rather than the feature. The `auth` config recipe is not applied by the planner yet — generate those routes after an auth session lands, or write them by hand.
124
+ `plan` uses the same `auth:` recipe as `run`, so a route behind a login is drafted from the real page rather than from the login wall.
120
125
 
121
126
  ### Closing the coverage hole
122
127
 
@@ -205,7 +210,7 @@ jobs:
205
210
 
206
211
  - run: npm start & # however your app boots
207
212
 
208
- - uses: hamc/blastproof@v0.2.0
213
+ - uses: hamc/blastproof@v0.2.2
209
214
  with:
210
215
  api-key: ${{ secrets.ANTHROPIC_API_KEY }}
211
216
  base: ${{ github.event.pull_request.base.ref }}
@@ -217,8 +222,9 @@ Exit non-zero blocks the merge. Use the score in a later step:
217
222
 
218
223
  ```yaml
219
224
  - id: bp
220
- uses: hamc/blastproof@v0.2.0
225
+ uses: hamc/blastproof@v0.2.2
221
226
  with:
227
+ version: '0.2.2' # pin both when the result gates merges
222
228
  api-key: ${{ secrets.ANTHROPIC_API_KEY }}
223
229
  base: ${{ github.event.pull_request.base.ref }}
224
230
  min-score: '80'
@@ -267,7 +273,9 @@ allowed_origins:
267
273
 
268
274
  This is enforced by comparison, not by asking the model nicely, so it holds regardless of what the page says.
269
275
 
270
- **Your secrets never reach the model.** `{{env.*}}` placeholders stay intact all the way through the prompt and are substituted at the moment of typing. The model is told to pass them through unchanged. This matters because blastproof encourages pointing `llm.base_url` at a gateway you do not run — the credential now stays on your machine either way.
276
+ **Your secrets stay out of the model's prompts.** `{{env.*}}` placeholders stay intact all the way through the prompt and are substituted at the moment of typing. Every value any test or the auth recipe references is also redacted from anything else crossing into a prompt — page snapshots included, since your app may render the credential itself — in both literal and percent-encoded form. This matters because blastproof encourages pointing `llm.base_url` at a gateway you do not run.
277
+
278
+ Redaction matches known values, so it cannot anticipate every way a page might transform one before rendering it. Treat it as a strong default, not a guarantee against a hostile application.
271
279
 
272
280
  The system prompt also tells the model that page content is data under test and never an instruction to obey. That raises the cost of a casual injection and is **not** a security boundary — a determined one will get past prompt wording. The origin constraint is the boundary; treat the rest as hygiene, and do not point blastproof at an application you would not run locally.
273
281
 
@@ -356,6 +364,12 @@ Note the last one names *which variable* holds your key — the key itself is ne
356
364
 
357
365
  ## Development
358
366
 
367
+ Built with AI assistance, using spec-driven development throughout: every change began as a written
368
+ proposal with its design rationale, and those documents are kept rather than discarded — `openspec/`
369
+ holds the reasoning behind each decision, including the alternatives that were rejected and why. The
370
+ `.claude/`, `.cursor/` and `.opencode/` directories are agent configuration for the tools used to
371
+ build it; ignore them unless you are contributing with one.
372
+
359
373
  This project uses **spec-driven development** via [OpenSpec](https://github.com/Fission-AI/OpenSpec). See [`AGENTS.md`](./AGENTS.md) for architecture, conventions and the contribution workflow — every change starts with an OpenSpec proposal.
360
374
 
361
375
  ```bash
package/dist/cli.js CHANGED
@@ -93,21 +93,25 @@ sessions/
93
93
  .auth-state.json
94
94
  auth.json
95
95
  `;
96
- var SAMPLE_LOGIN_TEST = `summary: Login with valid credentials succeeds
96
+ var SAMPLE_LOGIN_TEMPLATE = `# TEMPLATE \u2014 rename to login.yaml once these steps match your app.
97
+ # Until then it is ignored: only .yaml/.yml files are discovered.
98
+ #
99
+ # Credentials come from the environment. Export them before running:
100
+ # export TEST_EMAIL=... TEST_PASSWORD=...
101
+ # Substituted values are masked as *** everywhere and never reach the model.
102
+ summary: Login with valid credentials succeeds
97
103
  priority: P0
98
104
  tags: [smoke, auth]
99
105
  routes: ["/login"]
100
- # This test exercises the login itself, so it must start signed out even when an
101
- # auth recipe is configured.
106
+ # Exercises the login itself, so it must start signed out even when an auth
107
+ # recipe is configured.
102
108
  auth: false
103
109
  steps:
104
110
  - navigate to /login
105
- - fill the email field with demo@blastproof.dev
106
- - fill the password field with demo123
111
+ - fill the email field with {{env.TEST_EMAIL}}
112
+ - fill the password field with {{env.TEST_PASSWORD}}
107
113
  - submit the login form
108
114
  - verify a welcome message is shown
109
- # Secrets tip: use {{env.VAR_NAME}} placeholders in steps (e.g. {{env.TEST_PASSWORD}})
110
- # and export the variable before running. Values are masked as *** in all output.
111
115
  `;
112
116
  async function exists(file) {
113
117
  try {
@@ -132,7 +136,7 @@ async function initProject(cwd = process.cwd()) {
132
136
  await writeIfAbsent(path.join(root, ".gitignore"), GITIGNORE, result);
133
137
  await writeIfAbsent(path.join(root, "config.yaml"), DEFAULT_CONFIG, result);
134
138
  await writeIfAbsent(path.join(root, "tests", "app-load.yaml"), SAMPLE_TEST, result);
135
- await writeIfAbsent(path.join(root, "tests", "login.yaml"), SAMPLE_LOGIN_TEST, result);
139
+ await writeIfAbsent(path.join(root, "tests", "login.yaml.example"), SAMPLE_LOGIN_TEMPLATE, result);
136
140
  return result;
137
141
  }
138
142
  function formatInitGuidance(result) {
@@ -149,7 +153,7 @@ function formatInitGuidance(result) {
149
153
  "",
150
154
  "Next steps:",
151
155
  " 1. Set your LLM API key: export ANTHROPIC_API_KEY=... (or OPENAI_API_KEY; ollama needs no key)",
152
- " 2. Point base_url at your app in .blastproof/config.yaml",
156
+ " 2. Start your app, then point base_url at it in .blastproof/config.yaml",
153
157
  " 3. Run your tests: blastproof run",
154
158
  "",
155
159
  "First time with Playwright? Install the browser: npx playwright install chromium"
@@ -211,9 +215,21 @@ var SecretsMask = class {
211
215
  if (value === void 0) {
212
216
  throw new MissingEnvError(name);
213
217
  }
214
- this.secrets.add(value);
218
+ this.add(value);
215
219
  }
216
220
  }
221
+ /**
222
+ * Registers a value and the forms it takes on the way to a prompt. `navigate`
223
+ * reports a resolved URL, and `new URL()` percent-encodes — so a secret with a
224
+ * space stopped matching a literal search and passed through unmasked.
225
+ * This cannot cover every transform a page might apply; see the README.
226
+ */
227
+ add(value) {
228
+ if (!value) return;
229
+ this.secrets.add(value);
230
+ const encoded = encodeURIComponent(value);
231
+ if (encoded !== value) this.secrets.add(encoded);
232
+ }
217
233
  mask(text) {
218
234
  return maskSecrets(text, this.secrets);
219
235
  }
@@ -392,8 +408,8 @@ async function executeTest(page, test, options) {
392
408
  action = await brain.nextAction({
393
409
  step,
394
410
  isSetup: setup,
395
- snapshot: snap,
396
- lastResult,
411
+ snapshot: mask(snap),
412
+ lastResult: lastResult === void 0 ? void 0 : mask(lastResult),
397
413
  retriesLeft: maxRetries - failedAttempts,
398
414
  iterationsLeft: maxIterationsPerStep - iterations
399
415
  });
@@ -416,7 +432,7 @@ async function executeTest(page, test, options) {
416
432
  }
417
433
  if (action.action === "assert") {
418
434
  const expectation = action.expectation ?? action.reasoning;
419
- const judgment = await brain.judge(expectation, snap);
435
+ const judgment = await brain.judge(mask(expectation), mask(snap));
420
436
  const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
421
437
  emitAction(index, action, result);
422
438
  if (judgment.pass) {
@@ -521,8 +537,7 @@ async function fromStorageState(file) {
521
537
  }
522
538
  return { storageState: parsed };
523
539
  }
524
- function fromStatic(auth) {
525
- const mask = new SecretsMask();
540
+ function fromStatic(auth, mask) {
526
541
  const session = {};
527
542
  if (auth.headers) {
528
543
  const headers = {};
@@ -539,7 +554,7 @@ function fromStatic(auth) {
539
554
  });
540
555
  session.storageState = { cookies, origins: [] };
541
556
  }
542
- return { session, mask };
557
+ return session;
543
558
  }
544
559
  async function runJourney(...args) {
545
560
  try {
@@ -551,10 +566,8 @@ async function runJourney(...args) {
551
566
  }
552
567
  }
553
568
  async function fromSteps(options) {
554
- const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent } = options;
569
+ const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent, mask } = options;
555
570
  const steps = auth.steps;
556
- const mask = new SecretsMask();
557
- for (const step of steps) mask.registerFrom(step);
558
571
  const context = await browser.newContext();
559
572
  try {
560
573
  const page = await context.newPage();
@@ -587,7 +600,7 @@ async function fromSteps(options) {
587
600
  );
588
601
  }
589
602
  if (auth.verify) {
590
- const judgment = await brain.judge(auth.verify, await snapshot(page));
603
+ const judgment = await brain.judge(auth.verify, mask.mask(await snapshot(page)));
591
604
  if (!judgment.pass) {
592
605
  throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
593
606
  }
@@ -620,7 +633,7 @@ async function authenticate(options) {
620
633
  return fromStorageState(path3.resolve(cwd, auth.storage_state));
621
634
  }
622
635
  if (auth.headers || auth.cookies) {
623
- return fromStatic(auth).session;
636
+ return fromStatic(auth, options.mask);
624
637
  }
625
638
  const session = await fromSteps(options);
626
639
  if (auth.cache) await writeCached(cwd, session);
@@ -1164,7 +1177,7 @@ function renderTestYaml(draft, meta) {
1164
1177
  });
1165
1178
  }
1166
1179
  async function generateForRoute(page, options) {
1167
- const { route, baseUrl, changedFiles, brain, snapshot = defaultSnapshot } = options;
1180
+ const { route, baseUrl, changedFiles, brain, mask, snapshot = defaultSnapshot } = options;
1168
1181
  const url = new URL(route, baseUrl).toString();
1169
1182
  try {
1170
1183
  await page.goto(url, { timeout: 3e4 });
@@ -1173,7 +1186,11 @@ async function generateForRoute(page, options) {
1173
1186
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
1174
1187
  );
1175
1188
  }
1176
- const generated = await brain.planTest({ route, snapshot: await snapshot(page), changedFiles });
1189
+ const generated = await brain.planTest({
1190
+ route,
1191
+ snapshot: mask(await snapshot(page)),
1192
+ changedFiles
1193
+ });
1177
1194
  const leaked = findSecretLiterals(generated.steps);
1178
1195
  if (leaked.length > 0) {
1179
1196
  throw new PlannerError(
@@ -1489,12 +1506,21 @@ function applyUrlOverride(config, url) {
1489
1506
  }
1490
1507
  return { ...config, base_url: url };
1491
1508
  }
1492
- function resolveSecretsAndSteps(test) {
1509
+ function buildRunMask(config, tests) {
1493
1510
  const mask = new SecretsMask();
1511
+ for (const step of config.auth?.steps ?? []) mask.registerFrom(step);
1512
+ for (const value of Object.values(config.auth?.headers ?? {})) mask.registerFrom(value);
1513
+ for (const cookie of config.auth?.cookies ?? []) mask.registerFrom(cookie.value);
1514
+ for (const test of tests) {
1515
+ for (const step of [...test.setup ?? [], ...test.steps]) mask.registerFrom(step);
1516
+ }
1517
+ return mask;
1518
+ }
1519
+ function resolveSecretsAndSteps(test) {
1494
1520
  for (const step of [...test.setup ?? [], ...test.steps]) {
1495
- mask.registerFrom(step);
1521
+ new SecretsMask().registerFrom(step);
1496
1522
  }
1497
- return { test, mask };
1523
+ return { test };
1498
1524
  }
1499
1525
  function printEvent(event) {
1500
1526
  switch (event.type) {
@@ -1542,12 +1568,12 @@ X ${r.summary} (${r.file})`);
1542
1568
  if (r.screenshot) console.log(` screenshot: ${r.screenshot}`);
1543
1569
  }
1544
1570
  }
1545
- async function runOne(browser, test, config, sessionDir, session) {
1571
+ async function runOne(browser, test, config, sessionDir, session, runMask) {
1546
1572
  const brain = createBrain(createModel(config.llm).model);
1547
1573
  let resolved;
1548
- let mask;
1574
+ const mask = runMask;
1549
1575
  try {
1550
- ({ test: resolved, mask } = resolveSecretsAndSteps(test));
1576
+ ({ test: resolved } = resolveSecretsAndSteps(test));
1551
1577
  } catch (error) {
1552
1578
  if (error instanceof MissingEnvError) {
1553
1579
  return {
@@ -1627,22 +1653,24 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
1627
1653
  await writeHtml(target, html);
1628
1654
  console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
1629
1655
  }
1630
- const unclassified = options.failOnUnmapped ? impact?.unmappedFiles ?? [] : [];
1631
- if (unclassified.length > 0) {
1632
- console.error(
1633
- `error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
1634
- );
1635
- for (const file of unclassified) console.error(` ${file}`);
1636
- console.error(
1637
- "Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
1638
- );
1639
- return EXIT_FAILED;
1640
- }
1656
+ if (reportUnclassified(options, impact)) return EXIT_FAILED;
1641
1657
  if (options.minScore !== void 0) {
1642
1658
  return score >= options.minScore ? EXIT_OK : EXIT_FAILED;
1643
1659
  }
1644
1660
  return results.some((result) => result.status === "failed") ? EXIT_FAILED : EXIT_OK;
1645
1661
  }
1662
+ function reportUnclassified(options, impact) {
1663
+ const unclassified = options.failOnUnmapped ? impact?.unmappedFiles ?? [] : [];
1664
+ if (unclassified.length === 0) return false;
1665
+ console.error(
1666
+ `error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
1667
+ );
1668
+ for (const file of unclassified) console.error(` ${file}`);
1669
+ console.error(
1670
+ "Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
1671
+ );
1672
+ return true;
1673
+ }
1646
1674
  function printDryRun(selected, baseUrl, cwd) {
1647
1675
  console.log(`
1648
1676
  Dry run: ${selected.length} test(s) selected, base_url=${baseUrl}`);
@@ -1664,6 +1692,12 @@ async function runCommand(options) {
1664
1692
  }
1665
1693
  const impacted = options.impacted ?? false;
1666
1694
  const base = options.base ?? "main";
1695
+ if (options.failOnUnmapped && !impacted) {
1696
+ console.error(
1697
+ "error: --fail-on-unmapped classifies the files in a diff, so it requires --impacted."
1698
+ );
1699
+ return EXIT_USAGE;
1700
+ }
1667
1701
  let impact;
1668
1702
  if (impacted) {
1669
1703
  let changedFiles;
@@ -1719,7 +1753,13 @@ async function runCommand(options) {
1719
1753
  }
1720
1754
  if (options.dryRun) {
1721
1755
  printDryRun(selected, config.base_url, options.cwd);
1722
- return EXIT_OK;
1756
+ if (results.length > 0) {
1757
+ console.error(`
1758
+ ${results.length} test file(s) could not be parsed:`);
1759
+ for (const broken of results) console.error(` ${broken.file}: ${broken.reason ?? "invalid"}`);
1760
+ return EXIT_FAILED;
1761
+ }
1762
+ return reportUnclassified(options, impact) ? EXIT_FAILED : EXIT_OK;
1723
1763
  }
1724
1764
  const sessionDir = path9.join(options.cwd, ".blastproof", "reports", sessionId());
1725
1765
  const startedAt = Date.now();
@@ -1742,6 +1782,7 @@ async function runCommand(options) {
1742
1782
  console.log(
1743
1783
  `blastproof run: ${selected.length} test(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
1744
1784
  );
1785
+ const runMask = buildRunMask(config, parsed);
1745
1786
  const browser = await chromium.launch({ headless: config.browser.headless });
1746
1787
  try {
1747
1788
  let session;
@@ -1756,6 +1797,7 @@ async function runCommand(options) {
1756
1797
  browser,
1757
1798
  brain: createBrain(createModel(config.llm).model),
1758
1799
  maxRetries: config.max_retries_per_step,
1800
+ mask: runMask,
1759
1801
  onEvent: printEvent
1760
1802
  });
1761
1803
  } catch (error) {
@@ -1769,7 +1811,7 @@ async function runCommand(options) {
1769
1811
  for (const test of selected) {
1770
1812
  console.log(`
1771
1813
  > ${test.summary} [${test.priority}] (${path9.relative(options.cwd, test.path)})`);
1772
- results.push(await runOne(browser, test, config, sessionDir, session));
1814
+ results.push(await runOne(browser, test, config, sessionDir, session, runMask));
1773
1815
  }
1774
1816
  } finally {
1775
1817
  await browser.close();
@@ -1874,6 +1916,13 @@ async function planCommand(options) {
1874
1916
  console.log(
1875
1917
  `blastproof plan: ${work.length} route(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
1876
1918
  );
1919
+ const mask = new SecretsMask();
1920
+ for (const step of config.auth?.steps ?? []) mask.registerFrom(step);
1921
+ for (const value of Object.values(config.auth?.headers ?? {})) mask.registerFrom(value);
1922
+ for (const cookie of config.auth?.cookies ?? []) mask.registerFrom(cookie.value);
1923
+ for (const test of suite) {
1924
+ for (const step of [...test.setup ?? [], ...test.steps]) mask.registerFrom(step);
1925
+ }
1877
1926
  const generated = [];
1878
1927
  const written = [];
1879
1928
  const failed = [];
@@ -1891,6 +1940,7 @@ async function planCommand(options) {
1891
1940
  browser,
1892
1941
  brain: createBrain(createModel(config.llm).model),
1893
1942
  maxRetries: config.max_retries_per_step,
1943
+ mask,
1894
1944
  onEvent: printAuthEvent
1895
1945
  });
1896
1946
  } catch (error) {
@@ -1913,7 +1963,8 @@ async function planCommand(options) {
1913
1963
  route,
1914
1964
  baseUrl: config.base_url,
1915
1965
  changedFiles,
1916
- brain
1966
+ brain,
1967
+ mask: (text) => mask.mask(text)
1917
1968
  });
1918
1969
  } catch (error) {
1919
1970
  failed.push({ route, reason: error instanceof Error ? error.message : String(error) });
@@ -2013,7 +2064,7 @@ function parseMinScore(value) {
2013
2064
  return score;
2014
2065
  }
2015
2066
  var program = new Command();
2016
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.2.0");
2067
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.2.2");
2017
2068
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
2018
2069
  try {
2019
2070
  const result = await initProject(process.cwd());