@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
@@ -4,8 +4,8 @@
4
4
  * Оборачивает 8-10 ручных шагов (prepare-fixtures → generate → probes
5
5
  * → session-wrapped run → coverage → HTML report) в одну команду:
6
6
  *
7
- * 1. `prepare-fixtures --apply` (или `--cascade --seed --apply` при
8
- * `--seed`) заполняет `.env.yaml` FK-идентификаторами.
7
+ * 1. `prepare-fixtures --apply` single-pass, заполняет `.env.yaml`
8
+ * FK-идентификаторами, что нашлись детерминированно.
9
9
  * 2. `generate` — пропускается если `apis/<name>/tests/` свежее, чем
10
10
  * `spec.json` (mtime-эвристика; `--force` отключает skip).
11
11
  * 3. `probe static` (validation+methods, всегда). `mass-assignment` и
@@ -23,16 +23,19 @@
23
23
 
24
24
  import { existsSync, statSync } from "node:fs";
25
25
  import { writeFile } from "node:fs/promises";
26
- import { join } from "node:path";
26
+ import { join, resolve } from "node:path";
27
27
  import type { Command } from "commander";
28
28
  import { globalJson } from "../resolve.ts";
29
29
  import { getDb } from "../../db/schema.ts";
30
- import { findCollectionByNameOrId } from "../../db/queries.ts";
30
+ import { findCollectionByNameOrId, listSessions, listRunsBySession } from "../../db/queries.ts";
31
31
  import { resolveCollectionSpec } from "../../core/setup-api.ts";
32
32
  import { printSuccess, printWarning, printError } from "../output.ts";
33
33
  import { getApi, MISSING_API_MESSAGE } from "../util/api-context.ts";
34
34
  import { jsonOk, printJson } from "../json-envelope.ts";
35
35
  import { VERSION } from "../version.ts";
36
+ import { diagnoseRun, type DiagnoseResult } from "../../core/diagnostics/db-analysis.ts";
37
+ import { readCurrentSession } from "../../core/context/session.ts";
38
+ import { resolveBudget, isBudget, BUDGETS, type Budget } from "../../core/checks/budget.ts";
36
39
 
37
40
  interface Stage {
38
41
  key: string;
@@ -54,13 +57,19 @@ interface StageResult {
54
57
  export interface AuditOptions {
55
58
  api: string;
56
59
  dbPath?: string;
57
- seed?: boolean;
58
60
  withMassAssignment?: boolean;
59
61
  withSecurity?: boolean;
60
62
  out?: string;
61
63
  dryRun?: boolean;
62
64
  force?: boolean;
63
65
  json?: boolean;
66
+ /** ARV-264: opt-in to the full pipeline against a real-traffic API.
67
+ * When false (default), audit runs in safe mode — no mass-assignment /
68
+ * security probes, no destructive probes. */
69
+ live?: boolean;
70
+ /** ARV-292: adaptive cap and stateful gating tier. Translates to
71
+ * `--max-requests N` on every spawned `run` stage. */
72
+ budget?: Budget;
64
73
  }
65
74
 
66
75
  /**
@@ -79,20 +88,34 @@ function zondInvoker(): string[] {
79
88
  function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null): Stage[] {
80
89
  const api = opts.api;
81
90
  const stages: Stage[] = [];
82
-
83
- if (opts.seed) {
84
- stages.push({
85
- key: "prepare-fixtures-cascade",
86
- name: "prepare-fixtures (cascade discover + seed)",
87
- args: ["prepare-fixtures", "--api", api, "--apply", "--seed"],
88
- });
89
- } else {
90
- stages.push({
91
- key: "prepare-fixtures",
92
- name: "prepare-fixtures (path-FK fixtures)",
93
- args: ["prepare-fixtures", "--api", api, "--apply"],
94
- });
95
- }
91
+ const budgetResolved = resolveBudget(opts.budget, undefined);
92
+ const runMaxRequestsArgs: string[] = budgetResolved.maxRequests !== undefined
93
+ ? ["--max-requests", String(budgetResolved.maxRequests)]
94
+ // placeholder to keep the binding shape stable
95
+ : [];
96
+ // ARV-302: probes don't share `zond run`'s --max-requests vocabulary
97
+ // (probes scan endpoint-by-endpoint, not request-by-request). Map the
98
+ // budget tier to a coarse `--max-endpoints` cap so probe stages stay
99
+ // inside the same wall-clock budget. `full` keeps the legacy
100
+ // uncapped behaviour.
101
+ const probeMaxEndpointsByTier: Record<string, number | undefined> = {
102
+ quick: 10,
103
+ standard: 50,
104
+ full: undefined,
105
+ };
106
+ const probeMaxEndpoints = opts.budget ? probeMaxEndpointsByTier[opts.budget] : undefined;
107
+ const probeMaxEndpointsArgs: string[] = probeMaxEndpoints !== undefined
108
+ ? ["--max-endpoints", String(probeMaxEndpoints)]
109
+ : [];
110
+
111
+ // ARV-336: prep is always a single-pass `prepare-fixtures --apply`. It
112
+ // fills what discover resolves deterministically and reports the rest as
113
+ // gaps for the agent/user to fill by hand — no autonomous seed/cascade.
114
+ stages.push({
115
+ key: "prepare-fixtures",
116
+ name: "prepare-fixtures (path-FK fixtures)",
117
+ args: ["prepare-fixtures", "--api", api, "--apply"],
118
+ });
96
119
 
97
120
  stages.push({
98
121
  key: "generate",
@@ -120,7 +143,11 @@ function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null
120
143
  args: ["probe", "static", "--api", api, "--output", join(apiDir, "probes", "static")],
121
144
  });
122
145
 
123
- if (opts.withMassAssignment) {
146
+ // ARV-264: mass-assignment + security probes send real POST/PUT/DELETE
147
+ // traffic. Gate them behind --live so the default (safe) audit can't
148
+ // pollute a production API.
149
+ const liveProbesEnabled = opts.live === true;
150
+ if (opts.withMassAssignment && liveProbesEnabled) {
124
151
  stages.push({
125
152
  key: "probe-mass-assignment",
126
153
  name: "probe mass-assignment",
@@ -129,10 +156,11 @@ function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null
129
156
  "--output", join(apiDir, "probes", "mass-assignment-digest.md"),
130
157
  "--emit-tests", join(apiDir, "probes", "mass-assignment"),
131
158
  "--overwrite",
159
+ ...probeMaxEndpointsArgs,
132
160
  ],
133
161
  });
134
162
  }
135
- if (opts.withSecurity) {
163
+ if (opts.withSecurity && liveProbesEnabled) {
136
164
  stages.push({
137
165
  key: "probe-security",
138
166
  name: "probe security (ssrf,crlf,open-redirect)",
@@ -141,15 +169,39 @@ function buildStages(opts: AuditOptions, apiDir: string, specPath: string | null
141
169
  "--output", join(apiDir, "probes", "security-digest.md"),
142
170
  "--emit-tests", join(apiDir, "probes", "security"),
143
171
  "--overwrite",
172
+ ...probeMaxEndpointsArgs,
144
173
  ],
145
174
  });
146
175
  }
147
176
 
177
+ // ARV-65: if the user already has an active session, REUSE it — don't
178
+ // clobber their .zond/current-session by spawning audit's own. Skipping
179
+ // session-end is the critical half: otherwise audit clears the user's
180
+ // session even when start was a no-op.
181
+ const existingSession = readCurrentSession();
148
182
  const sessionLabel = `audit-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
149
- stages.push({ key: "session-start", name: `session start (${sessionLabel})`, args: ["session", "start", "--label", sessionLabel] });
150
- stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api] });
151
- stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api] });
152
- stages.push({ key: "session-end", name: "session end", args: ["session", "end"] });
183
+ if (existingSession) {
184
+ const reuseReason = `reusing active session ${existingSession.id}${existingSession.label ? ` (${existingSession.label})` : ""}`;
185
+ stages.push({
186
+ key: "session-start",
187
+ name: "session start (reused)",
188
+ args: ["session", "start", "--label", sessionLabel],
189
+ skip: () => reuseReason,
190
+ });
191
+ stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api, ...runMaxRequestsArgs] });
192
+ stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api, ...runMaxRequestsArgs] });
193
+ stages.push({
194
+ key: "session-end",
195
+ name: "session end (reused — kept active)",
196
+ args: ["session", "end"],
197
+ skip: () => reuseReason,
198
+ });
199
+ } else {
200
+ stages.push({ key: "session-start", name: `session start (${sessionLabel})`, args: ["session", "start", "--label", sessionLabel] });
201
+ stages.push({ key: "run-tests", name: "run tests", args: ["run", join(apiDir, "tests"), "--api", api, ...runMaxRequestsArgs] });
202
+ stages.push({ key: "run-probes", name: "run probes", args: ["run", join(apiDir, "probes"), "--api", api, ...runMaxRequestsArgs] });
203
+ stages.push({ key: "session-end", name: "session end", args: ["session", "end"] });
204
+ }
153
205
  // ARV-108: surface the post-stage coverage capture in the plan so the
154
206
  // dry-run listing matches the actual pipeline. The stage is special-cased
155
207
  // in auditCommand — we keep stdout for JSON parsing rather than inheriting.
@@ -174,13 +226,15 @@ async function runStage(stage: Stage, idx: number, total: number, json: boolean)
174
226
  const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
175
227
  const code = await proc.exited;
176
228
  const ms = Date.now() - t0;
177
- return {
178
- key: stage.key,
179
- name: stage.name,
180
- status: code === 0 ? "ok" : "failed",
181
- exit_code: code,
182
- duration_ms: ms,
183
- };
229
+ const status: StageResult["status"] = code === 0 ? "ok" : "failed";
230
+ // ARV-66: print a per-stage completion line so the user sees OK/FAIL inline
231
+ // next to "Stage N/M" instead of inferring from the final summary. Subprocess
232
+ // stdio is inherited above, so this line lands AFTER the stage's own output.
233
+ if (!json) {
234
+ const tag = status === "ok" ? "OK" : `FAIL (exit ${code})`;
235
+ console.log(` └─ ${tag} · ${(ms / 1000).toFixed(1)}s`);
236
+ }
237
+ return { key: stage.key, name: stage.name, status, exit_code: code, duration_ms: ms };
184
238
  }
185
239
 
186
240
  interface CoverageCapture {
@@ -190,22 +244,63 @@ interface CoverageCapture {
190
244
  durationMs: number;
191
245
  }
192
246
 
193
- async function captureCoverage(api: string): Promise<CoverageCapture> {
247
+ /**
248
+ * ARV-301: build the coverage-stage CLI args. Exported so unit tests
249
+ * can assert that audit pins `--session-id <id>` rather than
250
+ * `--union session` whenever a session id was captured ahead of
251
+ * `session end` (the latter selector rejects closed sessions).
252
+ */
253
+ export function buildCoverageStageArgs(api: string, sessionId?: string): string[] {
254
+ const sel = sessionId ? ["--session-id", sessionId] : ["--union", "session"];
255
+ return ["coverage", "--api", api, ...sel, "--json"];
256
+ }
257
+
258
+ /**
259
+ * ARV-301 follow-up: judge audit's coverage stage on the JSON envelope's
260
+ * `ok` field, not the subprocess exit code.
261
+ *
262
+ * `zond coverage` exits 1 when there are uncovered endpoints (legacy
263
+ * CI-gate behaviour) — that is a normal report state for audit's
264
+ * purposes, the envelope is well-formed and carries valid coverage
265
+ * data. Only exit 2 / non-JSON output / `ok:false` envelopes mean a
266
+ * real coverage failure.
267
+ *
268
+ * Exported pure function so unit tests can lock the contract without
269
+ * spawning a subprocess.
270
+ */
271
+ export function interpretCoverageOutput(
272
+ stdout: string,
273
+ exitCode: number | null,
274
+ ): { data: unknown | null; parseError: string | null } {
275
+ let parsed: unknown = null;
276
+ let parseError: string | null = null;
277
+ try {
278
+ parsed = stdout.trim() ? JSON.parse(stdout) : null;
279
+ } catch (e) {
280
+ parseError = (e as Error).message;
281
+ }
282
+ if (parsed && typeof parsed === "object" && (parsed as { ok?: boolean }).ok === true) {
283
+ // Envelope says everything is fine; treat coverage as captured
284
+ // even if exit was 1 due to "has uncovered endpoints".
285
+ return { data: parsed, parseError: null };
286
+ }
287
+ // ok:false, or no envelope at all — propagate the failure signal.
288
+ // exitCode is intentionally not consulted here; callers attach it
289
+ // to the stage result for the user.
290
+ void exitCode;
291
+ return { data: null, parseError };
292
+ }
293
+
294
+ async function captureCoverage(api: string, sessionId?: string): Promise<CoverageCapture> {
194
295
  const t0 = Date.now();
195
296
  try {
196
- const cmd = [...zondInvoker(), "coverage", "--api", api, "--union", "session", "--json"];
297
+ const cmd = [...zondInvoker(), ...buildCoverageStageArgs(api, sessionId)];
197
298
  const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
198
299
  const stdout = await new Response(proc.stdout).text();
199
300
  const code = await proc.exited;
200
301
  const ms = Date.now() - t0;
201
- if (code !== 0) {
202
- return { data: null, exitCode: code, parseError: null, durationMs: ms };
203
- }
204
- try {
205
- return { data: JSON.parse(stdout), exitCode: code, parseError: null, durationMs: ms };
206
- } catch (e) {
207
- return { data: null, exitCode: code, parseError: (e as Error).message, durationMs: ms };
208
- }
302
+ const { data, parseError } = interpretCoverageOutput(stdout, code);
303
+ return { data, exitCode: code, parseError, durationMs: ms };
209
304
  } catch (e) {
210
305
  return { data: null, exitCode: null, parseError: (e as Error).message, durationMs: Date.now() - t0 };
211
306
  }
@@ -231,6 +326,13 @@ export async function auditCommand(options: AuditOptions): Promise<number> {
231
326
  if (existsSync(guess)) specPath = guess;
232
327
  }
233
328
 
329
+ // ARV-264: surface what safe mode silently drops and run pre-flight
330
+ // checks (auth + security schemes) before any subprocess fires.
331
+ const preflightWarnings = runSafePreflight(options, specPath, apiDir);
332
+ if (!options.json) {
333
+ for (const w of preflightWarnings) printWarning(w);
334
+ }
335
+
234
336
  const stages = buildStages(options, apiDir, specPath);
235
337
  const out = options.out ?? "audit-report.html";
236
338
 
@@ -255,12 +357,19 @@ export async function auditCommand(options: AuditOptions): Promise<number> {
255
357
  const results: StageResult[] = [];
256
358
  let coverageJson: unknown = null;
257
359
  let coverageCapture: CoverageCapture | null = null;
360
+ // ARV-301: capture the active session id BEFORE session-end runs, so
361
+ // coverage can target it explicitly (--session-id) instead of via
362
+ // --union session, which would reject the now-closed session.
363
+ let pinnedSessionId: string | undefined;
258
364
  for (let i = 0; i < stages.length; i++) {
259
365
  const stage = stages[i]!;
366
+ if (stage.key === "session-end" && !pinnedSessionId) {
367
+ pinnedSessionId = readCurrentSession()?.id;
368
+ }
260
369
  if (stage.key === "coverage") {
261
370
  // ARV-108: coverage runs via captureCoverage so we keep stdout JSON.
262
371
  if (!options.json) console.log(`==> Stage ${i + 1}/${stages.length}: ${stage.name}`);
263
- coverageCapture = await captureCoverage(options.api);
372
+ coverageCapture = await captureCoverage(options.api, pinnedSessionId);
264
373
  coverageJson = coverageCapture.data;
265
374
  const status: StageResult["status"] = coverageCapture.data
266
375
  ? "ok"
@@ -283,12 +392,48 @@ export async function auditCommand(options: AuditOptions): Promise<number> {
283
392
  ? `coverage exited ${coverageCapture.exitCode}`
284
393
  : undefined,
285
394
  });
395
+ // ARV-66: per-stage completion line for the coverage special-case too.
396
+ if (!options.json) {
397
+ const tag = status === "ok" ? "OK" : status === "skipped" ? "SKIPPED" : `FAIL (exit ${coverageCapture.exitCode})`;
398
+ console.log(` └─ ${tag} · ${(coverageCapture.durationMs / 1000).toFixed(1)}s`);
399
+ }
286
400
  continue;
287
401
  }
288
402
  results.push(await runStage(stage, i + 1, stages.length, options.json === true));
289
403
  }
290
404
  const totalMs = Date.now() - t0;
291
405
 
406
+ // ARV-158: collect per-run drill-down from the audit session so the HTML
407
+ // can answer "WHICH 271 findings" instead of just "3 failed stages".
408
+ // The session was started by `session-start` and closed by `session-end`,
409
+ // so it's the most recent session in the DB at this point. listSessions(1)
410
+ // surfaces it; we then diagnose each run with failures > 0 (passed-only
411
+ // runs need no triage).
412
+ const drilldown: Array<{ run: { id: number; failed: number; total: number; passed: number }; diagnose: DiagnoseResult }> = [];
413
+ try {
414
+ const recent = listSessions(1);
415
+ const session = recent[0];
416
+ if (session?.session_id) {
417
+ const runs = listRunsBySession(session.session_id);
418
+ for (const r of runs) {
419
+ if (r.failed <= 0) continue;
420
+ try {
421
+ const diag = diagnoseRun(r.id, false, options.dbPath);
422
+ drilldown.push({
423
+ run: { id: r.id, failed: r.failed, total: r.total, passed: r.passed },
424
+ diagnose: diag,
425
+ });
426
+ } catch {
427
+ // diagnose of a single run failing should not break the report —
428
+ // skip and keep the rest.
429
+ }
430
+ }
431
+ }
432
+ } catch {
433
+ // DB unreachable / no sessions — leave drilldown empty; HTML degrades
434
+ // to the previous summary-only form.
435
+ }
436
+
292
437
  await writeAuditReport(out, {
293
438
  api: options.api,
294
439
  apiDir,
@@ -297,6 +442,7 @@ export async function auditCommand(options: AuditOptions): Promise<number> {
297
442
  coverage: coverageJson,
298
443
  coverageStage: results.find((r) => r.key === "coverage") ?? null,
299
444
  options,
445
+ drilldown,
300
446
  });
301
447
 
302
448
  // ARV-108: coverage is informational — keep it out of the fail count so we
@@ -315,7 +461,10 @@ export async function auditCommand(options: AuditOptions): Promise<number> {
315
461
  }));
316
462
  } else {
317
463
  console.log("");
318
- const summary = `Audit complete (${results.length} stages, ${(totalMs / 1000).toFixed(1)}s) ${out}`;
464
+ // ARV-80: print absolute path so the user can open the HTML report
465
+ // without traversing — `audit-report.html` (relative) hid the location
466
+ // behind cwd, especially when audit was invoked from a parent dir.
467
+ const summary = `Audit complete (${results.length} stages, ${(totalMs / 1000).toFixed(1)}s) → ${resolve(out)}`;
319
468
  if (failed === 0) {
320
469
  printSuccess(summary);
321
470
  } else {
@@ -325,7 +474,7 @@ export async function auditCommand(options: AuditOptions): Promise<number> {
325
474
  return failed === 0 ? 0 : 1;
326
475
  }
327
476
 
328
- interface ReportInput {
477
+ export interface ReportInput {
329
478
  api: string;
330
479
  apiDir: string;
331
480
  stages: StageResult[];
@@ -335,6 +484,15 @@ interface ReportInput {
335
484
  * distinguish "no session runs" from "coverage subcommand failed". */
336
485
  coverageStage: StageResult | null;
337
486
  options: AuditOptions;
487
+ /** ARV-158: per-run diagnose envelopes for runs in this audit's session
488
+ * that had failures. Each entry feeds a collapsible drill-down block
489
+ * in the HTML so the report answers "WHICH findings" inline. Empty
490
+ * array when no runs failed or when DB lookup couldn't reach the
491
+ * session. */
492
+ drilldown: Array<{
493
+ run: { id: number; failed: number; total: number; passed: number };
494
+ diagnose: DiagnoseResult;
495
+ }>;
338
496
  }
339
497
 
340
498
  function escapeHtml(s: string): string {
@@ -354,7 +512,10 @@ interface CoverageEnvelope {
354
512
  };
355
513
  }
356
514
 
357
- async function writeAuditReport(outPath: string, data: ReportInput): Promise<void> {
515
+ /** ARV-158: exported for regression-test on HTML markup (drill-down
516
+ * sections per failed run). Same signature as before — the export
517
+ * doesn't expose anything CLI-internal. */
518
+ export async function writeAuditReport(outPath: string, data: ReportInput): Promise<void> {
358
519
  const cov = data.coverage as CoverageEnvelope | null;
359
520
  const totals = cov?.data?.totals;
360
521
  const pass = cov?.data?.pass_coverage?.ratio;
@@ -367,7 +528,6 @@ async function writeAuditReport(outPath: string, data: ReportInput): Promise<voi
367
528
  }).join("\n");
368
529
 
369
530
  const reruncmd = `zond audit --api ${data.api}`
370
- + (data.options.seed ? " --seed" : "")
371
531
  + (data.options.withMassAssignment ? " --with-mass-assignment" : "")
372
532
  + (data.options.withSecurity ? " --with-security" : "");
373
533
 
@@ -383,6 +543,59 @@ async function writeAuditReport(outPath: string, data: ReportInput): Promise<voi
383
543
  : `Coverage stage ${covStage.status} (exit ${covStage.exit_code ?? "?"}).`
384
544
  : "Coverage stage was not part of this audit (older binary?).";
385
545
 
546
+ // ARV-158: per-run drill-down — collapsible sections, one per failed
547
+ // run, surfacing by_recommended_action buckets (count + first example).
548
+ // Concrete commands replace the previous "type <run-id> manually" hint.
549
+ const drilldownBlocks = data.drilldown.length === 0
550
+ ? ""
551
+ : `<h2>Failures by run</h2>\n` + data.drilldown.map(({ run, diagnose }) => {
552
+ const buckets = diagnose.by_recommended_action ?? {};
553
+ // Sort buckets by priority (report_backend_bug first, then by count)
554
+ // — matches the zond-triage skill priority order.
555
+ const priorityOrder = [
556
+ "report_backend_bug",
557
+ "fix_spec",
558
+ "fix_auth_config",
559
+ "fix_env",
560
+ "fix_fixture",
561
+ "fix_network_config",
562
+ "regenerate_suite",
563
+ "tighten_validation",
564
+ "add_required_header",
565
+ "fix_test_logic",
566
+ "wontfix_known_limitation",
567
+ ];
568
+ const sortedKeys = Object.keys(buckets).sort((a, b) => {
569
+ const ai = priorityOrder.indexOf(a);
570
+ const bi = priorityOrder.indexOf(b);
571
+ if (ai === -1 && bi === -1) return (buckets[b]!.count) - (buckets[a]!.count);
572
+ if (ai === -1) return 1;
573
+ if (bi === -1) return -1;
574
+ return ai - bi;
575
+ });
576
+ const bucketsHtml = sortedKeys.length === 0
577
+ ? `<p class="muted">No <code>recommended_action</code> buckets — see raw failures via the commands below.</p>`
578
+ : `<ul class="buckets">` + sortedKeys.map((key) => {
579
+ const b = buckets[key]!;
580
+ const first = b.examples[0];
581
+ const sample = first
582
+ ? `<code>${escapeHtml(first.method)} ${escapeHtml(first.path)}</code> → <strong>${first.status}</strong>${first.reason ? ` — ${escapeHtml(first.reason)}` : ""}`
583
+ : "";
584
+ const moreExamples = b.examples.length > 1
585
+ ? ` <span class="muted">(+${b.examples.length - 1} more)</span>`
586
+ : "";
587
+ return `<li><strong>${escapeHtml(key)}</strong> ×${b.count}${sample ? ` — ${sample}${moreExamples}` : ""}</li>`;
588
+ }).join("\n") + `</ul>`;
589
+ return `<details>
590
+ <summary>Run #${run.id} — ${run.failed}/${run.total} failed (${run.passed} passed)</summary>
591
+ ${bucketsHtml}
592
+ <p class="cmds">
593
+ Drill in: <code>zond db diagnose --run-id ${run.id} --json</code> ·
594
+ <code>zond report export ${run.id}</code>
595
+ </p>
596
+ </details>`;
597
+ }).join("\n");
598
+
386
599
  const coverageBlock = totals
387
600
  ? `<h2>Coverage (session union)</h2>
388
601
  <div class="cov">
@@ -413,6 +626,13 @@ tr.skip td:nth-child(2) { color: #888; font-style: italic; }
413
626
  .warn { background: #fef9e7; padding: 8px 12px; border-left: 3px solid #f0c040; margin: 1em 0; }
414
627
  code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
415
628
  ul { line-height: 1.6; }
629
+ details { margin: 0.8em 0; border: 1px solid #eee; border-radius: 4px; padding: 0.3em 0.8em; }
630
+ details summary { cursor: pointer; font-weight: 600; padding: 0.3em 0; }
631
+ details[open] summary { border-bottom: 1px solid #eee; margin-bottom: 0.6em; }
632
+ ul.buckets { padding-left: 1.2em; margin: 0.4em 0; }
633
+ ul.buckets li { margin: 0.25em 0; }
634
+ p.cmds { font-size: 0.88em; color: #555; margin-top: 0.8em; }
635
+ .muted { color: #888; }
416
636
  </style></head>
417
637
  <body>
418
638
  <h1>zond audit — ${escapeHtml(data.api)}</h1>
@@ -427,21 +647,93 @@ ${stageRows}
427
647
 
428
648
  ${coverageBlock}
429
649
 
650
+ ${drilldownBlocks}
651
+
430
652
  <h2>Drill-down</h2>
431
653
  <ul>
432
- <li>Per-run HTML: <code>zond report export &lt;run-id&gt;</code></li>
433
- <li>Diagnose failures: <code>zond db diagnose &lt;run-id&gt; --json</code></li>
434
654
  <li>Re-run audit: <code>${escapeHtml(reruncmd)}</code></li>
655
+ <li>List recent runs: <code>zond db runs --limit 20</code></li>
656
+ <li>Per-run report: <code>zond report export &lt;run-id&gt;</code></li>
435
657
  </ul>
436
658
  </body></html>`;
437
659
 
438
660
  await writeFile(outPath, html, "utf-8");
439
661
  }
440
662
 
663
+ /**
664
+ * ARV-264: pre-flight checks for safe-mode auditing.
665
+ *
666
+ * 1. Warn when the user passed `--seed` / `--with-mass-assignment` /
667
+ * `--with-security` without `--live` — those stages are silently
668
+ * dropped in safe mode and the user deserves to know.
669
+ * 2. Warn when the spec declares `components.securitySchemes` but the
670
+ * workspace has no `auth_token` set — every probe will skip with
671
+ * 401 and the report will read as "all green" misleadingly.
672
+ * 3. Warn when no security schemes are declared at all (open API or
673
+ * incomplete spec) — same misleading-green risk on auth gates.
674
+ */
675
+ export function runSafePreflight(
676
+ opts: AuditOptions,
677
+ specPath: string | null,
678
+ apiDir: string,
679
+ ): string[] {
680
+ const out: string[] = [];
681
+ const safe = opts.live !== true;
682
+
683
+ if (safe) {
684
+ if (opts.withMassAssignment) {
685
+ out.push(
686
+ "audit --safe (default): --with-mass-assignment ignored. Mass-assignment probes POST mutated " +
687
+ "bodies and follow up with GET/DELETE — high blast radius. Re-run with --live to enable.",
688
+ );
689
+ }
690
+ if (opts.withSecurity) {
691
+ out.push(
692
+ "audit --safe (default): --with-security ignored. Security probes send live SSRF/CRLF/redirect " +
693
+ "payloads to real endpoints. Re-run with --live to enable.",
694
+ );
695
+ }
696
+ }
697
+
698
+ // Spec-level checks (cheap synchronous read; spec.json is JSON).
699
+ if (specPath && existsSync(specPath)) {
700
+ try {
701
+ const spec = JSON.parse(require("node:fs").readFileSync(specPath, "utf-8")) as {
702
+ components?: { securitySchemes?: Record<string, unknown> };
703
+ };
704
+ const hasSchemes = Boolean(
705
+ spec.components?.securitySchemes && Object.keys(spec.components.securitySchemes).length > 0,
706
+ );
707
+ const envPath = join(apiDir, ".env.yaml");
708
+ let authTokenSet = false;
709
+ if (existsSync(envPath)) {
710
+ const env = require("node:fs").readFileSync(envPath, "utf-8") as string;
711
+ // Match `auth_token: ...` lines whose value isn't an unfilled placeholder.
712
+ authTokenSet = /^\s*auth_token\s*:\s*(?!["']?\s*(<|UNSET|TODO|REPLACE|null|~)\b)\S/m.test(env);
713
+ }
714
+ if (hasSchemes && !authTokenSet) {
715
+ out.push(
716
+ "spec declares securitySchemes but auth_token is unset in .env.yaml — probes will likely hit " +
717
+ "401/403 and the report can read misleadingly green. Run `zond doctor --api " + opts.api + "` to fix.",
718
+ );
719
+ } else if (!hasSchemes) {
720
+ out.push(
721
+ "spec declares no securitySchemes — either the API is open or the spec is incomplete. " +
722
+ "Auth-related checks (ignored_auth, open_cors_on_sensitive) will skip; verify before sharing the report.",
723
+ );
724
+ }
725
+ } catch {
726
+ // Spec unreadable — separate failure mode, surfaced by other stages.
727
+ }
728
+ }
729
+
730
+ return out;
731
+ }
732
+
441
733
  export function registerAudit(program: Command): void {
442
734
  program
443
735
  .command("audit")
444
- .description("Macro: prepare-fixtures → generate → probes → run → coverage → HTML report (TASK-262)")
736
+ .description("Smoke + breadth-coverage macro: prepare-fixtures → generate → probe static session-wrapped run → coverage → HTML report. For depth-checks / security probes / stateful invariants, drive the `zond` skill — `audit` is the breadth pass, depth is the skill's job.")
445
737
  // ARV-29: not `requiredOption` — same regression that hit prepare-fixtures
446
738
  // (TASK-20) and checks run (TASK-17). Commander routes `--api` to the
447
739
  // program-level option, so the subcommand's opts.api ends up undefined and
@@ -450,12 +742,16 @@ export function registerAudit(program: Command): void {
450
742
  // mirror > .zond/current-api.
451
743
  .option("--api <name>", "Registered API to audit. Falls back to ZOND_API / .zond/current-api.")
452
744
  .option("--db <path>", "Path to SQLite database file")
453
- .option("--seed", "Use 'prepare-fixtures --cascade --seed --apply' instead of the plain single-pass prep stage")
454
- .option("--with-mass-assignment", "Include 'probe mass-assignment' as an extra stage")
455
- .option("--with-security", "Include 'probe security ssrf,crlf,open-redirect' as an extra stage")
745
+ .option("--with-mass-assignment", "Include 'probe mass-assignment' as an extra stage. Requires --live.")
746
+ .option("--with-security", "Include 'probe security ssrf,crlf,open-redirect' as an extra stage. Requires --live.")
747
+ .option("--live", "ARV-264: opt into the full pipeline against a real-traffic API. Without this flag, audit runs in safe mode: no mass-assignment / security probes, no destructive traffic. Use only against throwaway/sandbox accounts.")
456
748
  .option("--out <path>", "HTML report output path (default: audit-report.html)")
457
749
  .option("--dry-run", "Print the stage plan without executing anything")
458
750
  .option("--force", "Disable mtime-based skip (always regenerate, even if tests/ newer than spec)")
751
+ .option(
752
+ "--budget <tier>",
753
+ "ARV-292: adaptive request cap for spawned `run` stages. `quick` (50 req, ~60-sec gate), `standard` (500 req), `full` (uncapped). Omitted ⇒ legacy uncapped pipeline.",
754
+ )
459
755
  .action(async (opts, cmd: Command) => {
460
756
  // ARV-53.
461
757
  const apiName = getApi(cmd, opts);
@@ -465,16 +761,26 @@ export function registerAudit(program: Command): void {
465
761
  return;
466
762
  }
467
763
  opts.api = apiName;
764
+ let budget: Budget | undefined;
765
+ if (opts.budget !== undefined) {
766
+ if (!isBudget(opts.budget)) {
767
+ printError(`--budget must be one of: ${BUDGETS.join(", ")}; got '${opts.budget}'`);
768
+ process.exitCode = 2;
769
+ return;
770
+ }
771
+ budget = opts.budget;
772
+ }
468
773
  process.exitCode = await auditCommand({
469
774
  api: opts.api,
470
775
  dbPath: opts.db,
471
- seed: opts.seed === true,
472
776
  withMassAssignment: opts.withMassAssignment === true,
473
777
  withSecurity: opts.withSecurity === true,
474
778
  out: opts.out,
475
779
  dryRun: opts.dryRun === true,
476
780
  force: opts.force === true,
477
781
  json: globalJson(cmd),
782
+ live: opts.live === true,
783
+ budget,
478
784
  });
479
785
  });
480
786
  }
@@ -303,6 +303,10 @@ function defineCheckSpec(parent: Command, name: string): void {
303
303
  .option("--api <name>", "Use the registered API's spec (apis/<name>/spec.json)")
304
304
  .option("--strict", "Exit non-zero even on LOW-severity issues")
305
305
  .option("--ndjson", "Stream issues as one JSON per line (NDJSON), instead of the wrapped envelope")
306
+ .option(
307
+ "--report <format>",
308
+ "ARV-204: output format alias for parity with `checks run`. Accepts: console (default human summary), json (equivalent to --json envelope), ndjson (equivalent to --ndjson). Last writer wins if combined with --json/--ndjson.",
309
+ )
306
310
  .option(
307
311
  "--rule <list>",
308
312
  "Unified rule selector (TASK-291). Comma-separated items: 'B1' (whitelist), " +
@@ -329,10 +333,19 @@ function defineCheckSpec(parent: Command, name: string): void {
329
333
 
330
334
  const merged = mergeRuleFlags(opts.rule, opts.filterRule);
331
335
 
336
+ // ARV-204: --report ndjson|json|console aliases the existing booleans
337
+ // so scripts can keep one flag shape across `checks run` and `check spec`.
338
+ let report = typeof opts.report === "string" ? opts.report.toLowerCase() : undefined;
339
+ if (report && !["console", "json", "ndjson"].includes(report)) {
340
+ printError(`--report: unknown format '${opts.report}'. Use one of: console, json, ndjson.`);
341
+ process.exitCode = 2;
342
+ return;
343
+ }
344
+
332
345
  process.exitCode = await checkSpecCommand({
333
346
  specPath: resolved.spec,
334
- json: globalJson(cmd),
335
- ndjson: opts.ndjson === true,
347
+ json: globalJson(cmd) || report === "json",
348
+ ndjson: opts.ndjson === true || report === "ndjson",
336
349
  strict: opts.strict === true,
337
350
  rule: merged.cliRule,
338
351
  config: opts.config,