@apifuse/provider-sdk 2.2.0-beta.2 → 2.2.0-beta.21

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 (291) hide show
  1. package/AUTHORING.md +487 -0
  2. package/CHANGELOG.md +90 -0
  3. package/README.md +49 -3
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +14 -0
  9. package/bin/apifuse-pack-smoke.ts +36 -81
  10. package/bin/apifuse-pack-types.ts +305 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +659 -111
  13. package/bin/apifuse-submit-check.ts +546 -48
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth.d.ts +2 -2
  21. package/dist/auth.js +9 -18
  22. package/dist/ceremonies/index.d.ts +9 -1
  23. package/dist/ceremonies/index.js +65 -18
  24. package/dist/cli/commands.d.ts +1 -1
  25. package/dist/cli/commands.js +8 -0
  26. package/dist/cli/create.d.ts +3 -0
  27. package/dist/cli/create.js +34 -35
  28. package/dist/cli/prompt-assets.d.ts +80 -0
  29. package/dist/cli/prompt-assets.js +743 -0
  30. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  31. package/dist/config/loader.d.ts +176 -8
  32. package/dist/config/loader.js +424 -95
  33. package/dist/contract-serialization.d.ts +2 -2
  34. package/dist/contract-serialization.js +3 -6
  35. package/dist/contract-types.d.ts +2 -2
  36. package/dist/contract.d.ts +3 -3
  37. package/dist/contract.js +4 -6
  38. package/dist/define.d.ts +9 -1
  39. package/dist/define.js +304 -119
  40. package/dist/dev.d.ts +1 -1
  41. package/dist/dev.js +1 -1
  42. package/dist/error-resolution.d.ts +2 -0
  43. package/dist/error-resolution.js +90 -0
  44. package/dist/errors.d.ts +22 -1
  45. package/dist/errors.js +88 -0
  46. package/dist/fixture-sanitization.d.ts +26 -0
  47. package/dist/fixture-sanitization.js +216 -0
  48. package/dist/i18n/catalog.d.ts +2 -2
  49. package/dist/i18n/catalog.js +4 -10
  50. package/dist/i18n/index.d.ts +2 -2
  51. package/dist/i18n/index.js +2 -2
  52. package/dist/i18n/keys.d.ts +2 -2
  53. package/dist/index.d.ts +46 -42
  54. package/dist/index.js +41 -37
  55. package/dist/lint.d.ts +1 -1
  56. package/dist/lint.js +8 -15
  57. package/dist/native-address.d.ts +43 -0
  58. package/dist/native-address.js +281 -0
  59. package/dist/native-egress-policy.d.ts +31 -0
  60. package/dist/native-egress-policy.js +288 -0
  61. package/dist/observability.d.ts +5 -2
  62. package/dist/observability.js +48 -1
  63. package/dist/provider.d.ts +12 -11
  64. package/dist/provider.js +10 -9
  65. package/dist/public-schema-field-lint.d.ts +1 -1
  66. package/dist/recipes/gov-api.js +1 -1
  67. package/dist/runtime/auth-flow.d.ts +2 -1
  68. package/dist/runtime/auth-flow.js +4 -3
  69. package/dist/runtime/browser.d.ts +1 -1
  70. package/dist/runtime/browser.js +15 -29
  71. package/dist/runtime/cache.d.ts +1 -1
  72. package/dist/runtime/cache.js +4 -8
  73. package/dist/runtime/choice.d.ts +1 -1
  74. package/dist/runtime/choice.js +31 -35
  75. package/dist/runtime/credential.d.ts +1 -1
  76. package/dist/runtime/credential.js +1 -1
  77. package/dist/runtime/env.d.ts +1 -1
  78. package/dist/runtime/executor.d.ts +1 -1
  79. package/dist/runtime/executor.js +31 -4
  80. package/dist/runtime/http.d.ts +2 -2
  81. package/dist/runtime/http.js +387 -47
  82. package/dist/runtime/insights.d.ts +1 -1
  83. package/dist/runtime/insights.js +6 -13
  84. package/dist/runtime/instrumentation.d.ts +2 -2
  85. package/dist/runtime/instrumentation.js +345 -22
  86. package/dist/runtime/keyring.js +1 -1
  87. package/dist/runtime/namespace.js +1 -1
  88. package/dist/runtime/native-network.d.ts +127 -0
  89. package/dist/runtime/native-network.js +1298 -0
  90. package/dist/runtime/otlp.d.ts +1 -1
  91. package/dist/runtime/perf.d.ts +1 -1
  92. package/dist/runtime/provider.d.ts +1 -1
  93. package/dist/runtime/provider.js +1 -2
  94. package/dist/runtime/proxy-errors.d.ts +1 -1
  95. package/dist/runtime/proxy-errors.js +9 -7
  96. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  97. package/dist/runtime/proxy-nodemaven.js +146 -0
  98. package/dist/runtime/proxy-retry-policy.d.ts +2 -2
  99. package/dist/runtime/proxy-retry-policy.js +2 -2
  100. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  101. package/dist/runtime/proxy-telemetry.js +55 -52
  102. package/dist/runtime/redirects.d.ts +29 -0
  103. package/dist/runtime/redirects.js +36 -0
  104. package/dist/runtime/redis.d.ts +1 -1
  105. package/dist/runtime/redis.js +2 -4
  106. package/dist/runtime/request-options.d.ts +68 -1
  107. package/dist/runtime/request-options.js +548 -0
  108. package/dist/runtime/secrets.d.ts +27 -0
  109. package/dist/runtime/secrets.js +51 -0
  110. package/dist/runtime/state.d.ts +2 -2
  111. package/dist/runtime/state.js +238 -26
  112. package/dist/runtime/stealth.d.ts +5 -3
  113. package/dist/runtime/stealth.js +423 -143
  114. package/dist/runtime/stt.d.ts +1 -1
  115. package/dist/runtime/stt.js +11 -15
  116. package/dist/runtime/trace.d.ts +2 -2
  117. package/dist/runtime/trace.js +2 -4
  118. package/dist/runtime/waterfall.d.ts +1 -1
  119. package/dist/schema.d.ts +1 -1
  120. package/dist/schema.js +7 -15
  121. package/dist/serve.d.ts +1 -1
  122. package/dist/serve.js +1 -1
  123. package/dist/server/index.d.ts +7 -7
  124. package/dist/server/index.js +6 -6
  125. package/dist/server/self-test-input-tokens.d.ts +2 -1
  126. package/dist/server/self-test-input-tokens.js +18 -14
  127. package/dist/server/self-test-redaction.d.ts +1 -1
  128. package/dist/server/self-test-redaction.js +1 -1
  129. package/dist/server/self-test.d.ts +104 -3
  130. package/dist/server/self-test.js +673 -115
  131. package/dist/server/serve.d.ts +116 -4
  132. package/dist/server/serve.js +799 -128
  133. package/dist/server/types.d.ts +34 -9
  134. package/dist/server/types.js +8 -1
  135. package/dist/stateful/errors.d.ts +14 -0
  136. package/dist/stateful/errors.js +14 -0
  137. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  138. package/dist/stateful/http-provider-event-emitter.js +237 -0
  139. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  140. package/dist/stateful/http-session-owner-registry.js +210 -0
  141. package/dist/stateful/index.d.ts +18 -0
  142. package/dist/stateful/index.js +18 -0
  143. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  144. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  145. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  146. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  147. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  148. package/dist/stateful/provider-event-pipeline.js +1 -0
  149. package/dist/stateful/provider-events.d.ts +101 -0
  150. package/dist/stateful/provider-events.js +289 -0
  151. package/dist/stateful/session-key.d.ts +15 -0
  152. package/dist/stateful/session-key.js +86 -0
  153. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  154. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  155. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  156. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  157. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  158. package/dist/stateful/stateful-provider-adapter.js +287 -0
  159. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  160. package/dist/stateful/stateful-provider-observability.js +161 -0
  161. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  162. package/dist/stateful/stateful-provider-owner-forwarder.js +215 -0
  163. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  164. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  165. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  166. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  167. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  168. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  169. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  170. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  171. package/dist/stateful-signing.d.ts +18 -0
  172. package/dist/stateful-signing.js +27 -0
  173. package/dist/stealth/profiles.d.ts +1 -1
  174. package/dist/stealth/profiles.js +5 -14
  175. package/dist/stream-evidence.d.ts +74 -0
  176. package/dist/stream-evidence.js +785 -0
  177. package/dist/stream.d.ts +1 -1
  178. package/dist/testing/index.d.ts +2 -2
  179. package/dist/testing/index.js +2 -2
  180. package/dist/testing/run.d.ts +32 -2
  181. package/dist/testing/run.js +478 -28
  182. package/dist/types.d.ts +342 -14
  183. package/dist/types.js +1 -0
  184. package/dist/user-input.d.ts +30 -0
  185. package/dist/user-input.js +66 -0
  186. package/package.json +16 -5
  187. package/src/auth-turn/index.ts +1 -1
  188. package/src/auth.ts +28 -86
  189. package/src/ceremonies/index.ts +103 -78
  190. package/src/cli/commands.ts +10 -0
  191. package/src/cli/create.ts +42 -35
  192. package/src/cli/prompt-assets.ts +865 -0
  193. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  194. package/src/config/loader.ts +652 -204
  195. package/src/contract-serialization.ts +5 -11
  196. package/src/contract-types.ts +2 -2
  197. package/src/contract.ts +12 -28
  198. package/src/define.ts +499 -498
  199. package/src/dev.ts +4 -9
  200. package/src/error-resolution.ts +91 -0
  201. package/src/errors.ts +110 -5
  202. package/src/fixture-sanitization.ts +247 -0
  203. package/src/i18n/catalog.ts +10 -32
  204. package/src/i18n/index.ts +2 -2
  205. package/src/i18n/keys.ts +5 -11
  206. package/src/index.ts +112 -42
  207. package/src/lint.ts +88 -152
  208. package/src/native-address.ts +340 -0
  209. package/src/native-egress-policy.ts +358 -0
  210. package/src/observability.ts +51 -1
  211. package/src/provider.ts +66 -11
  212. package/src/public-schema-field-lint.ts +7 -33
  213. package/src/recipes/gov-api.ts +2 -5
  214. package/src/runtime/auth-flow.ts +7 -7
  215. package/src/runtime/browser.ts +64 -187
  216. package/src/runtime/cache.ts +20 -67
  217. package/src/runtime/choice.ts +79 -132
  218. package/src/runtime/credential.ts +2 -2
  219. package/src/runtime/env.ts +1 -1
  220. package/src/runtime/executor.ts +43 -20
  221. package/src/runtime/http.ts +494 -57
  222. package/src/runtime/insights.ts +15 -53
  223. package/src/runtime/instrumentation.ts +495 -66
  224. package/src/runtime/keyring.ts +7 -19
  225. package/src/runtime/namespace.ts +2 -7
  226. package/src/runtime/native-network.ts +1686 -0
  227. package/src/runtime/otlp.ts +12 -23
  228. package/src/runtime/perf.ts +1 -1
  229. package/src/runtime/provider.ts +4 -9
  230. package/src/runtime/proxy-errors.ts +29 -42
  231. package/src/runtime/proxy-nodemaven.ts +221 -0
  232. package/src/runtime/proxy-retry-policy.ts +3 -3
  233. package/src/runtime/proxy-telemetry.ts +79 -77
  234. package/src/runtime/redirects.ts +66 -0
  235. package/src/runtime/redis.ts +4 -12
  236. package/src/runtime/request-options.ts +679 -9
  237. package/src/runtime/secrets.ts +64 -0
  238. package/src/runtime/state.ts +353 -133
  239. package/src/runtime/stealth.ts +505 -154
  240. package/src/runtime/stt.ts +38 -94
  241. package/src/runtime/trace.ts +14 -44
  242. package/src/runtime/waterfall.ts +5 -18
  243. package/src/schema.ts +23 -84
  244. package/src/serve.ts +1 -1
  245. package/src/server/index.ts +29 -7
  246. package/src/server/self-test-input-tokens.ts +29 -14
  247. package/src/server/self-test-redaction.ts +2 -2
  248. package/src/server/self-test.ts +857 -132
  249. package/src/server/serve.ts +1151 -328
  250. package/src/server/types.ts +12 -13
  251. package/src/stateful/README.md +146 -0
  252. package/src/stateful/errors.ts +23 -0
  253. package/src/stateful/http-provider-event-emitter.ts +314 -0
  254. package/src/stateful/http-session-owner-registry.ts +306 -0
  255. package/src/stateful/index.ts +18 -0
  256. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  257. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  258. package/src/stateful/provider-event-pipeline.ts +61 -0
  259. package/src/stateful/provider-events.ts +462 -0
  260. package/src/stateful/session-key.ts +111 -0
  261. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  262. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  263. package/src/stateful/stateful-provider-adapter.ts +562 -0
  264. package/src/stateful/stateful-provider-observability.ts +261 -0
  265. package/src/stateful/stateful-provider-owner-forwarder.ts +287 -0
  266. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  267. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  268. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  269. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  270. package/src/stateful-signing.ts +46 -0
  271. package/src/stealth/profiles.ts +10 -26
  272. package/src/stream-evidence.ts +988 -0
  273. package/src/stream.ts +8 -19
  274. package/src/testing/index.ts +10 -2
  275. package/src/testing/run.ts +653 -74
  276. package/src/types.ts +408 -28
  277. package/src/user-input.ts +118 -0
  278. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  279. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  280. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  281. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  282. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  283. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  284. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  285. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  286. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  287. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  288. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  289. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  290. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  291. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -11,15 +11,22 @@ import * as acorn from "acorn";
11
11
  import { z } from "zod";
12
12
 
13
13
  import packageJson from "../package.json";
14
- import type { ProviderDefinition } from "../src";
14
+ import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
15
15
  import {
16
16
  loadProviderLocaleCatalogs,
17
17
  type ProviderLocale,
18
18
  validateProviderLocaleCatalogs,
19
- } from "../src/i18n";
20
- import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema";
21
- import { safeParseSchemaSync } from "../src/schema";
22
- import { type CheckResult, runChecks } from "./apifuse-check";
19
+ } from "../src/i18n/index.js";
20
+ import type { ProviderDefinition } from "../src/index.js";
21
+ import { APIFUSE_DESCRIPTION_KEY_META_KEY, safeParseSchemaSync } from "../src/schema.js";
22
+ import {
23
+ findStreamCaptureGroup,
24
+ hasStreamEvidenceMarker,
25
+ parseStreamEvidenceRecord,
26
+ } from "../src/stream-evidence.js";
27
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
28
+ import { hasSubstantiveDelimitedTextStructure } from "./submit-check-delimited-text.js";
29
+ import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
23
30
 
24
31
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
25
32
  const TIER_VALUES: ReadonlySet<string> = new Set(TIERS);
@@ -266,7 +273,15 @@ export async function buildSubmitCheckReport(
266
273
  const baseChecks = await safeRunChecks(providerRoot);
267
274
  const provider = await safeLoadProvider(providerRoot);
268
275
 
269
- checks.push(...scoreBaseChecks(baseChecks));
276
+ // Prompt-asset freshness is reported by its own dedicated zero-point
277
+ // blocker below; filter the base-check duplicate so it is not double
278
+ // penalized under the definition category.
279
+ checks.push(
280
+ ...scoreBaseChecks(
281
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
282
+ ),
283
+ );
284
+ checks.push(scorePromptAssetFreshness(providerRoot));
270
285
  checks.push(scoreProviderIdSlug(providerRoot, provider));
271
286
  checks.push(scoreNoVendorShim(providerRoot));
272
287
  checks.push(scoreNoVendorImport(providerRoot));
@@ -282,6 +297,7 @@ export async function buildSubmitCheckReport(
282
297
  if (provider) {
283
298
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
284
299
  checks.push(scoreCredentialUsage(providerRoot, provider));
300
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
285
301
  checks.push(scoreLocaleCatalog(providerRoot, provider));
286
302
  checks.push(scoreOperationMetadata(provider));
287
303
  checks.push(scoreFixtureCoverage(provider));
@@ -1423,6 +1439,159 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1423
1439
  );
1424
1440
  }
1425
1441
 
1442
+ // ---------------------------------------------------------------------------
1443
+ // sdk-owned-secret-presence (warn): provider-local double validation of
1444
+ // declared env secrets.
1445
+ //
1446
+ // The SDK runtime is the single source of truth for secret presence: declared
1447
+ // `required: true` secrets are validated before every handler/auth-flow
1448
+ // invocation and fail with the canonical structured MISSING_SECRET error
1449
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
1450
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
1451
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
1452
+ // with/without category), which broke uniform incident attribution when nine
1453
+ // providers shipped with unprovisioned secrets (2026-07-22).
1454
+ //
1455
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
1456
+ // `.env.get(...)` (string literal or a const alias of a declared name)
1457
+ // followed within a small window by a falsy presence check plus a `throw`.
1458
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
1459
+ // declared in defineProvider secrets[], and optional declarations
1460
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
1461
+ // are out of scope. Escape hatch:
1462
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
1463
+ // ---------------------------------------------------------------------------
1464
+
1465
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
1466
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
1467
+
1468
+ const ENV_GET_CALL_PATTERN =
1469
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
1470
+
1471
+ const SECRET_ALIAS_CONST_PATTERN =
1472
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
1473
+
1474
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
1475
+
1476
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
1477
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
1478
+ // guards are detected, not just direct string literals.
1479
+ function buildDeclaredSecretAliasMap(
1480
+ providerRoot: string,
1481
+ declaredNames: ReadonlySet<string>,
1482
+ ): Map<string, string> {
1483
+ const aliases = new Map<string, string>();
1484
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1485
+ const content = readFileSync(filePath, "utf8");
1486
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
1487
+ const alias = match[1];
1488
+ const name = match[2] ?? match[3] ?? match[4];
1489
+ if (alias && name && declaredNames.has(name)) {
1490
+ aliases.set(alias, name);
1491
+ }
1492
+ }
1493
+ }
1494
+ return aliases;
1495
+ }
1496
+
1497
+ function hasLocalSecretPresenceGuard(
1498
+ line: string,
1499
+ remainingLines: readonly string[],
1500
+ declaredNames: ReadonlySet<string>,
1501
+ aliases: ReadonlyMap<string, string>,
1502
+ ): boolean {
1503
+ const match = ENV_GET_CALL_PATTERN.exec(line);
1504
+ if (!match) {
1505
+ return false;
1506
+ }
1507
+ const literal = match[1] ?? match[2] ?? match[3];
1508
+ const identifier = match[4];
1509
+ const readsDeclaredSecret =
1510
+ literal !== undefined
1511
+ ? declaredNames.has(literal)
1512
+ : identifier !== undefined && aliases.has(identifier);
1513
+ if (!readsDeclaredSecret) {
1514
+ return false;
1515
+ }
1516
+
1517
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
1518
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
1519
+ return false;
1520
+ }
1521
+
1522
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
1523
+ // check on THAT variable counts as a presence guard. Anchoring on the
1524
+ // assigned identifier avoids false positives from unrelated guards/throws
1525
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
1526
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
1527
+ if (assigned) {
1528
+ const escaped = assigned.replace(/\$/g, "\\$");
1529
+ const guardPattern = new RegExp(
1530
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
1531
+ );
1532
+ return window.some((candidate) => guardPattern.test(candidate));
1533
+ }
1534
+
1535
+ // Un-assigned read: only an inline presence check on the same line counts,
1536
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
1537
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
1538
+ }
1539
+
1540
+ function scoreSdkOwnedSecretPresence(
1541
+ providerRoot: string,
1542
+ provider: ProviderDefinition,
1543
+ ): SubmitCheck {
1544
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
1545
+ // Only `required: true` declarations: those are exactly what the runtime
1546
+ // gate enforces. A presence guard over an optional secret is conditional
1547
+ // business logic the SDK will not replace, not double validation.
1548
+ const declaredNames: ReadonlySet<string> = new Set(
1549
+ (provider.secrets ?? [])
1550
+ .filter((secret) => secret.required === true)
1551
+ .map((secret) => secret.name),
1552
+ );
1553
+ if (declaredNames.size === 0) {
1554
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1555
+ }
1556
+
1557
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
1558
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
1559
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
1560
+ );
1561
+ if (findings.length === 0) {
1562
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1563
+ }
1564
+
1565
+ const { violations, overridden } = partitionAllowOverrides(
1566
+ providerRoot,
1567
+ findings,
1568
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1569
+ );
1570
+ if (violations.length === 0) {
1571
+ return pass(
1572
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1573
+ SDK_NATIVE_CATEGORY,
1574
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
1575
+ 0,
1576
+ formatSourceFindings(overridden),
1577
+ );
1578
+ }
1579
+
1580
+ return {
1581
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1582
+ category: SDK_NATIVE_CATEGORY,
1583
+ level: "warn",
1584
+ status: "warn",
1585
+ points: 0,
1586
+ maxPoints: 0,
1587
+ message:
1588
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
1589
+ remediation:
1590
+ "The provider SDK validates declared required secrets before handlers and auth flows run and returns the canonical MISSING_SECRET error (HTTP 400, category credential_unavailable). Declare the secret with required: true in defineProvider({ secrets: [...] }), delete the provider-local presence guard (requireServiceKey/requireApiKey style), and read the value directly with ctx.env.get(); the guard is dead weight and its divergent CONFIGURATION_ERROR-style shape is deprecated. Acknowledge intentional exceptions with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.",
1591
+ evidence: formatSourceFindings(violations),
1592
+ };
1593
+ }
1594
+
1426
1595
  function findSourceLineMatches(
1427
1596
  providerRoot: string,
1428
1597
  pattern: RegExp | ((line: string) => boolean),
@@ -1522,6 +1691,10 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1522
1691
  );
1523
1692
  }
1524
1693
 
1694
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1695
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1696
+ // directories must not become a scan-exempt hiding place for secrets, raw
1697
+ // fetch, or vendor imports.
1525
1698
  function shouldScanSourceDirectory(relativePath: string): boolean {
1526
1699
  return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1527
1700
  }
@@ -1544,6 +1717,27 @@ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1544
1717
  return findings.map((finding) => `${finding.file}:${finding.line}`);
1545
1718
  }
1546
1719
 
1720
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
1721
+ const verification = verifyPromptAssets(providerRoot);
1722
+ if (verification.ok) {
1723
+ return pass(
1724
+ "prompt-assets-fresh",
1725
+ "docs",
1726
+ "SDK-managed agent prompt assets match the installed SDK version.",
1727
+ 0,
1728
+ );
1729
+ }
1730
+
1731
+ return blocker(
1732
+ "prompt-assets-fresh",
1733
+ "docs",
1734
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
1735
+ "Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and .apifuse/prompt-assets.json for the installed SDK version.",
1736
+ 0,
1737
+ formatPromptAssetIssues(verification),
1738
+ );
1739
+ }
1740
+
1547
1741
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1548
1742
  const missing: string[] = [];
1549
1743
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
@@ -1979,17 +2173,19 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
1979
2173
 
1980
2174
  const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
1981
2175
 
1982
- function scoreFixtureProvenance(
1983
- providerRoot: string,
1984
- provider: ProviderDefinition,
1985
- ): SubmitCheck {
2176
+ function scoreFixtureProvenance(providerRoot: string, provider: ProviderDefinition): SubmitCheck {
1986
2177
  const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
1987
2178
  let hasRecordedEvidence = false;
2179
+ let fixtureValidationError: string | undefined;
1988
2180
  if (existsSync(rawPath)) {
1989
2181
  try {
1990
- hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
1991
- } catch {
2182
+ hasRecordedEvidence = recordedFixtureStats(
2183
+ JSON.parse(readFileSync(rawPath, "utf8")),
2184
+ 0,
2185
+ ).hasNestedSubstance;
2186
+ } catch (error) {
1992
2187
  hasRecordedEvidence = false;
2188
+ fixtureValidationError = error instanceof Error ? error.message : String(error);
1993
2189
  }
1994
2190
  }
1995
2191
 
@@ -2001,6 +2197,16 @@ function scoreFixtureProvenance(
2001
2197
  0,
2002
2198
  );
2003
2199
  }
2200
+ if (fixtureValidationError) {
2201
+ return blocker(
2202
+ "fixture-provenance",
2203
+ "fixtures",
2204
+ `Malformed recorded fixture evidence in __fixtures__/raw.json: ${fixtureValidationError}`,
2205
+ "Re-run `bun run record` to replace the malformed stream evidence, or repair the named field using the stream evidence contract.",
2206
+ 0,
2207
+ ["__fixtures__/raw.json"],
2208
+ );
2209
+ }
2004
2210
 
2005
2211
  if (allOperationsAreGeneratedLocalScaffold(provider)) {
2006
2212
  return {
@@ -2028,14 +2234,32 @@ function scoreFixtureProvenance(
2028
2234
  );
2029
2235
  }
2030
2236
 
2031
- function hasNonEmptyRecordedFixture(value: unknown): boolean {
2032
- return recordedFixtureStats(value, 0).hasNestedSubstance;
2237
+ export function hasNonEmptyRecordedFixture(value: unknown): boolean {
2238
+ try {
2239
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
2240
+ } catch {
2241
+ return false;
2242
+ }
2033
2243
  }
2034
2244
 
2035
2245
  function recordedFixtureStats(
2036
2246
  value: unknown,
2037
2247
  depth: number,
2038
2248
  ): { hasNestedSubstance: boolean; leafValues: number } {
2249
+ if (
2250
+ value !== null &&
2251
+ typeof value === "object" &&
2252
+ !Array.isArray(value) &&
2253
+ (value as Record<string, unknown>).__apifuse_capture__ === true
2254
+ ) {
2255
+ const group = findStreamCaptureGroup(value);
2256
+ if (!group) throw new Error("Stream capture envelope is invalid.");
2257
+ return { hasNestedSubstance: true, leafValues: group.items.length };
2258
+ }
2259
+ if (hasStreamEvidenceMarker(value)) {
2260
+ parseStreamEvidenceRecord(value);
2261
+ return { hasNestedSubstance: true, leafValues: 1 };
2262
+ }
2039
2263
  if (value === null || value === undefined) {
2040
2264
  return { hasNestedSubstance: false, leafValues: 0 };
2041
2265
  }
@@ -2066,8 +2290,18 @@ function recordedFixtureStats(
2066
2290
  leafValues,
2067
2291
  };
2068
2292
  }
2069
- if (typeof value === "string" && value.length === 0) {
2070
- return { hasNestedSubstance: false, leafValues: 0 };
2293
+ if (typeof value === "string") {
2294
+ if (value.length === 0) {
2295
+ return { hasNestedSubstance: false, leafValues: 0 };
2296
+ }
2297
+ // A recorded operation value may be a raw XML or delimited-text success
2298
+ // payload. Treat a substantive one as nested evidence while still counting
2299
+ // the string as a leaf so existing JSON provenance heuristics are unchanged.
2300
+ return {
2301
+ hasNestedSubstance:
2302
+ hasSubstantiveXmlStructure(value) || hasSubstantiveDelimitedTextStructure(value),
2303
+ leafValues: 1,
2304
+ };
2071
2305
  }
2072
2306
  return { hasNestedSubstance: false, leafValues: 1 };
2073
2307
  }
@@ -2083,12 +2317,17 @@ function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): b
2083
2317
  }
2084
2318
 
2085
2319
  function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
2086
- return escapeHatchResult(providerRoot, "vendor-key-leak", findVendorKeyLeakFindings(providerRoot), {
2087
- blockerMessage: "Public schema keys leak raw vendor field names.",
2088
- remediation:
2089
- "Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
2090
- passMessage: "No vendor field-name leaks detected in public schemas.",
2091
- });
2320
+ return escapeHatchResult(
2321
+ providerRoot,
2322
+ "vendor-key-leak",
2323
+ findVendorKeyLeakFindings(providerRoot),
2324
+ {
2325
+ blockerMessage: "Public schema keys leak raw vendor field names.",
2326
+ remediation:
2327
+ "Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
2328
+ passMessage: "No vendor field-name leaks detected in public schemas.",
2329
+ },
2330
+ );
2092
2331
  }
2093
2332
 
2094
2333
  function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
@@ -2191,26 +2430,57 @@ function vendorKeyFindingsForObject(
2191
2430
  const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
2192
2431
  const digitFamilies = new Map<string, Set<string>>();
2193
2432
  for (const key of keys) {
2194
- const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2195
- if (!digitMatch?.[1] || !digitMatch[2]) {
2433
+ const member = numberedFamilyMember(key.name);
2434
+ if (!member) {
2196
2435
  continue;
2197
2436
  }
2198
- const digits = digitFamilies.get(digitMatch[1]) ?? new Set<string>();
2199
- digits.add(digitMatch[2]);
2200
- digitFamilies.set(digitMatch[1], digits);
2437
+ const positions = digitFamilies.get(member.base) ?? new Set<string>();
2438
+ positions.add(member.position);
2439
+ digitFamilies.set(member.base, positions);
2201
2440
  }
2202
2441
 
2203
2442
  return keys
2204
2443
  .filter((key) => {
2205
- if (!/^[a-z][a-zA-Z0-9]*$/.test(key.name)) {
2444
+ if (!isAllowedPublicOutputKeyName(key.name)) {
2206
2445
  return true;
2207
2446
  }
2208
- const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2209
- return digitMatch?.[1] !== undefined && (digitFamilies.get(digitMatch[1])?.size ?? 0) >= 3;
2447
+ const member = numberedFamilyMember(key.name);
2448
+ return member !== null && (digitFamilies.get(member.base)?.size ?? 0) >= 3;
2210
2449
  })
2211
2450
  .map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
2212
2451
  }
2213
2452
 
2453
+ // A numbered vendor family is a base name plus a numeric position and an
2454
+ // optional trailing letter suffix, in either compact/camel form (sensor1,
2455
+ // duty1s) or semantic snake_case form (sensor_1, duty_time_1s). Both styles
2456
+ // normalize to the same { base, position } so a family of >=3 distinct
2457
+ // positions is caught regardless of which naming style the vendor leaked
2458
+ // through. Returns null for names that carry no numeric position.
2459
+ function numberedFamilyMember(name: string): { base: string; position: string } | null {
2460
+ const camelMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(name);
2461
+ if (camelMatch?.[1] && camelMatch[2]) {
2462
+ return { base: camelMatch[1], position: camelMatch[2] };
2463
+ }
2464
+ const snakeMatch = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*?)_(\d+)[a-z]*$/.exec(name);
2465
+ if (snakeMatch?.[1] && snakeMatch[2]) {
2466
+ return { base: snakeMatch[1], position: snakeMatch[2] };
2467
+ }
2468
+ return null;
2469
+ }
2470
+
2471
+ // Public output keys may use APIFuse lowerCamelCase (isOpen24h, latitude) or
2472
+ // semantic snake_case (pharmacy_id, weekly_hours, total_count, scan_exhausted).
2473
+ // Both are normalized, human-authored names. Raw vendor keys leak through mixed
2474
+ // case or uppercase acronyms (MKioskTy) and match neither, so they stay flagged.
2475
+ // Numbered vendor families still pass this name gate in either style
2476
+ // (sensor1/2/3 or sensor_1/sensor_2/sensor_3), so they are caught separately by
2477
+ // the >=3-member numberedFamilyMember check in vendorKeyFindingsForObject.
2478
+ function isAllowedPublicOutputKeyName(name: string): boolean {
2479
+ const isLowerCamelCase = /^[a-z][a-zA-Z0-9]*$/.test(name);
2480
+ const isSemanticSnakeCase = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/.test(name);
2481
+ return isLowerCamelCase || isSemanticSnakeCase;
2482
+ }
2483
+
2214
2484
  function collectTopLevelObjectKeys(
2215
2485
  source: string,
2216
2486
  objectStart: number,
@@ -2244,9 +2514,7 @@ function collectTopLevelObjectKeys(
2244
2514
  if (computedQuote === '"' || computedQuote === "'") {
2245
2515
  const literalEnd = findStringEnd(source, literalStart);
2246
2516
  const afterLiteral =
2247
- literalEnd === -1
2248
- ? -1
2249
- : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2517
+ literalEnd === -1 ? -1 : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2250
2518
  if (literalEnd !== -1 && afterLiteral === computedEnd) {
2251
2519
  key = source.slice(literalStart + 1, literalEnd);
2252
2520
  }
@@ -2364,8 +2632,13 @@ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2364
2632
  return ranges;
2365
2633
  }
2366
2634
 
2367
- function findConstValueRangeContaining(source: string, offset: number): NamedObjectRange | undefined {
2368
- return findNamedConstValueRanges(source).find((range) => offset >= range.start && offset <= range.end);
2635
+ function findConstValueRangeContaining(
2636
+ source: string,
2637
+ offset: number,
2638
+ ): NamedObjectRange | undefined {
2639
+ return findNamedConstValueRanges(source).find(
2640
+ (range) => offset >= range.start && offset <= range.end,
2641
+ );
2369
2642
  }
2370
2643
 
2371
2644
  function findStringLiteralsInRange(
@@ -3503,9 +3776,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
3503
3776
  ];
3504
3777
  }
3505
3778
 
3779
+ // Splits secret findings into still-active findings and acknowledged
3780
+ // `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
3781
+ // (same pragma placement: the finding line or the line directly above it).
3782
+ // Every finding source carries a line number (entropy candidates and located
3783
+ // SECRET_PATTERNS matches); a finding that somehow lacks one stays active
3784
+ // defensively.
3785
+ function partitionSecretScanAllowOverrides(
3786
+ providerRoot: string,
3787
+ findings: readonly SecretFinding[],
3788
+ ): { active: SecretFinding[]; overridden: SecretFinding[] } {
3789
+ const fileLineCache = new Map<string, string[]>();
3790
+ const active: SecretFinding[] = [];
3791
+ const overridden: SecretFinding[] = [];
3792
+
3793
+ for (const finding of findings) {
3794
+ if (finding.line === undefined) {
3795
+ active.push(finding);
3796
+ continue;
3797
+ }
3798
+ const absolute = resolve(providerRoot, finding.file);
3799
+ let lines = fileLineCache.get(absolute);
3800
+ if (lines === undefined) {
3801
+ lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
3802
+ fileLineCache.set(absolute, lines);
3803
+ }
3804
+ if (hasAllowOverride(lines, finding.line, "secret-scan")) {
3805
+ overridden.push(finding);
3806
+ } else {
3807
+ active.push(finding);
3808
+ }
3809
+ }
3810
+
3811
+ return { active, overridden };
3812
+ }
3813
+
3506
3814
  function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3507
- const findings = findSecretFindings(providerRoot, provider?.id);
3508
- const blockerFindings = findings.filter((finding) => finding.level !== "warn");
3815
+ const { active, overridden } = partitionSecretScanAllowOverrides(
3816
+ providerRoot,
3817
+ findSecretFindings(providerRoot, provider?.id),
3818
+ );
3819
+ const blockerFindings = active.filter((finding) => finding.level !== "warn");
3509
3820
  if (blockerFindings.length > 0) {
3510
3821
  return {
3511
3822
  id: "secret-scan",
@@ -3525,7 +3836,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3525
3836
  ),
3526
3837
  };
3527
3838
  }
3528
- if (findings.length > 0) {
3839
+ if (active.length > 0 || overridden.length > 0) {
3840
+ const messageBase =
3841
+ active.length > 0
3842
+ ? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
3843
+ : "Potential credential-like strings were found in shareable files.";
3844
+ const message =
3845
+ overridden.length > 0
3846
+ ? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
3847
+ : messageBase;
3529
3848
  return {
3530
3849
  id: "secret-scan",
3531
3850
  category: "security",
@@ -3533,11 +3852,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3533
3852
  status: "warn",
3534
3853
  points: 8,
3535
3854
  maxPoints: CATEGORY_MAX_POINTS.security,
3536
- message:
3537
- "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
3855
+ message,
3538
3856
  remediation:
3539
- 'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public.',
3540
- evidence: findings.map(
3857
+ 'Review the listed strings. If any are credentials, move them to env vars read via `ctx.env.get("APIFUSE__PROVIDER__<ID>__<NAME>")` and rotate the leaked credential; otherwise keep generated blobs in fixtures/tests or document why they are public with `// @apifuse-allow secret-scan: <reason>`.',
3858
+ evidence: [...active, ...overridden].map(
3541
3859
  (finding) =>
3542
3860
  finding.evidence ??
3543
3861
  `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
@@ -3568,8 +3886,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
3568
3886
  if (!existsSync(filePath)) continue;
3569
3887
  const content = readFileSync(filePath, "utf8");
3570
3888
  for (const [label, pattern] of SECRET_PATTERNS) {
3571
- if (pattern.test(content)) {
3572
- findings.push({ label, file: relativePath });
3889
+ // Locate every match to its line so pattern findings carry the line
3890
+ // information hasAllowOverride needs: `// @apifuse-allow secret-scan`
3891
+ // must behave uniformly across entropy findings and pattern findings.
3892
+ const globalPattern = new RegExp(
3893
+ pattern.source,
3894
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
3895
+ );
3896
+ const seenLines = new Set<number>();
3897
+ for (const match of content.matchAll(globalPattern)) {
3898
+ const line = offsetToLine(content, match.index);
3899
+ if (seenLines.has(line)) continue;
3900
+ seenLines.add(line);
3901
+ findings.push({ label, file: relativePath, line });
3573
3902
  }
3574
3903
  }
3575
3904
  }
@@ -3627,7 +3956,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
3627
3956
  continue;
3628
3957
  }
3629
3958
  if (char === quote) {
3630
- if (cursor - contentStart >= 20) {
3959
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
3631
3960
  candidates.push(line.slice(contentStart, cursor));
3632
3961
  }
3633
3962
  index = cursor;
@@ -3652,6 +3981,25 @@ function classifyEntropyCandidate(input: {
3652
3981
  if (!charset) return undefined;
3653
3982
  const entropy = shannonEntropy(value);
3654
3983
  const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
3984
+ // Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
3985
+ // "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
3986
+ // (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
3987
+ // blocker-flagged. They are never skipped — entropy classification always
3988
+ // runs — but when the secret-ish context comes solely from identifier-
3989
+ // constant-shaped literal text (the line with those literals stripped
3990
+ // carries no secret-ish identifier), the finding is capped at a
3991
+ // non-blocking warning instead of a blocker. Stripping constant-shaped
3992
+ // siblings — not just the candidate — matters for lines holding several
3993
+ // constants (e.g. an ERROR_CODES array), while quoted property keys and
3994
+ // header names ("Authorization", "apiKey") stay visible as genuine
3995
+ // external context. Assignments to `apiKey`/`token`/`secret`-style names
3996
+ // still escalate to blockers via the identifier side, and
3997
+ // `// @apifuse-allow secret-scan` remains the reviewed way to silence the
3998
+ // warning.
3999
+ const selfContextOnlyConstant =
4000
+ secretishContext &&
4001
+ isScreamingSnakeConstantValue(value) &&
4002
+ !SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
3655
4003
  const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3656
4004
  if (entropy < threshold) return undefined;
3657
4005
 
@@ -3662,16 +4010,161 @@ function classifyEntropyCandidate(input: {
3662
4010
  charset === "hex"
3663
4011
  ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3664
4012
  : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
4013
+ const contextNote = selfContextOnlyConstant
4014
+ ? "; identifier-like constant (downgraded to warning)"
4015
+ : secretishContext
4016
+ ? ""
4017
+ : "; may be a false positive";
3665
4018
  return {
3666
4019
  label,
3667
4020
  file: input.file,
3668
4021
  line: input.lineNumber,
3669
- level: secretishContext ? "blocker" : "warn",
4022
+ level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
3670
4023
  remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3671
- evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
4024
+ evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
3672
4025
  };
3673
4026
  }
3674
4027
 
4028
+ // Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
4029
+ // separated segments, each essentially pure alphabetic — letters optionally
4030
+ // followed by a SHORT digit suffix (at most 2, e.g. version markers like
4031
+ // "V2") — and at most 15% digits across the whole value. Dictionary-style
4032
+ // constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
4033
+ // "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
4034
+ // (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
4035
+ // uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
4036
+ // ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
4037
+ // it only decides whether a finding whose secret-ish context comes solely
4038
+ // from the literal's own text is downgraded from blocker to warning, so it
4039
+ // deliberately stays strict: values that merely contain a secret-ish word but
4040
+ // are not word-like constants keep full blocker severity.
4041
+ function isScreamingSnakeConstantValue(value: string): boolean {
4042
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
4043
+ const segments = value.split("_");
4044
+ if (segments.length < 2) return false;
4045
+ if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
4046
+ const digitCount = value.match(/[0-9]/g)?.length ?? 0;
4047
+ return digitCount / value.length <= 0.15;
4048
+ }
4049
+
4050
+ type LineStringLiteral = {
4051
+ // Index of the opening quote.
4052
+ start: number;
4053
+ // Index just past the closing quote (line end when unterminated).
4054
+ end: number;
4055
+ content: string;
4056
+ closed: boolean;
4057
+ role: "key" | "value";
4058
+ // Nearest unclosed bracket enclosing the literal's start, if any.
4059
+ container?: { bracket: "[" | "(" | "{"; index: number };
4060
+ };
4061
+
4062
+ // Stable identity for the container a literal sits in ("top" when the
4063
+ // literal is not inside any bracket on the line).
4064
+ function literalContainerKey(literal: LineStringLiteral): string {
4065
+ return literal.container ? `${literal.container.bracket}${literal.container.index}` : "top";
4066
+ }
4067
+
4068
+ // Single-pass line tokenizer: extracts every string literal with its span and
4069
+ // classifies its syntactic role once. A literal is a KEY when it is preceded
4070
+ // (ignoring whitespace) by "{", ",", "(", or the line start AND followed
4071
+ // (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
4072
+ // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
4073
+ // elements, call arguments, and assignment right-hand sides, even when a
4074
+ // ternary's ":" happens to follow them. Uses the same quote/escape walking as
4075
+ // extractStringLiteralCandidates.
4076
+ function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
4077
+ const literals: LineStringLiteral[] = [];
4078
+ const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
4079
+ let index = 0;
4080
+ while (index < line.length) {
4081
+ const char = line[index];
4082
+ if (char !== '"' && char !== "'" && char !== "`") {
4083
+ if (char === "[" || char === "(" || char === "{") {
4084
+ bracketStack.push({ bracket: char, index });
4085
+ } else if (char === "]" || char === ")" || char === "}") {
4086
+ bracketStack.pop();
4087
+ }
4088
+ index += 1;
4089
+ continue;
4090
+ }
4091
+ const quote = char;
4092
+ const start = index;
4093
+ const contentStart = index + 1;
4094
+ let cursor = contentStart;
4095
+ let closed = false;
4096
+ while (cursor < line.length) {
4097
+ const inner = line[cursor];
4098
+ if (inner === "\\") {
4099
+ cursor += 2;
4100
+ continue;
4101
+ }
4102
+ if (inner === quote) {
4103
+ closed = true;
4104
+ break;
4105
+ }
4106
+ cursor += 1;
4107
+ }
4108
+ const contentEnd = Math.min(cursor, line.length);
4109
+ const end = closed ? cursor + 1 : line.length;
4110
+ const before = line.slice(0, start).trimEnd();
4111
+ const keyPreceded = before === "" || /[{,(]$/.test(before);
4112
+ const keyFollowed = closed && /^\s*:/.test(line.slice(end));
4113
+ literals.push({
4114
+ start,
4115
+ end,
4116
+ content: line.slice(contentStart, contentEnd),
4117
+ closed,
4118
+ role: keyPreceded && keyFollowed ? "key" : "value",
4119
+ container: bracketStack[bracketStack.length - 1],
4120
+ });
4121
+ index = end;
4122
+ }
4123
+ return literals;
4124
+ }
4125
+
4126
+ // Builds the context text used to decide whether a candidate's secret-ish
4127
+ // context is genuine. The candidate's own literal is ALWAYS stripped
4128
+ // (self-context rule). SIBLING identifier-constant-shaped candidate literals
4129
+ // (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
4130
+ // stripped only when they share the candidate's non-call container — the same
4131
+ // `[...]` array, the same `{...}` object value list, or the bracket-free top
4132
+ // level (ternary arms) — so a value list of error codes cannot poison its own
4133
+ // members' context. Call-argument siblings (inside `(...)`) always keep their
4134
+ // context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
4135
+ // argument genuinely describes the second, so stripping it would erase real
4136
+ // auth/token context. KEY-role literals are never stripped.
4137
+ function stripIdentifierConstantLiterals(line: string, candidate: string): string {
4138
+ const literals = tokenizeLineStringLiterals(line);
4139
+ const candidateContainers = new Set<string>();
4140
+ for (const literal of literals) {
4141
+ if (literal.content === candidate) {
4142
+ candidateContainers.add(literalContainerKey(literal));
4143
+ }
4144
+ }
4145
+ let result = "";
4146
+ let previousEnd = 0;
4147
+ for (const literal of literals) {
4148
+ result += line.slice(previousEnd, literal.start);
4149
+ const isSelf = literal.content === candidate;
4150
+ const isSameNonCallContainerSibling =
4151
+ literal.role === "value" &&
4152
+ literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
4153
+ isScreamingSnakeConstantValue(literal.content) &&
4154
+ literal.container?.bracket !== "(" &&
4155
+ candidateContainers.has(literalContainerKey(literal));
4156
+ if (isSelf || isSameNonCallContainerSibling) {
4157
+ const quote = line[literal.start] ?? "";
4158
+ result += quote + (literal.closed ? quote : "");
4159
+ } else {
4160
+ result += line.slice(literal.start, literal.end);
4161
+ }
4162
+ previousEnd = literal.end;
4163
+ }
4164
+ result += line.slice(previousEnd);
4165
+ return result;
4166
+ }
4167
+
3675
4168
  function shouldConsiderEntropyValue(value: string): boolean {
3676
4169
  const lower = value.toLowerCase();
3677
4170
  if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
@@ -3687,7 +4180,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
3687
4180
  if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3688
4181
  return false;
3689
4182
  }
3690
- return value.length >= 20;
4183
+ return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
3691
4184
  }
3692
4185
 
3693
4186
  function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
@@ -3721,6 +4214,11 @@ function guessSecretName(line: string): string {
3721
4214
 
3722
4215
  const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3723
4216
 
4217
+ // Minimum length for a string literal to be considered an entropy candidate.
4218
+ // Shared by candidate extraction, entropy screening, and the context strip so
4219
+ // the three stay coherent.
4220
+ const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
4221
+
3724
4222
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
3725
4223
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3726
4224
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],