blastproof 0.20.0 → 0.22.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
@@ -275,11 +275,11 @@ The application under test is not trusted input: its page content reaches the mo
275
275
 
276
276
  If your application legitimately spans hosts (an identity provider, a hosted payment step), declare them. A suite that was quietly walking onto a foreign page will now fail and name the origin to add.
277
277
 
278
- **Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form. Redaction matches a known value case-insensitively and tolerantly of whitespace, so a page that uppercases what you typed is still covered. It does not chase a value your application re-encodes, hashes or truncates — no list of transforms can be complete, and one that pretends to be would make you careless. When a redaction is found only by that wider comparison, the run prints one warning naming the variable, because the same page could just as easily have returned a form nothing here can recognise. Treat all of it as a strong default rather than a guarantee against a hostile app.
278
+ **Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form, and replaced with a label naming its variable: `[redacted TEST_PASSWORD]`. The label carries the name and nothing derived from the value, so it tells a reader, and the model, *which* secret was there without telling anyone what it was. Redaction matches a known value case-insensitively and tolerantly of whitespace, so a page that uppercases what you typed is still covered. It does not chase a value your application re-encodes, hashes or truncates — no list of transforms can be complete, and one that pretends to be would make you careless. When a redaction is found only by that wider comparison, the run prints one warning naming the variable, because the same page could just as easily have returned a form nothing here can recognise. Treat all of it as a strong default rather than a guarantee against a hostile app.
279
279
 
280
280
  **Redaction covers text, not pixels.** A failure screenshot shows whatever was on screen, including a value typed from `{{env.*}}` or echoed back by the page. So when anything in a run references `{{env.*}}`, the HTML report does not embed screenshots: each failure links to its PNG under `.blastproof/reports/` instead. That keeps `report.html` safe to attach to a pull request. The PNGs themselves are not masked, so do not upload `.blastproof/reports/` anywhere you would not put the secret ([#110](https://github.com/hamc/blastproof/issues/110)).
281
281
 
282
- **A redacted value cannot be asserted on.** The masking is thorough by design, and page snapshots are not exempt — so a step that verifies text which happens to equal an `{{env.*}}` value can never pass, because the judge is shown `***` where the page shows the thing. The failure is the most misleading shape available: the test is right, the application is right, and the report blames the application. Put a value in `{{env.*}}` because it is a secret or because it varies by environment, but do not then write a step that asserts on it ([#87](https://github.com/hamc/blastproof/issues/87)). Matching is case-insensitive, so this covers a little more of the page than the value's exact spelling — one more reason to keep assertions off it.
282
+ **A redacted value is asserted on by identity.** A step that verifies text equal to an `{{env.*}}` value reaches the judge through the mask, like the page does, so both sides read `[redacted NAME]`. The same label on both sides is a match; different labels are not. Until 0.21.0 every secret was redacted to the same `***`, and a step verifying one secret passed against a page showing another — measured, 3 times out of 3 ([#87](https://github.com/hamc/blastproof/issues/87)). What the judge still cannot check is anything *about* the value beyond which variable it is: its length, its format, a prefix. Assert on those with a value that is not in `{{env.*}}`.
283
283
 
284
284
  The system prompt also tells the model that page content is data, never instruction. That raises the cost of casual injection and is **not** a boundary — the origin constraint is. Do not point blastproof at an application you would not run locally.
285
285
 
package/dist/cli.js CHANGED
@@ -118,7 +118,7 @@ var SAMPLE_LOGIN_TEMPLATE = `# TEMPLATE \u2014 rename to login.yaml once these s
118
118
  #
119
119
  # Credentials come from the environment. Export them before running:
120
120
  # export TEST_EMAIL=... TEST_PASSWORD=...
121
- # Substituted values are masked as *** everywhere and never reach the model.
121
+ # Substituted values are masked as [redacted NAME] everywhere and never reach the model.
122
122
  summary: Login with valid credentials succeeds
123
123
  priority: P0
124
124
  tags: [smoke, auth]
@@ -333,22 +333,35 @@ function referencedEnvVars(text) {
333
333
  }
334
334
  return [...names];
335
335
  }
336
+ function placeholdersAsLabels(text) {
337
+ return text.replace(ENV_PLACEHOLDER, (_match, name) => redactionLabel(name));
338
+ }
336
339
  function escapeRegExp(value) {
337
340
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
338
341
  }
339
342
  function secretPattern(secret) {
340
343
  return new RegExp(escapeRegExp(secret).replace(/\s+/g, "\\s+"), "gi");
341
344
  }
342
- function compile(secrets) {
343
- return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value) }));
345
+ var REDACTION_PREFIX = "[redacted";
346
+ function redactionLabel(name) {
347
+ return name === void 0 ? `${REDACTION_PREFIX}]` : `${REDACTION_PREFIX} ${name}]`;
348
+ }
349
+ var LABEL = /\[redacted ([A-Za-z_][A-Za-z0-9_]*)\]/g;
350
+ function labelledVariables(text) {
351
+ const names = /* @__PURE__ */ new Set();
352
+ for (const match of text.matchAll(LABEL)) if (match[1]) names.add(match[1]);
353
+ return [...names];
354
+ }
355
+ function compile(secrets, names) {
356
+ return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value), label: redactionLabel(names?.get(value)) }));
344
357
  }
345
358
  function maskCompiled(text, compiled, onNearMiss) {
346
359
  let masked = text;
347
- for (const { value, pattern } of compiled) {
360
+ for (const { value, pattern, label } of compiled) {
348
361
  pattern.lastIndex = 0;
349
362
  masked = masked.replace(pattern, (found) => {
350
363
  if (found !== value) onNearMiss?.(value, found);
351
- return "***";
364
+ return label;
352
365
  });
353
366
  }
354
367
  return masked;
@@ -388,7 +401,7 @@ var SecretsMask = class {
388
401
  }
389
402
  }
390
403
  mask(text) {
391
- this.compiled ??= compile(this.secrets);
404
+ this.compiled ??= compile(this.secrets, this.names);
392
405
  return maskCompiled(text, this.compiled, (secret) => {
393
406
  const name = this.names.get(secret);
394
407
  if (name !== void 0) this.nearMissed.add(name);
@@ -677,7 +690,26 @@ var StepRecovery = class {
677
690
  * and #28 has now produced one on three applications.
678
691
  */
679
692
  refusalFor(action) {
680
- return this.repeatedCommitRefusal(action) ?? this.unsourcedValueRefusal(action);
693
+ return this.repeatedCommitRefusal(action) ?? this.redactionLabelRefusal(action) ?? this.unsourcedValueRefusal(action);
694
+ }
695
+ /**
696
+ * Refuses a typed value containing a redaction label (design
697
+ * label-a-redaction-with-its-variable, D3).
698
+ *
699
+ * Checked before the source check because the source check would admit it:
700
+ * `observe` credits the model with the masked snapshot, and the label is in
701
+ * the masked snapshot. It is the mask's own writing, not the application's, so
702
+ * typing it would put the words "[redacted TEST_EMAIL]" into a real field.
703
+ * A prefix match rather than the exact label, because a model that copies
704
+ * part of one, or rebuilds one around another name, is equally wrong.
705
+ *
706
+ * Translating it back into its placeholder was the alternative, and it is the
707
+ * exemption #66 closed: the model would be choosing a secret to type.
708
+ */
709
+ redactionLabelRefusal(action) {
710
+ if (!SOURCED_VALUE_ACTIONS.has(action.action)) return void 0;
711
+ if (!action.value?.includes(REDACTION_PREFIX)) return void 0;
712
+ return `refused: the value was NOT typed, because it contains a redaction label. ${REDACTION_PREFIX} NAME] stands for the value of {{env.NAME}}, withheld from you; it is never a value the application should receive. To enter an environment value, use the {{env.*}} placeholder this step names. If the step names none, use a value it supplies, or fail the step.`;
681
713
  }
682
714
  repeatedCommitRefusal(action) {
683
715
  if (!COMMIT_ACTIONS.has(action.action)) return void 0;
@@ -1449,7 +1481,7 @@ Rules:
1449
1481
  - An action reported as "blocked" is the exception to that rule: it means another element is on top of your target, not that you picked the wrong target. Re-targeting cannot fix it. Whatever is covering the page is in the snapshot \u2014 a dialog, a cookie banner, an onboarding overlay \u2014 so dismiss that first, with its own close or accept control, or by pressing Escape with no target, and then act on your original target again. Overlays can be stacked: clearing one may reveal another, and that is progress, not failure.
1450
1482
  - Never invent a value. A value you type must come from the step, from the page, or from an {{env.*}} placeholder. This one is enforced, not merely asked: a fill or select whose value is in none of those is refused and not performed. If a step needs a value it does not give you, that is a failing step, not a gap for you to fill in.
1451
1483
  - A record of the actions you already performed in this step may be shown to you. It is the ground truth about what happened, even when the page no longer shows it: a form that submitted successfully and came back empty looks exactly like one you never submitted. Do not redo work that record says you already did.
1452
- - \`***\` in a snapshot is a redacted secret \u2014 a password, token or key deliberately withheld from you. Seeing it is expected and is not a problem. A field showing \`***\` after you filled it from an {{env.VAR}} placeholder means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, and never report failure because a value was withheld.
1484
+ - \`[redacted NAME]\` in a snapshot is the value of {{env.NAME}}, deliberately withheld from you. Seeing one is expected and is not a problem. The same label is the same value; two different labels are two different values. A field showing \`[redacted NAME]\` after you filled it from {{env.NAME}} means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, never report failure because a value was withheld, and never type a label as a value: to enter an environment value, use the {{env.*}} placeholder the step names.
1453
1485
  - Keep reasoning to one short sentence.`;
1454
1486
  }
1455
1487
  function agentUserPrompt(input) {
@@ -1485,7 +1517,7 @@ The record is not evidence that the step's outcome holds. An action reported as
1485
1517
 
1486
1518
  Be strict about what the step asks, not about withholding a pass you can plainly see is earned. Answer with pass=true/false and a one-sentence reason.
1487
1519
 
1488
- \`***\` marks a secret deliberately withheld from you \u2014 a password, token or key. Seeing it is expected. A field holding \`***\` is filled, not empty, so do not fail a step on the grounds that a value was redacted. This applies only to the redaction itself: everything else the step asks for must still be visibly satisfied by the snapshot, and a step you genuinely cannot check against what you were shown still fails.`;
1520
+ \`[redacted NAME]\` marks the value of {{env.NAME}}, deliberately withheld from you. Seeing one is expected. A field holding a label is filled, not empty, so do not fail a step on the grounds that a value was redacted. The same label is the same value, and two different labels are two different values: a step expecting \`[redacted A]\` is not satisfied by a page showing \`[redacted B]\`. This applies only to the redaction itself: everything else the step asks for must still be visibly satisfied by the snapshot, and a step you genuinely cannot check against what you were shown still fails.`;
1489
1521
  }
1490
1522
  function assertUserPrompt(step, expectation, snapshot, stepHistory) {
1491
1523
  const parts = [`Step under test: ${step}`];
@@ -1520,7 +1552,7 @@ Rules:
1520
1552
  - **The test starts at the application's base URL, not at this route.** Begin with a step that navigates to the route and says what should be visible once it loads \u2014 "navigate to /support and verify the heading "Contact support" is shown". Without it the run opens the home page and every later step looks for controls that are not there.
1521
1553
  - **Every step says what it should produce.** Name what must be true once the step has been carried out, not the action alone: "submit the support form and verify the confirmation page shows the ticket number", never "submit the support form". A step that names an action without an outcome asks the runner to judge whether something happened while looking at the page that succeeding produces \u2014 a submitted form comes back empty, a redirect moves the URL \u2014 and that is the shape behind several real failures.
1522
1554
  - **A step that enters a value writes the value.** "fill the subject field with Order not received", never "enter a subject". The runner is forbidden from inventing values, and enforces it: a fill whose value is in neither the step nor the page is refused, so a step that supplies none cannot be relied on to run.
1523
- - If a step needs a credential or any secret, write it as a placeholder like {{env.TEST_PASSWORD}}. Never write a real or invented password, token or key.
1555
+ - If a step needs a credential, or any value that identifies an account or a person \u2014 an email address, a username, an account or customer number \u2014 write it as a placeholder like {{env.TEST_EMAIL}} or {{env.TEST_PASSWORD}}. Never write a real or invented one.
1524
1556
  - Keep the whole test to a handful of steps: one journey, not an exhaustive suite.`;
1525
1557
  }
1526
1558
  function plannerUserPrompt(input) {
@@ -1584,8 +1616,10 @@ function parseAgentAction(value) {
1584
1616
  return parsedAgentActionSchema.safeParse(value);
1585
1617
  }
1586
1618
  var assertJudgmentSchema = z2.object({
1587
- pass: z2.boolean().describe("Whether the snapshot satisfies the expectation."),
1588
- reason: z2.string().describe("One sentence explaining the judgment.")
1619
+ reason: z2.string().describe("One sentence: what the STEP requires to be true now, and whether the snapshot shows it."),
1620
+ pass: z2.boolean().describe(
1621
+ "Whether the snapshot shows the STEP's own outcome. The expectation is only a claim offered in support; it never replaces the step. False if any part of the outcome the step asks for is not shown, or cannot be assessed from this snapshot."
1622
+ )
1589
1623
  });
1590
1624
  var generatedTestSchema = z2.object({
1591
1625
  summary: z2.string().min(1).describe('One line naming the user journey this test covers, e.g. "Applying a discount updates the cart total".'),
@@ -1642,11 +1676,20 @@ function createBrain(model, generate = generateObject, budget) {
1642
1676
  return parsed.data;
1643
1677
  },
1644
1678
  async judge(step, expectation, snapshot, stepHistory) {
1679
+ const asJudged = stepHistory?.map((entry) => ({
1680
+ action: placeholdersAsLabels(entry.action),
1681
+ result: placeholdersAsLabels(entry.result)
1682
+ }));
1645
1683
  const result = await countedGenerate(generate, budget, {
1646
1684
  model,
1647
1685
  schema: assertJudgmentSchema,
1648
1686
  system: assertSystemPrompt(),
1649
- prompt: assertUserPrompt(step, expectation, snapshot, stepHistory),
1687
+ prompt: assertUserPrompt(
1688
+ placeholdersAsLabels(step),
1689
+ placeholdersAsLabels(expectation),
1690
+ snapshot,
1691
+ asJudged
1692
+ ),
1650
1693
  temperature: 0
1651
1694
  });
1652
1695
  const parsed = assertJudgmentSchema.safeParse(result.object);
@@ -1655,10 +1698,26 @@ function createBrain(model, generate = generateObject, budget) {
1655
1698
  `Model returned an invalid assert judgment: ${parsed.error.issues[0]?.message ?? "unknown"}`
1656
1699
  );
1657
1700
  }
1658
- return parsed.data;
1701
+ return secretMismatch(placeholdersAsLabels(step), snapshot, asJudged, parsed.data);
1659
1702
  }
1660
1703
  };
1661
1704
  }
1705
+ function secretMismatch(judgedStep, snapshot, judgedRecord, judgment) {
1706
+ if (!judgment.pass) return judgment;
1707
+ const onPage = labelledVariables(snapshot);
1708
+ const inRecord = labelledVariables((judgedRecord ?? []).map((e) => `${e.action}
1709
+ ${e.result}`).join("\n"));
1710
+ for (const name of labelledVariables(judgedStep)) {
1711
+ const others = onPage.filter((other) => other !== name);
1712
+ if (onPage.includes(name) || inRecord.includes(name) || others.length === 0) continue;
1713
+ const shown = others.map((other) => redactionLabel(other)).join(", ");
1714
+ return {
1715
+ pass: false,
1716
+ reason: `The step names ${redactionLabel(name)}, which appears neither on the page nor in this step's actions, while the page shows ${shown}: a different secret cannot satisfy it. (The judge had said: ${judgment.reason})`
1717
+ };
1718
+ }
1719
+ return judgment;
1720
+ }
1662
1721
  function createPlanner(model, generate = generateObject, budget) {
1663
1722
  return {
1664
1723
  async planTest(input) {
@@ -1736,6 +1795,41 @@ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
1736
1795
  import path6 from "path";
1737
1796
  import { stringify } from "yaml";
1738
1797
 
1798
+ // src/runner/authoring.ts
1799
+ var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
1800
+ var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
1801
+ var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
1802
+ var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
1803
+ var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
1804
+ var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
1805
+ function namesAValue(step) {
1806
+ const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
1807
+ return CONNECTOR_WORD.test(withoutPhrasal) || CONNECTOR_SYMBOL.test(withoutPhrasal);
1808
+ }
1809
+ function namesNoValue(step) {
1810
+ return LEADING_VALUE_VERB.test(step) && !namesAValue(step);
1811
+ }
1812
+ function entersNamedValue(step) {
1813
+ return LEADING_VALUE_VERB.test(step) && namesAValue(step);
1814
+ }
1815
+ function detectMissingValues(tests) {
1816
+ const findings = [];
1817
+ for (const test of tests) {
1818
+ for (const origin of ["setup", "steps"]) {
1819
+ const steps = origin === "setup" ? test.setup ?? [] : test.steps;
1820
+ steps.forEach((step, position) => {
1821
+ if (namesNoValue(step)) {
1822
+ findings.push({ test, origin, index: position + 1, step });
1823
+ }
1824
+ });
1825
+ }
1826
+ }
1827
+ return { findings };
1828
+ }
1829
+ function suggestValueClause(step) {
1830
+ return `${step.trimEnd()} with <value>`;
1831
+ }
1832
+
1739
1833
  // src/runner/testfile.ts
1740
1834
  import { readFile as readFile3, readdir } from "fs/promises";
1741
1835
  import path5 from "path";
@@ -1811,9 +1905,20 @@ var CREDENTIAL_WORD = /\b(password|passwd|api[ _-]?key|token|secret|credential)\
1811
1905
  var QUOTED_LITERAL = /["'][^"']+["']/;
1812
1906
  function findSecretLiterals(steps) {
1813
1907
  return steps.filter(
1814
- (step) => CREDENTIAL_WORD.test(step) && QUOTED_LITERAL.test(step) && !step.includes("{{env.")
1908
+ (step) => CREDENTIAL_WORD.test(step) && !step.includes("{{env.") && (QUOTED_LITERAL.test(step) || entersNamedValue(step))
1815
1909
  );
1816
1910
  }
1911
+ var EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
1912
+ function findUnsourcedEmails(steps, snapshot) {
1913
+ const page = snapshot.toLowerCase();
1914
+ const found = [];
1915
+ steps.forEach((text, step) => {
1916
+ for (const [address] of text.matchAll(EMAIL_ADDRESS)) {
1917
+ if (!page.includes(address.toLowerCase())) found.push({ step, address });
1918
+ }
1919
+ });
1920
+ return found;
1921
+ }
1817
1922
  function routeToSlug(route) {
1818
1923
  const slug = route.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1819
1924
  return slug || "home";
@@ -1845,9 +1950,10 @@ async function generateForRoute(page, options) {
1845
1950
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
1846
1951
  );
1847
1952
  }
1953
+ const pageSnapshot = mask(await takeSnapshot(page));
1848
1954
  const generated = await brain.planTest({
1849
1955
  route,
1850
- snapshot: mask(await takeSnapshot(page)),
1956
+ snapshot: pageSnapshot,
1851
1957
  changedFiles
1852
1958
  });
1853
1959
  const leaked = findSecretLiterals(generated.steps);
@@ -1856,7 +1962,11 @@ async function generateForRoute(page, options) {
1856
1962
  `Generated steps contain literal secrets instead of {{env.VAR}} placeholders: ${leaked.join(" | ")}`
1857
1963
  );
1858
1964
  }
1859
- return { ...generated, routes: [route] };
1965
+ return {
1966
+ ...generated,
1967
+ routes: [route],
1968
+ unsourcedEmails: findUnsourcedEmails(generated.steps, pageSnapshot)
1969
+ };
1860
1970
  }
1861
1971
  async function writeDraft(cwd, draft, meta) {
1862
1972
  const dir = path6.join(cwd, TESTS_RELATIVE_DIR);
@@ -2294,36 +2404,6 @@ async function writeJUnit(file, xml) {
2294
2404
  }
2295
2405
  }
2296
2406
 
2297
- // src/runner/authoring.ts
2298
- var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
2299
- var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
2300
- var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
2301
- var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
2302
- var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
2303
- var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
2304
- function namesNoValue(step) {
2305
- if (!LEADING_VALUE_VERB.test(step)) return false;
2306
- const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
2307
- return !CONNECTOR_WORD.test(withoutPhrasal) && !CONNECTOR_SYMBOL.test(withoutPhrasal);
2308
- }
2309
- function detectMissingValues(tests) {
2310
- const findings = [];
2311
- for (const test of tests) {
2312
- for (const origin of ["setup", "steps"]) {
2313
- const steps = origin === "setup" ? test.setup ?? [] : test.steps;
2314
- steps.forEach((step, position) => {
2315
- if (namesNoValue(step)) {
2316
- findings.push({ test, origin, index: position + 1, step });
2317
- }
2318
- });
2319
- }
2320
- }
2321
- return { findings };
2322
- }
2323
- function suggestValueClause(step) {
2324
- return `${step.trimEnd()} with <value>`;
2325
- }
2326
-
2327
2407
  // src/runner/pool.ts
2328
2408
  async function runWithConcurrency(items, concurrency, run) {
2329
2409
  if (concurrency < 1) throw new RangeError(`concurrency must be at least 1, got ${concurrency}`);
@@ -3074,6 +3154,12 @@ async function planCommand(options) {
3074
3154
  } finally {
3075
3155
  await context.close();
3076
3156
  }
3157
+ for (const { step, address } of draft.unsourcedEmails) {
3158
+ console.error(
3159
+ ` warning: step ${step + 1} writes an email address the page does not show (${address}).
3160
+ If it is the account the test signs in as, use a placeholder like {{env.TEST_EMAIL}}.`
3161
+ );
3162
+ }
3077
3163
  generated.push(route);
3078
3164
  if (options.write) {
3079
3165
  try {
@@ -3206,7 +3292,7 @@ function parsePositiveNumber(flag) {
3206
3292
  };
3207
3293
  }
3208
3294
  var program = new Command();
3209
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.20.0");
3295
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.22.0");
3210
3296
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
3211
3297
  try {
3212
3298
  const result = await initProject(process.cwd());