@kirrosh/zond 0.23.0 → 0.26.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 (158) hide show
  1. package/CHANGELOG.md +164 -1
  2. package/README.md +8 -7
  3. package/package.json +2 -3
  4. package/src/CLAUDE.md +112 -0
  5. package/src/cli/commands/add-api.ts +19 -7
  6. package/src/cli/commands/api/annotate/index.ts +359 -4
  7. package/src/cli/commands/api/annotate/lifecycle.ts +1 -1
  8. package/src/cli/commands/api/annotate/overlay.ts +1 -1
  9. package/src/cli/commands/api/annotate/pagination.ts +10 -6
  10. package/src/cli/commands/api/annotate/prompts.ts +39 -2
  11. package/src/cli/commands/audit.ts +360 -54
  12. package/src/cli/commands/check.ts +15 -2
  13. package/src/cli/commands/checks.ts +352 -36
  14. package/src/cli/commands/cleanup.ts +4 -30
  15. package/src/cli/commands/coverage.ts +275 -57
  16. package/src/cli/commands/db.ts +311 -8
  17. package/src/cli/commands/discover.ts +281 -161
  18. package/src/cli/commands/doctor.ts +57 -3
  19. package/src/cli/commands/fixtures.ts +1 -1
  20. package/src/cli/commands/generate.ts +24 -7
  21. package/src/cli/commands/init/bootstrap.ts +4 -1
  22. package/src/cli/commands/init/index.ts +2 -2
  23. package/src/cli/commands/init/skills.ts +43 -0
  24. package/src/cli/commands/init/templates/agents.md +12 -3
  25. package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
  26. package/src/cli/commands/init/templates/skills/zond-checks.md +268 -44
  27. package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
  28. package/src/cli/commands/init/templates/skills/zond-triage.md +88 -26
  29. package/src/cli/commands/init/templates/skills/zond.md +274 -64
  30. package/src/cli/commands/init/templates/zond-config.yml +1 -1
  31. package/src/cli/commands/prepare-fixtures.ts +14 -52
  32. package/src/cli/commands/probe/_seed-bodies.ts +52 -0
  33. package/src/cli/commands/probe/mass-assignment.ts +101 -10
  34. package/src/cli/commands/probe/security.ts +95 -12
  35. package/src/cli/commands/probe/webhooks.ts +2 -0
  36. package/src/cli/commands/probe.ts +87 -11
  37. package/src/cli/commands/refresh-api.ts +59 -1
  38. package/src/cli/commands/report-bundle.ts +3 -11
  39. package/src/cli/commands/request.ts +116 -0
  40. package/src/cli/commands/run.ts +33 -5
  41. package/src/cli/commands/schema-from-runs.ts +128 -0
  42. package/src/cli/commands/secrets.ts +133 -0
  43. package/src/cli/json-envelope.ts +0 -20
  44. package/src/cli/json-schemas.ts +51 -0
  45. package/src/cli/output.ts +17 -1
  46. package/src/cli/program.ts +5 -4
  47. package/src/cli/safe-live.ts +24 -0
  48. package/src/cli/status-filter.ts +0 -10
  49. package/src/core/audit/persist.ts +183 -0
  50. package/src/core/checks/budget.ts +59 -0
  51. package/src/core/checks/checks/cross_call_references.ts +17 -4
  52. package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
  53. package/src/core/checks/checks/idempotency_replay.ts +1 -5
  54. package/src/core/checks/checks/ignored_auth.ts +44 -1
  55. package/src/core/checks/checks/index.ts +3 -0
  56. package/src/core/checks/checks/lifecycle_transitions.ts +169 -26
  57. package/src/core/checks/checks/negative_data_rejection.ts +119 -16
  58. package/src/core/checks/checks/not_a_server_error.ts +8 -0
  59. package/src/core/checks/checks/open_cors_on_sensitive.ts +47 -18
  60. package/src/core/checks/checks/pagination_invariants.ts +298 -117
  61. package/src/core/checks/checks/positive_data_acceptance.ts +1 -4
  62. package/src/core/checks/checks/status_code_conformance.ts +78 -7
  63. package/src/core/checks/mode.ts +3 -0
  64. package/src/core/checks/recommended-action.ts +5 -1
  65. package/src/core/checks/runner.ts +614 -27
  66. package/src/core/checks/spec-findings.ts +308 -0
  67. package/src/core/checks/types.ts +117 -1
  68. package/src/core/checks/zond-extensions.ts +73 -0
  69. package/src/core/classifier/recommended-action.ts +35 -6
  70. package/src/core/coverage/loader.ts +31 -0
  71. package/src/core/diagnostics/db-analysis.ts +200 -106
  72. package/src/core/diagnostics/failure-class.ts +21 -1
  73. package/src/core/diagnostics/failure-hints.ts +4 -208
  74. package/src/core/diagnostics/suggested-fixes.ts +2 -3
  75. package/src/core/generator/chunker.ts +1 -8
  76. package/src/core/generator/data-factory.ts +199 -61
  77. package/src/core/generator/fixtures-builder.ts +38 -31
  78. package/src/core/generator/index.ts +0 -2
  79. package/src/core/generator/openapi-reader.ts +98 -4
  80. package/src/core/generator/path-param-disambig.ts +30 -4
  81. package/src/core/generator/resources-builder.ts +276 -26
  82. package/src/core/generator/schema-utils.ts +22 -0
  83. package/src/core/generator/suite-generator.ts +168 -15
  84. package/src/core/generator/types.ts +6 -0
  85. package/src/core/identity/identity-file.ts +0 -0
  86. package/src/core/output/README.md +11 -29
  87. package/src/core/output/index.ts +1 -1
  88. package/src/core/output/run.ts +0 -35
  89. package/src/core/output/types.ts +0 -7
  90. package/src/core/parser/dynamic-values.ts +160 -0
  91. package/src/core/parser/variables.ts +0 -0
  92. package/src/core/probe/dry-run-envelope.ts +4 -0
  93. package/src/core/probe/mass-assignment/classify.ts +175 -0
  94. package/src/core/probe/mass-assignment/cleanup.ts +52 -0
  95. package/src/core/probe/mass-assignment/digest.ts +114 -0
  96. package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
  97. package/src/core/probe/mass-assignment/regression.ts +141 -0
  98. package/src/core/probe/mass-assignment/suspects.ts +92 -0
  99. package/src/core/probe/mass-assignment/types.ts +135 -0
  100. package/src/core/probe/mass-assignment-probe.ts +23 -1118
  101. package/src/core/probe/mass-assignment-template.ts +32 -4
  102. package/src/core/probe/path-discovery.ts +3 -4
  103. package/src/core/probe/probe-harness.ts +21 -22
  104. package/src/core/probe/security/baseline.ts +174 -0
  105. package/src/core/probe/security/classify.ts +341 -0
  106. package/src/core/probe/security/cleanup.ts +125 -0
  107. package/src/core/probe/security/detectors.ts +71 -0
  108. package/src/core/probe/security/digest.ts +104 -0
  109. package/src/core/probe/security/orchestrator.ts +398 -0
  110. package/src/core/probe/security/regression.ts +103 -0
  111. package/src/core/probe/security/types.ts +151 -0
  112. package/src/core/probe/security-probe-class.ts +8 -2
  113. package/src/core/probe/security-probe.ts +28 -1449
  114. package/src/core/probe/shared.ts +26 -0
  115. package/src/core/probe/webhooks-probe.ts +5 -7
  116. package/src/core/runner/assertions.ts +1 -1
  117. package/src/core/runner/executor.ts +3 -18
  118. package/src/core/runner/form-encode.ts +8 -18
  119. package/src/core/runner/http-client.ts +38 -1
  120. package/src/core/runner/preflight-vars.ts +19 -15
  121. package/src/core/runner/rate-limiter.ts +11 -29
  122. package/src/core/runner/run-kind.ts +7 -1
  123. package/src/core/runner/schema-validator.ts +2 -6
  124. package/src/core/runner/send-request.ts +11 -6
  125. package/src/core/runner/types.ts +6 -0
  126. package/src/core/setup-api.ts +53 -15
  127. package/src/core/severity/index.ts +0 -63
  128. package/src/core/spec/infer-schema.ts +102 -0
  129. package/src/core/spec/merge-specs.ts +156 -0
  130. package/src/core/spec/schema-from-runs.ts +117 -0
  131. package/src/core/spec/schema-overlay.ts +130 -0
  132. package/src/core/util/ajv.ts +13 -0
  133. package/src/core/util/headers.ts +9 -0
  134. package/src/core/util/url.ts +24 -0
  135. package/src/core/workspace/fixture-gap-report.ts +84 -0
  136. package/src/core/workspace/fixture-gaps.ts +71 -0
  137. package/src/core/workspace/root.ts +13 -11
  138. package/src/db/migrate.ts +2 -0
  139. package/src/db/migrations/0002_run_kind_request.sql +59 -0
  140. package/src/db/queries/collections.ts +2 -2
  141. package/src/db/queries/results.ts +88 -0
  142. package/src/db/queries/runs.ts +56 -2
  143. package/src/db/queries.ts +3 -0
  144. package/src/db/schema.ts +7 -7
  145. package/src/cli/commands/bootstrap.ts +0 -710
  146. package/src/core/anti-fp/bootstrap.ts +0 -34
  147. package/src/core/anti-fp/index.ts +0 -33
  148. package/src/core/anti-fp/registry.ts +0 -44
  149. package/src/core/anti-fp/rules/baseline-echo.ts +0 -74
  150. package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +0 -52
  151. package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +0 -38
  152. package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +0 -35
  153. package/src/core/anti-fp/rules/schemathesis/index.ts +0 -24
  154. package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +0 -53
  155. package/src/core/anti-fp/rules/subscription-gated/index.ts +0 -11
  156. package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +0 -75
  157. package/src/core/anti-fp/types.ts +0 -68
  158. package/src/core/generator/create-body.ts +0 -89
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ARV-269: shared loader for agent-authored `seed_body` overlays.
3
+ *
4
+ * Stateful checks already consume `.api-resources.local.yaml::seed_body`
5
+ * via `resolveCreateBody` (`core/checks/checks/_crud-helpers.ts`). Before
6
+ * ARV-269 the live probes (mass-assignment, security) did not — every
7
+ * baseline POST fell back to `generateFromSchema`, which strict APIs like
8
+ * Stripe v1 routinely reject as 400 with `INCONCLUSIVE-baseline`. The
9
+ * agent-authored overlay (ARV-187 pipeline) silently lost half its ROI.
10
+ *
11
+ * This helper builds the `Map<"METHOD path", SeedBodyConfig>` the probe
12
+ * runners consume. It's a CLI-layer helper because it crosses into
13
+ * `cli/commands/discover.ts` (the resource-map loader); keeping it under
14
+ * `core/probe` would inject a CLI dependency into the runtime.
15
+ */
16
+ import { resolveApiCollection } from "../../resolve.ts";
17
+ import { readResourceMap } from "../discover.ts";
18
+ import type { SeedBodyConfig } from "../../../core/generator/resources-builder.ts";
19
+
20
+ /** Parse the "METHOD /path" labels used in `.api-resources.yaml`. */
21
+ function parseLabel(label: string): { method: string; path: string } | null {
22
+ const parts = label.trim().split(/\s+/);
23
+ if (parts.length < 2) return null;
24
+ return { method: parts[0]!.toUpperCase(), path: parts[1]! };
25
+ }
26
+
27
+ /**
28
+ * Returns undefined when no API context is in scope (raw `--spec`
29
+ * invocation) or when no resource declares `seed_body`. Probe runners
30
+ * treat undefined as "no overlay" and keep the legacy generator path.
31
+ */
32
+ export async function loadSeedBodyOverlays(
33
+ apiName: string | undefined,
34
+ dbPath?: string,
35
+ ): Promise<Map<string, SeedBodyConfig> | undefined> {
36
+ if (!apiName) return undefined;
37
+ const col = resolveApiCollection(apiName, dbPath);
38
+ if ("error" in col || !col.baseDir) return undefined;
39
+ const map = await readResourceMap(col.baseDir);
40
+ if (!map) return undefined;
41
+ const out = new Map<string, SeedBodyConfig>();
42
+ for (const r of map.resources) {
43
+ if (!r.seed_body || !r.endpoints?.create) continue;
44
+ const parsed = parseLabel(r.endpoints.create);
45
+ if (!parsed) continue;
46
+ out.set(`${parsed.method} ${parsed.path}`, {
47
+ contentType: r.seed_body.content_type,
48
+ body: r.seed_body.body,
49
+ });
50
+ }
51
+ return out.size > 0 ? out : undefined;
52
+ }
@@ -17,6 +17,7 @@ import { printMutationBanner, countCleanupFailures } from "../../../core/probe/s
17
17
  import { MassAssignmentProbe } from "../../../core/probe/mass-assignment-probe-class.ts";
18
18
  import { summarizeDryRun, formatDryRunDigest } from "../../../core/probe/dry-run-envelope.ts";
19
19
  import { compileOperationFilter } from "../../../core/selectors/operation-filter.ts";
20
+ import { loadSeedBodyOverlays } from "./_seed-bodies.ts";
20
21
  import type { EndpointVerdict, MassAssignmentResult } from "../../../core/probe/mass-assignment-probe.ts";
21
22
  import type { ProbeEndpointResult, ProbeEndpointStatus, ProbeFindingSeverity } from "../../../core/probe/types.ts";
22
23
 
@@ -85,6 +86,17 @@ export interface ProbeMassAssignmentOptions {
85
86
  * spec-extension support (x-zond-suspect-fields) is tracked in
86
87
  * ARV-189. */
87
88
  suspectField?: string[];
89
+ /** ARV-265: collection name for audit-coverage attribution. Set by
90
+ * the CLI when --api / current-api resolves; left undefined for
91
+ * bare-spec invocations. */
92
+ apiName?: string;
93
+ /** ARV-302: cap the number of endpoints probed in this run (after
94
+ * --include / --exclude / --tag filters). Used by `zond audit
95
+ * --budget` so probe stages stay inside a wall-clock budget instead
96
+ * of unbounded scanning (Stripe: 587 endpoints, 3.6k probes — silent
97
+ * 10+ min). When the cap trims the set, a warning is emitted so the
98
+ * user knows the run was partial. */
99
+ maxEndpoints?: number;
88
100
  }
89
101
 
90
102
  export async function probeMassAssignmentCommand(
@@ -131,6 +143,23 @@ export async function probeMassAssignmentCommand(
131
143
  }
132
144
  endpoints = endpoints.filter(compiled.filter);
133
145
  }
146
+ // ARV-302: --max-endpoints cap — applied after include/exclude/tag
147
+ // filters so the user-visible filter intent is preserved. The cap
148
+ // is a coarse time-budget knob (`zond audit --budget standard` →
149
+ // 50 endpoints), not a sampling strategy: take the first N from
150
+ // the filtered list. A warning fires so partial output is obvious.
151
+ let cappedEndpoints = false;
152
+ if (typeof options.maxEndpoints === "number" && options.maxEndpoints > 0
153
+ && endpoints.length > options.maxEndpoints) {
154
+ const total = endpoints.length;
155
+ endpoints = endpoints.slice(0, options.maxEndpoints);
156
+ cappedEndpoints = true;
157
+ if (!options.json) {
158
+ process.stderr.write(
159
+ `zond: probe mass-assignment capped at ${options.maxEndpoints}/${total} endpoints (--max-endpoints) — pass --max-endpoints <N> or --budget full to widen.\n`,
160
+ );
161
+ }
162
+ }
134
163
 
135
164
  // Load env vars (base_url, auth_token, api_key, path-param overrides).
136
165
  let vars: Record<string, string> = {};
@@ -160,6 +189,24 @@ export async function probeMassAssignmentCommand(
160
189
  options: {},
161
190
  });
162
191
  const data = summarizeDryRun(plan);
192
+ // ARV-317: persist the planned inventory when --output is given, so it
193
+ // survives beyond stdout (an agent can read it back). --emit-tests is
194
+ // skipped on dry-run — there are no findings to turn into regression
195
+ // suites; the plan digest is the dry-run deliverable.
196
+ // ARV-321: say so explicitly. A silent no-op read as a bug on the live
197
+ // Stripe run (report-zond friction, 2026-07-02) — the target dir stayed
198
+ // empty with zero signal that --emit-tests was ignored.
199
+ if (options.emitTests) {
200
+ printWarning(`--emit-tests skipped: safe/dry-run mode has no live verdicts to lock in as regression suites. Re-run with --live to emit ${options.emitTests}.`);
201
+ }
202
+ if (options.output) {
203
+ const payload = options.report === "json"
204
+ ? JSON.stringify(data, null, 2)
205
+ : formatDryRunDigest(plan);
206
+ await mkdir(join(options.output, "..").replace(/\/\.$/, ""), { recursive: true }).catch(() => {});
207
+ rotateOutputTarget(options.output, { overwrite: options.overwrite });
208
+ await writeFile(options.output, payload + "\n", "utf-8");
209
+ }
163
210
  if (options.json) {
164
211
  printJson(jsonOk("probe-mass-assignment", data));
165
212
  } else {
@@ -180,15 +227,54 @@ export async function probeMassAssignmentCommand(
180
227
  // is off — this banner is about the cleanup-pass, too.
181
228
  printMutationBanner("probe-mass-assignment", vars, { quiet: options.json === true });
182
229
 
183
- const result = await runMassAssignmentProbes({
184
- endpoints,
185
- securitySchemes,
186
- vars,
187
- noCleanup: options.noCleanup,
188
- timeoutMs: options.timeoutMs,
189
- discover: !options.noDiscover,
190
- extraSuspectFields: parseSuspectFieldFlags(options.suspectField),
191
- });
230
+ // ARV-265: capture HTTP touches for audit-coverage. Dry-run never
231
+ // reaches this branch (early return above), so AC#5 holds.
232
+ const { withHttpAudit, beginAuditRun, finalizeAuditRun, auditRecordToCase, checksPersistEnabled } =
233
+ await import("../../../core/audit/persist.ts");
234
+ const auditEnabled = checksPersistEnabled();
235
+ // ARV-269: lift agent-authored `seed_body` overlays out of
236
+ // `.api-resources.local.yaml` so create-endpoint baselines win against
237
+ // strict validators. No-op when --api isn't set (raw --spec invocation).
238
+ const seedBodies = await loadSeedBodyOverlays(options.apiName);
239
+ const { value: result, records: auditRecords } = await withHttpAudit(async () =>
240
+ runMassAssignmentProbes({
241
+ endpoints,
242
+ securitySchemes,
243
+ vars,
244
+ noCleanup: options.noCleanup,
245
+ timeoutMs: options.timeoutMs,
246
+ discover: !options.noDiscover,
247
+ extraSuspectFields: parseSuspectFieldFlags(options.suspectField),
248
+ seedBodies,
249
+ }),
250
+ );
251
+
252
+ if (auditEnabled && auditRecords.length > 0) {
253
+ try {
254
+ const { getDb } = await import("../../../db/schema.ts");
255
+ const { findCollectionByNameOrId } = await import("../../../db/queries.ts");
256
+ const { readCurrentSession } = await import("../../../core/context/session.ts");
257
+ getDb();
258
+ const collectionId = options.apiName ? findCollectionByNameOrId(options.apiName)?.id : undefined;
259
+ const session = readCurrentSession();
260
+ const runId = beginAuditRun({
261
+ runKind: "probe",
262
+ ...(collectionId != null ? { collectionId } : {}),
263
+ ...(session?.id ? { sessionId: session.id } : {}),
264
+ tags: ["probe", "mass-assignment"],
265
+ });
266
+ const suiteFile = `apis/${options.apiName ?? "_"}/probes/mass-assignment.yaml`;
267
+ finalizeAuditRun(runId, auditRecords.map((rec) =>
268
+ auditRecordToCase(rec, {
269
+ suiteName: "probe/mass-assignment",
270
+ suiteFile,
271
+ testName: `mass-assignment::${rec.request.method.toUpperCase()} ${rec.request.url}`,
272
+ }),
273
+ ));
274
+ } catch (err) {
275
+ process.stderr.write(`zond: audit persistence failed (${(err as Error).message}).\n`);
276
+ }
277
+ }
192
278
 
193
279
  // ARV-252: filter verdicts for display under the evidence-chain
194
280
  // principle. Silently-ignored (correct framework behaviour) never
@@ -246,7 +332,12 @@ export async function probeMassAssignmentCommand(
246
332
  by_status: maByStatus(structuredEndpoints),
247
333
  },
248
334
  orphans,
249
- warnings: result.warnings,
335
+ warnings: cappedEndpoints
336
+ ? [
337
+ ...result.warnings,
338
+ `--max-endpoints capped this run at ${options.maxEndpoints}; pass --max-endpoints <N> or --budget full to widen.`,
339
+ ]
340
+ : result.warnings,
250
341
  emittedTests: emittedSuites,
251
342
  }),
252
343
  );
@@ -20,6 +20,7 @@ import { persistVerdictsAsOrphans } from "../../../core/probe/orphan-tracker.ts"
20
20
  import { SecurityProbe } from "../../../core/probe/security-probe-class.ts";
21
21
  import { summarizeDryRun } from "../../../core/probe/dry-run-envelope.ts";
22
22
  import { compileOperationFilter } from "../../../core/selectors/operation-filter.ts";
23
+ import { loadSeedBodyOverlays } from "./_seed-bodies.ts";
23
24
 
24
25
  interface Buckets {
25
26
  high: number;
@@ -95,6 +96,11 @@ export interface ProbeSecurityOptions {
95
96
  * reflection — sanitization signal only). Hidden by default since
96
97
  * they carry single_signal proof with no exploit pathway. */
97
98
  verbose?: boolean;
99
+ /** ARV-302: cap the number of endpoints probed in this run (after
100
+ * --include / --exclude / --tag filters). Used by `zond audit
101
+ * --budget` to keep the security stage inside a wall-clock budget
102
+ * on big specs (Stripe: 587 endpoints) instead of unbounded scanning. */
103
+ maxEndpoints?: number;
98
104
  }
99
105
 
100
106
  function parseClasses(input: string): SecurityClass[] | string {
@@ -157,6 +163,21 @@ export async function probeSecurityCommand(
157
163
  }
158
164
  endpoints = endpoints.filter(compiled.filter);
159
165
  }
166
+ // ARV-302: --max-endpoints cap — see probe/mass-assignment.ts for the
167
+ // rationale. Applied after user-visible filters; a warning fires so
168
+ // partial output is obvious.
169
+ let cappedEndpoints = false;
170
+ if (typeof options.maxEndpoints === "number" && options.maxEndpoints > 0
171
+ && endpoints.length > options.maxEndpoints) {
172
+ const total = endpoints.length;
173
+ endpoints = endpoints.slice(0, options.maxEndpoints);
174
+ cappedEndpoints = true;
175
+ if (!options.json) {
176
+ process.stderr.write(
177
+ `zond: probe security capped at ${options.maxEndpoints}/${total} endpoints (--max-endpoints) — pass --max-endpoints <N> or --budget full to widen.\n`,
178
+ );
179
+ }
180
+ }
160
181
 
161
182
  let vars: Record<string, string> = {};
162
183
  if (options.env) {
@@ -196,10 +217,26 @@ export async function probeSecurityCommand(
196
217
  options: { isolated: options.isolated === true },
197
218
  });
198
219
  const data = summarizeDryRun(plan);
220
+ const { formatDryRunDigest } = await import("../../../core/probe/dry-run-envelope.ts");
221
+ // ARV-317: persist the planned inventory when --output is given (parity
222
+ // with probe mass-assignment). --emit-tests is skipped on dry-run —
223
+ // there are no findings to turn into regression suites.
224
+ // ARV-321: say so explicitly — see mass-assignment.ts for the friction
225
+ // that prompted this.
226
+ if (options.emitTests) {
227
+ printWarning(`--emit-tests skipped: safe/dry-run mode has no live verdicts to lock in as regression suites. Re-run with --live to emit ${options.emitTests}.`);
228
+ }
229
+ if (options.output) {
230
+ const payload = options.report === "json"
231
+ ? JSON.stringify(data, null, 2)
232
+ : formatDryRunDigest(plan);
233
+ await mkdir(join(options.output, "..").replace(/\/\.$/, ""), { recursive: true }).catch(() => {});
234
+ rotateOutputTarget(options.output, { overwrite: options.overwrite });
235
+ await writeFile(options.output, payload + "\n", "utf-8");
236
+ }
199
237
  if (options.json) {
200
238
  printJson(jsonOk("probe-security", data));
201
239
  } else {
202
- const { formatDryRunDigest } = await import("../../../core/probe/dry-run-envelope.ts");
203
240
  console.log(formatDryRunDigest(plan));
204
241
  }
205
242
  return 0;
@@ -210,17 +247,55 @@ export async function probeSecurityCommand(
210
247
  // travel in the envelope instead).
211
248
  printMutationBanner("probe-security", vars, { quiet: options.json === true });
212
249
 
213
- const result = await runSecurityProbes({
214
- endpoints,
215
- securitySchemes,
216
- vars,
217
- classes,
218
- noCleanup: options.noCleanup,
219
- timeoutMs: options.timeoutMs,
220
- dryRun: options.dryRun,
221
- isolated: options.isolated === true,
222
- allowLeaks: options.allowLeaks === true,
223
- });
250
+ // ARV-265: capture every HTTP request the probe sends so audit-coverage
251
+ // sees the touches. Dry-run never reaches this branch (early return
252
+ // above), so AC#5 holds — only live probes pollute the run table.
253
+ const { withHttpAudit, beginAuditRun, finalizeAuditRun, auditRecordToCase, checksPersistEnabled } =
254
+ await import("../../../core/audit/persist.ts");
255
+ const auditEnabled = checksPersistEnabled();
256
+ // ARV-269: same overlay-lift as probe-mass-assignment.
257
+ const seedBodies = await loadSeedBodyOverlays(options.apiName);
258
+ const { value: result, records: auditRecords } = await withHttpAudit(async () =>
259
+ runSecurityProbes({
260
+ endpoints,
261
+ securitySchemes,
262
+ vars,
263
+ classes,
264
+ noCleanup: options.noCleanup,
265
+ timeoutMs: options.timeoutMs,
266
+ dryRun: options.dryRun,
267
+ isolated: options.isolated === true,
268
+ allowLeaks: options.allowLeaks === true,
269
+ seedBodies,
270
+ }),
271
+ );
272
+
273
+ if (auditEnabled && auditRecords.length > 0) {
274
+ try {
275
+ const { getDb } = await import("../../../db/schema.ts");
276
+ const { findCollectionByNameOrId } = await import("../../../db/queries.ts");
277
+ const { readCurrentSession } = await import("../../../core/context/session.ts");
278
+ getDb();
279
+ const collectionId = options.apiName ? findCollectionByNameOrId(options.apiName)?.id : undefined;
280
+ const session = readCurrentSession();
281
+ const runId = beginAuditRun({
282
+ runKind: "probe",
283
+ ...(collectionId != null ? { collectionId } : {}),
284
+ ...(session?.id ? { sessionId: session.id } : {}),
285
+ tags: ["probe", "security", ...classes],
286
+ });
287
+ const suiteFile = `apis/${options.apiName ?? "_"}/probes/security.yaml`;
288
+ finalizeAuditRun(runId, auditRecords.map((rec) =>
289
+ auditRecordToCase(rec, {
290
+ suiteName: "probe/security",
291
+ suiteFile,
292
+ testName: `security::${rec.request.method.toUpperCase()} ${rec.request.url}`,
293
+ }),
294
+ ));
295
+ } catch (err) {
296
+ process.stderr.write(`zond: audit persistence failed (${(err as Error).message}).\n`);
297
+ }
298
+ }
224
299
 
225
300
  // ARV-253: filter verdicts for display under the evidence-chain
226
301
  // principle. INFO-severity findings (CRLF accepted, no reflection
@@ -328,6 +403,14 @@ export async function probeSecurityCommand(
328
403
  },
329
404
  orphans,
330
405
  emittedTests: emittedSuites,
406
+ // ARV-302: surface the partial-run warning so JSON consumers
407
+ // see that --max-endpoints trimmed the set (otherwise the cap
408
+ // is invisible in machine-readable output).
409
+ ...(cappedEndpoints ? {
410
+ warnings: [
411
+ `--max-endpoints capped this run at ${options.maxEndpoints}; pass --max-endpoints <N> or --budget full to widen.`,
412
+ ],
413
+ } : {}),
331
414
  }),
332
415
  );
333
416
  } else {
@@ -31,6 +31,8 @@ export interface ProbeWebhooksOptions {
31
31
  output?: string;
32
32
  /** Envelope mode — wraps result in {ok, command, data, errors}. */
33
33
  json?: boolean;
34
+ /** API name for audit-coverage attribution. */
35
+ apiName?: string;
34
36
  }
35
37
 
36
38
  function severityCount(findings: WebhookFinding[]): { high: number; low: number } {
@@ -26,7 +26,8 @@ import { getApi } from "../util/api-context.ts";
26
26
  import { existsSync } from "fs";
27
27
  import { join, dirname } from "node:path";
28
28
  import { parsePositiveInt } from "../argv.ts";
29
- import { printError } from "../output.ts";
29
+ import { printError, printWarning } from "../output.ts";
30
+ import { SAFE_HELP, LIVE_HELP, resolveLive } from "../safe-live.ts";
30
31
  import { jsonError, printJson } from "../json-envelope.ts";
31
32
  import { loadEnvMeta } from "../../core/parser/variables.ts";
32
33
  import { resolveTimeoutMs } from "../../core/workspace/config.ts";
@@ -190,16 +191,49 @@ function defineProbeStatic(parent: Command, name: string): void {
190
191
  .option("--max-per-endpoint <N>", "Cap negative-input probes per endpoint (default 50)", parsePositiveInt("--max-per-endpoint"))
191
192
  .option("--no-cleanup", "Skip emission of follow-up DELETE cleanup steps for mutating probes (use in namespace-isolated test envs)")
192
193
  .option("--use-synthetic-parents", "Bake synthetic-by-type values into all path params (legacy). By default, non-attacked path params are emitted as {{name}} and resolved from .env.yaml at run time — needed to reach the leaf validator on nested paths (TASK-135).")
193
- .option("--include <classes>", "Comma-separated subset of {validation, methods} (default: both)")
194
- .option("--exclude <classes>", "Comma-separated subset to skip (mutually exclusive with --include)")
194
+ // ARV-225: probe static --include is a CLASS LIST ({validation, methods}),
195
+ // while sibling commands (probe security, probe mass-assignment, checks
196
+ // run) use SELECTOR grammar (path:/method:/tag:/operation-id:). Rename the
197
+ // canonical flag to --include-class / --exclude-class for clarity; keep
198
+ // --include / --exclude as deprecated aliases (warn on use).
199
+ .option("--include-class <classes>", "Comma-separated subset of {validation, methods} (default: both)")
200
+ .option("--exclude-class <classes>", "Comma-separated subset to skip (mutually exclusive with --include-class)")
201
+ .option("--include <classes>", "[deprecated, use --include-class] Comma-separated subset of {validation, methods}")
202
+ .option("--exclude <classes>", "[deprecated, use --exclude-class] Comma-separated subset to skip")
203
+ // ARV-299: static only *generates* probe suites (it never sends live
204
+ // traffic), so it is always safe. The flags exist for vocabulary parity
205
+ // with the sibling subcommands; --live is a no-op here and says so.
206
+ .option("--safe", SAFE_HELP)
207
+ .option("--live", LIVE_HELP)
195
208
  .action(async (specPos: string | undefined, optsArg, cmdRef: Command) => {
209
+ if (optsArg.live === true) {
210
+ printWarning("probe static only generates suites and never sends live traffic — --safe/--live have no effect here. Run the emitted suites with `zond run … --live` to execute them.");
211
+ }
196
212
  // ARV-33: see resolveProbeApi — keep the chain consistent with the
197
213
  // sibling subcommands (mass-assignment, security).
198
214
  const apiName = resolveProbeApi(optsArg.api, cmdRef);
199
215
  const resolved = resolveSpecArg(specPos, apiName, optsArg.db);
200
216
  if ("error" in resolved) { printError(resolved.error); process.exitCode = 2; return; }
201
217
 
202
- const r = resolveStaticClasses(optsArg.include, optsArg.exclude);
218
+ // ARV-225: prefer --include-class / --exclude-class. Fall back to
219
+ // --include / --exclude with a one-line stderr deprecation note —
220
+ // these are the legacy names that collide semantically with the
221
+ // selector --include on sibling commands.
222
+ let includeClasses: string | undefined = optsArg.includeClass;
223
+ let excludeClasses: string | undefined = optsArg.excludeClass;
224
+ if (!includeClasses && optsArg.include) {
225
+ process.stderr.write(
226
+ "Warning: `probe static --include <classes>` is deprecated (class-list, not a selector — collides with probe security / checks run). Use --include-class.\n",
227
+ );
228
+ includeClasses = optsArg.include;
229
+ }
230
+ if (!excludeClasses && optsArg.exclude) {
231
+ process.stderr.write(
232
+ "Warning: `probe static --exclude <classes>` is deprecated. Use --exclude-class.\n",
233
+ );
234
+ excludeClasses = optsArg.exclude;
235
+ }
236
+ const r = resolveStaticClasses(includeClasses, excludeClasses);
203
237
  if ("error" in r) { printError(r.error); process.exitCode = 2; return; }
204
238
 
205
239
  // ARV-30: derive --output from the registered API's base_dir when the
@@ -245,7 +279,9 @@ function defineProbeMassAssignment(parent: Command, name: string): void {
245
279
  .option("--list-tags", "List available tags from spec and exit")
246
280
  .option("--no-cleanup", "Skip follow-up DELETE for resources accidentally created by 2xx probes")
247
281
  .option("--no-discover", "Disable auto-discovery of path-param fixtures via GET-on-list (TASK-92)")
248
- .option("--dry-run", "Print which endpoints/fields would be attacked without sending requests (m-17 ARV-52)")
282
+ .option("--dry-run", "Print which endpoints/fields would be attacked without sending requests (m-17 ARV-52). Equivalent to the default --safe mode.")
283
+ .option("--safe", SAFE_HELP)
284
+ .option("--live", LIVE_HELP)
249
285
  .option(
250
286
  "--include <selector>",
251
287
  "Filter operations (m-15 ARV-9 grammar: path:/users/.* | tag:Webhooks | method:POST,PATCH | operation-id:create.*). Repeatable.",
@@ -269,7 +305,8 @@ function defineProbeMassAssignment(parent: Command, name: string): void {
269
305
  (v: string, prev: string[] = []) => prev.concat(v),
270
306
  [] as string[],
271
307
  )
272
- .option("--emit-template <method:path>", "TASK-146: emit a ready-to-edit YAML probe template for one endpoint (e.g. \"POST:/users\") instead of running the live probe. Pairs `--output <file>` to write to disk (default: stdout). Use to drop down to manual catch-up after INCONCLUSIVE / INCONCLUSIVE-5XX verdicts without copy-pasting boilerplate from the skill.");
308
+ .option("--emit-template <method:path>", "TASK-146: emit a ready-to-edit YAML probe template for one endpoint (e.g. \"POST:/users\") instead of running the live probe. Pairs `--output <file>` to write to disk (default: stdout). Use to drop down to manual catch-up after INCONCLUSIVE / INCONCLUSIVE-5XX verdicts without copy-pasting boilerplate from the skill.")
309
+ .option("--max-endpoints <n>", "ARV-302: cap the number of endpoints probed in this run (after --include / --exclude / --tag filters). Used by `zond audit --budget` to keep probe stages inside a wall-clock budget instead of unbounded scanning.", parsePositiveInt("--max-endpoints"));
273
310
  addProbeReportOutputOptions(sub);
274
311
  sub.action(async (specPos: string | undefined, opts, cmd: Command) => {
275
312
  // ARV-33: resolve --api via the same fallback chain as prepare-fixtures /
@@ -291,11 +328,27 @@ function defineProbeMassAssignment(parent: Command, name: string): void {
291
328
  return;
292
329
  }
293
330
 
331
+ // ARV-299: safe (default) → plan only, no live mutating traffic. The
332
+ // proven --dry-run path IS the safe path, so we just force it on when
333
+ // --live is absent (and say so, unless the user already asked for a
334
+ // dry-run explicitly).
335
+ const live = resolveLive(opts);
336
+ const effectiveDryRun = opts.dryRun === true || !live;
337
+ // ARV-348: fail loudly (see probe security) — --emit-tests needs live
338
+ // verdicts; safe/dry-run mode would leave an empty dir + exit 0.
339
+ if (opts.emitTests && !live) {
340
+ printError("--emit-tests requires --live: safe/dry-run mode produces no live verdicts to lock into regression suites. Re-run with --live against a throwaway/sandbox account, or drop --emit-tests to just plan.");
341
+ process.exitCode = 2;
342
+ return;
343
+ }
344
+ if (!live && opts.dryRun !== true && !opts.listTags) {
345
+ printWarning("probe mass-assignment: safe mode (default) — planning only, no live attack traffic. Re-run with --live against a throwaway/sandbox account to send probes.");
346
+ }
294
347
  // m-17 / ARV-52 + ARV-58: dry-run and list-tags paths tolerate a
295
348
  // missing env file the way probe-security does — the user wants
296
349
  // to inspect the plan / available tags, not hit a live API.
297
350
  const envFile = resolveProbeEnv(opts.env, apiName, opts.db, {
298
- tolerateMissing: opts.dryRun === true || opts.listTags === true,
351
+ tolerateMissing: effectiveDryRun || opts.listTags === true,
299
352
  });
300
353
  if ("error" in envFile) { printError(envFile.error); process.exitCode = 2; return; }
301
354
  const timeoutMs = await resolveProbeTimeout(opts.timeout, apiName, envFile.env);
@@ -305,6 +358,7 @@ function defineProbeMassAssignment(parent: Command, name: string): void {
305
358
  process.exitCode = await probeMassAssignmentCommand({
306
359
  specPath: resolved.spec,
307
360
  env: envFile.env,
361
+ apiName,
308
362
  output: rep.output,
309
363
  emitTests: opts.emitTests,
310
364
  tag: opts.tag,
@@ -314,12 +368,13 @@ function defineProbeMassAssignment(parent: Command, name: string): void {
314
368
  timeoutMs,
315
369
  overwrite: opts.overwrite === true,
316
370
  json,
317
- dryRun: opts.dryRun === true,
371
+ dryRun: effectiveDryRun,
318
372
  include: Array.isArray(opts.include) && opts.include.length > 0 ? opts.include : undefined,
319
373
  exclude: Array.isArray(opts.exclude) && opts.exclude.length > 0 ? opts.exclude : undefined,
320
374
  report: rep.report,
321
375
  verbose: opts.verbose === true,
322
376
  suspectField: Array.isArray(opts.suspectField) && opts.suspectField.length > 0 ? opts.suspectField : undefined,
377
+ maxEndpoints: typeof opts.maxEndpoints === "number" ? opts.maxEndpoints : undefined,
323
378
  });
324
379
  });
325
380
  }
@@ -355,12 +410,15 @@ function defineProbeSecurity(parent: Command, name: string): void {
355
410
  (v: string, prev: string[] = []) => prev.concat(v),
356
411
  [] as string[],
357
412
  )
358
- .option("--dry-run", "Print which endpoints/fields would be attacked without sending requests")
413
+ .option("--dry-run", "Print which endpoints/fields would be attacked without sending requests. Equivalent to the default --safe mode.")
414
+ .option("--safe", SAFE_HELP)
415
+ .option("--live", LIVE_HELP)
359
416
  .option("--timeout <ms>", "Per-request timeout in ms (overrides apis/<name>/.env.yaml `timeoutMs` and zond.config.yml `defaults.timeout_ms`; default 30000)", parsePositiveInt("--timeout"))
360
417
  .option(
361
418
  "--verbose",
362
419
  "ARV-253: surface INFO-severity findings (sanitization-signal-only, e.g. CRLF accepted but no reflection observed). Default hides them — they're single-signal proof with no exploit pathway.",
363
- );
420
+ )
421
+ .option("--max-endpoints <n>", "ARV-302: cap the number of endpoints probed in this run (after --include / --exclude / --tag filters). Used by `zond audit --budget` to keep probe stages inside a wall-clock budget instead of unbounded scanning.", parsePositiveInt("--max-endpoints"));
364
422
  addProbeReportOutputOptions(sub);
365
423
  sub.action(async (classes: string | undefined, specPos: string | undefined, opts, cmd: Command) => {
366
424
  // ARV-36: missing-arg path should list the available classes (parity
@@ -378,6 +436,22 @@ function defineProbeSecurity(parent: Command, name: string): void {
378
436
  const apiName = resolveProbeApi(opts.api, cmd);
379
437
  const resolved = resolveSpecArg(specPos, apiName, opts.db);
380
438
  if ("error" in resolved) { printError(resolved.error); process.exitCode = 2; return; }
439
+ // ARV-299: safe (default) forces plan-only; live attack traffic needs --live.
440
+ const live = resolveLive(opts);
441
+ const effectiveDryRun = opts.dryRun === true || !live;
442
+ // ARV-348: fail loudly instead of silently producing an empty dir. Safe/
443
+ // dry-run mode has no live verdicts to lock into regression suites, so
444
+ // --emit-tests can't do anything — and a later `zond run <dir>` on the
445
+ // empty dir just prints "No test files found". One consistent message
446
+ // (—live), not the old contradictory "Re-run without --dry-run".
447
+ if (opts.emitTests && !live) {
448
+ printError("--emit-tests requires --live: safe/dry-run mode produces no live verdicts to lock into regression suites. Re-run with --live against a throwaway/sandbox account, or drop --emit-tests to just plan.");
449
+ process.exitCode = 2;
450
+ return;
451
+ }
452
+ if (!live && opts.dryRun !== true && !opts.listTags) {
453
+ printWarning("probe security: safe mode (default) — planning only, no live attack traffic. Re-run with --live against a throwaway/sandbox account to send probes.");
454
+ }
381
455
  // probe-security tolerates a missing env (--dry-run path), so don't
382
456
  // fail when --api is given but the env file isn't on disk yet.
383
457
  const envFile = resolveProbeEnv(opts.env, apiName, opts.db, { tolerateMissing: true });
@@ -395,7 +469,7 @@ function defineProbeSecurity(parent: Command, name: string): void {
395
469
  tag: opts.tag,
396
470
  listTags: opts.listTags,
397
471
  noCleanup: opts.cleanup === false,
398
- dryRun: opts.dryRun === true,
472
+ dryRun: effectiveDryRun,
399
473
  timeoutMs,
400
474
  overwrite: opts.overwrite === true,
401
475
  json,
@@ -406,6 +480,7 @@ function defineProbeSecurity(parent: Command, name: string): void {
406
480
  include: Array.isArray(opts.include) && opts.include.length > 0 ? opts.include : undefined,
407
481
  exclude: Array.isArray(opts.exclude) && opts.exclude.length > 0 ? opts.exclude : undefined,
408
482
  verbose: opts.verbose === true,
483
+ maxEndpoints: typeof opts.maxEndpoints === "number" ? opts.maxEndpoints : undefined,
409
484
  });
410
485
  });
411
486
  }
@@ -443,6 +518,7 @@ function defineProbeWebhooks(parent: Command, name: string): void {
443
518
  report: rep.report,
444
519
  output: rep.output,
445
520
  json,
521
+ apiName,
446
522
  });
447
523
  });
448
524
  }