@apifuse/provider-sdk 2.2.0-beta.4 → 2.2.0-beta.7

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 (51) hide show
  1. package/AUTHORING.md +92 -0
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +5 -1
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +26 -1
  6. package/bin/apifuse-pack-check.ts +14 -0
  7. package/bin/apifuse-submit-check.ts +433 -15
  8. package/bin/apifuse-sync-assets.ts +117 -0
  9. package/dist/cli/commands.d.ts +1 -1
  10. package/dist/cli/commands.js +8 -0
  11. package/dist/cli/create.d.ts +3 -0
  12. package/dist/cli/create.js +34 -35
  13. package/dist/cli/prompt-assets.d.ts +80 -0
  14. package/dist/cli/prompt-assets.js +743 -0
  15. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/runtime/executor.js +7 -0
  19. package/dist/runtime/secrets.d.ts +27 -0
  20. package/dist/runtime/secrets.js +51 -0
  21. package/dist/server/index.d.ts +1 -1
  22. package/dist/server/index.js +1 -1
  23. package/dist/server/self-test.d.ts +101 -0
  24. package/dist/server/self-test.js +670 -112
  25. package/dist/server/serve.d.ts +5 -0
  26. package/dist/server/serve.js +41 -1
  27. package/package.json +1 -1
  28. package/src/cli/commands.ts +10 -0
  29. package/src/cli/create.ts +42 -35
  30. package/src/cli/prompt-assets.ts +865 -0
  31. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  32. package/src/index.ts +5 -0
  33. package/src/runtime/executor.ts +8 -0
  34. package/src/runtime/secrets.ts +64 -0
  35. package/src/server/index.ts +5 -0
  36. package/src/server/self-test.ts +852 -127
  37. package/src/server/serve.ts +60 -1
  38. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  39. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  40. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  41. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  42. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  43. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  44. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  45. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  46. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  47. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  48. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  49. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  50. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  51. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
@@ -11,6 +11,10 @@ import * as acorn from "acorn";
11
11
  import { z } from "zod";
12
12
 
13
13
  import packageJson from "../package.json";
14
+ import {
15
+ formatPromptAssetIssues,
16
+ verifyPromptAssets,
17
+ } from "../src/cli/prompt-assets.js";
14
18
  import type { ProviderDefinition } from "../src/index.js";
15
19
  import {
16
20
  loadProviderLocaleCatalogs,
@@ -19,7 +23,7 @@ import {
19
23
  } from "../src/i18n/index.js";
20
24
  import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
21
25
  import { safeParseSchemaSync } from "../src/schema.js";
22
- import { type CheckResult, runChecks } from "./apifuse-check.js";
26
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
23
27
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
24
28
 
25
29
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
@@ -267,7 +271,15 @@ export async function buildSubmitCheckReport(
267
271
  const baseChecks = await safeRunChecks(providerRoot);
268
272
  const provider = await safeLoadProvider(providerRoot);
269
273
 
270
- checks.push(...scoreBaseChecks(baseChecks));
274
+ // Prompt-asset freshness is reported by its own dedicated zero-point
275
+ // blocker below; filter the base-check duplicate so it is not double
276
+ // penalized under the definition category.
277
+ checks.push(
278
+ ...scoreBaseChecks(
279
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
280
+ ),
281
+ );
282
+ checks.push(scorePromptAssetFreshness(providerRoot));
271
283
  checks.push(scoreProviderIdSlug(providerRoot, provider));
272
284
  checks.push(scoreNoVendorShim(providerRoot));
273
285
  checks.push(scoreNoVendorImport(providerRoot));
@@ -283,6 +295,7 @@ export async function buildSubmitCheckReport(
283
295
  if (provider) {
284
296
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
285
297
  checks.push(scoreCredentialUsage(providerRoot, provider));
298
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
286
299
  checks.push(scoreLocaleCatalog(providerRoot, provider));
287
300
  checks.push(scoreOperationMetadata(provider));
288
301
  checks.push(scoreFixtureCoverage(provider));
@@ -1424,6 +1437,159 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1424
1437
  );
1425
1438
  }
1426
1439
 
1440
+ // ---------------------------------------------------------------------------
1441
+ // sdk-owned-secret-presence (warn): provider-local double validation of
1442
+ // declared env secrets.
1443
+ //
1444
+ // The SDK runtime is the single source of truth for secret presence: declared
1445
+ // `required: true` secrets are validated before every handler/auth-flow
1446
+ // invocation and fail with the canonical structured MISSING_SECRET error
1447
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
1448
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
1449
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
1450
+ // with/without category), which broke uniform incident attribution when nine
1451
+ // providers shipped with unprovisioned secrets (2026-07-22).
1452
+ //
1453
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
1454
+ // `.env.get(...)` (string literal or a const alias of a declared name)
1455
+ // followed within a small window by a falsy presence check plus a `throw`.
1456
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
1457
+ // declared in defineProvider secrets[], and optional declarations
1458
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
1459
+ // are out of scope. Escape hatch:
1460
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
1461
+ // ---------------------------------------------------------------------------
1462
+
1463
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
1464
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
1465
+
1466
+ const ENV_GET_CALL_PATTERN =
1467
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
1468
+
1469
+ const SECRET_ALIAS_CONST_PATTERN =
1470
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
1471
+
1472
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
1473
+
1474
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
1475
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
1476
+ // guards are detected, not just direct string literals.
1477
+ function buildDeclaredSecretAliasMap(
1478
+ providerRoot: string,
1479
+ declaredNames: ReadonlySet<string>,
1480
+ ): Map<string, string> {
1481
+ const aliases = new Map<string, string>();
1482
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1483
+ const content = readFileSync(filePath, "utf8");
1484
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
1485
+ const alias = match[1];
1486
+ const name = match[2] ?? match[3] ?? match[4];
1487
+ if (alias && name && declaredNames.has(name)) {
1488
+ aliases.set(alias, name);
1489
+ }
1490
+ }
1491
+ }
1492
+ return aliases;
1493
+ }
1494
+
1495
+ function hasLocalSecretPresenceGuard(
1496
+ line: string,
1497
+ remainingLines: readonly string[],
1498
+ declaredNames: ReadonlySet<string>,
1499
+ aliases: ReadonlyMap<string, string>,
1500
+ ): boolean {
1501
+ const match = ENV_GET_CALL_PATTERN.exec(line);
1502
+ if (!match) {
1503
+ return false;
1504
+ }
1505
+ const literal = match[1] ?? match[2] ?? match[3];
1506
+ const identifier = match[4];
1507
+ const readsDeclaredSecret =
1508
+ literal !== undefined
1509
+ ? declaredNames.has(literal)
1510
+ : identifier !== undefined && aliases.has(identifier);
1511
+ if (!readsDeclaredSecret) {
1512
+ return false;
1513
+ }
1514
+
1515
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
1516
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
1517
+ return false;
1518
+ }
1519
+
1520
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
1521
+ // check on THAT variable counts as a presence guard. Anchoring on the
1522
+ // assigned identifier avoids false positives from unrelated guards/throws
1523
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
1524
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
1525
+ if (assigned) {
1526
+ const escaped = assigned.replace(/\$/g, "\\$");
1527
+ const guardPattern = new RegExp(
1528
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
1529
+ );
1530
+ return window.some((candidate) => guardPattern.test(candidate));
1531
+ }
1532
+
1533
+ // Un-assigned read: only an inline presence check on the same line counts,
1534
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
1535
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
1536
+ }
1537
+
1538
+ function scoreSdkOwnedSecretPresence(
1539
+ providerRoot: string,
1540
+ provider: ProviderDefinition,
1541
+ ): SubmitCheck {
1542
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
1543
+ // Only `required: true` declarations: those are exactly what the runtime
1544
+ // gate enforces. A presence guard over an optional secret is conditional
1545
+ // business logic the SDK will not replace, not double validation.
1546
+ const declaredNames: ReadonlySet<string> = new Set(
1547
+ (provider.secrets ?? [])
1548
+ .filter((secret) => secret.required === true)
1549
+ .map((secret) => secret.name),
1550
+ );
1551
+ if (declaredNames.size === 0) {
1552
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1553
+ }
1554
+
1555
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
1556
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
1557
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
1558
+ );
1559
+ if (findings.length === 0) {
1560
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1561
+ }
1562
+
1563
+ const { violations, overridden } = partitionAllowOverrides(
1564
+ providerRoot,
1565
+ findings,
1566
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1567
+ );
1568
+ if (violations.length === 0) {
1569
+ return pass(
1570
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1571
+ SDK_NATIVE_CATEGORY,
1572
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
1573
+ 0,
1574
+ formatSourceFindings(overridden),
1575
+ );
1576
+ }
1577
+
1578
+ return {
1579
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1580
+ category: SDK_NATIVE_CATEGORY,
1581
+ level: "warn",
1582
+ status: "warn",
1583
+ points: 0,
1584
+ maxPoints: 0,
1585
+ message:
1586
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
1587
+ remediation:
1588
+ "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>`.",
1589
+ evidence: formatSourceFindings(violations),
1590
+ };
1591
+ }
1592
+
1427
1593
  function findSourceLineMatches(
1428
1594
  providerRoot: string,
1429
1595
  pattern: RegExp | ((line: string) => boolean),
@@ -1523,6 +1689,10 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1523
1689
  );
1524
1690
  }
1525
1691
 
1692
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1693
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1694
+ // directories must not become a scan-exempt hiding place for secrets, raw
1695
+ // fetch, or vendor imports.
1526
1696
  function shouldScanSourceDirectory(relativePath: string): boolean {
1527
1697
  return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1528
1698
  }
@@ -1545,6 +1715,27 @@ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1545
1715
  return findings.map((finding) => `${finding.file}:${finding.line}`);
1546
1716
  }
1547
1717
 
1718
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
1719
+ const verification = verifyPromptAssets(providerRoot);
1720
+ if (verification.ok) {
1721
+ return pass(
1722
+ "prompt-assets-fresh",
1723
+ "docs",
1724
+ "SDK-managed agent prompt assets match the installed SDK version.",
1725
+ 0,
1726
+ );
1727
+ }
1728
+
1729
+ return blocker(
1730
+ "prompt-assets-fresh",
1731
+ "docs",
1732
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
1733
+ "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.",
1734
+ 0,
1735
+ formatPromptAssetIssues(verification),
1736
+ );
1737
+ }
1738
+
1548
1739
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1549
1740
  const missing: string[] = [];
1550
1741
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
@@ -3546,9 +3737,47 @@ function scoreProviderDocs(providerRoot: string): SubmitCheck[] {
3546
3737
  ];
3547
3738
  }
3548
3739
 
3740
+ // Splits secret findings into still-active findings and acknowledged
3741
+ // `// @apifuse-allow secret-scan` overrides, mirroring partitionAllowOverrides
3742
+ // (same pragma placement: the finding line or the line directly above it).
3743
+ // Every finding source carries a line number (entropy candidates and located
3744
+ // SECRET_PATTERNS matches); a finding that somehow lacks one stays active
3745
+ // defensively.
3746
+ function partitionSecretScanAllowOverrides(
3747
+ providerRoot: string,
3748
+ findings: readonly SecretFinding[],
3749
+ ): { active: SecretFinding[]; overridden: SecretFinding[] } {
3750
+ const fileLineCache = new Map<string, string[]>();
3751
+ const active: SecretFinding[] = [];
3752
+ const overridden: SecretFinding[] = [];
3753
+
3754
+ for (const finding of findings) {
3755
+ if (finding.line === undefined) {
3756
+ active.push(finding);
3757
+ continue;
3758
+ }
3759
+ const absolute = resolve(providerRoot, finding.file);
3760
+ let lines = fileLineCache.get(absolute);
3761
+ if (lines === undefined) {
3762
+ lines = existsSync(absolute) ? readFileSync(absolute, "utf8").split(/\r?\n/) : [];
3763
+ fileLineCache.set(absolute, lines);
3764
+ }
3765
+ if (hasAllowOverride(lines, finding.line, "secret-scan")) {
3766
+ overridden.push(finding);
3767
+ } else {
3768
+ active.push(finding);
3769
+ }
3770
+ }
3771
+
3772
+ return { active, overridden };
3773
+ }
3774
+
3549
3775
  function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): SubmitCheck {
3550
- const findings = findSecretFindings(providerRoot, provider?.id);
3551
- const blockerFindings = findings.filter((finding) => finding.level !== "warn");
3776
+ const { active, overridden } = partitionSecretScanAllowOverrides(
3777
+ providerRoot,
3778
+ findSecretFindings(providerRoot, provider?.id),
3779
+ );
3780
+ const blockerFindings = active.filter((finding) => finding.level !== "warn");
3552
3781
  if (blockerFindings.length > 0) {
3553
3782
  return {
3554
3783
  id: "secret-scan",
@@ -3568,7 +3797,15 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3568
3797
  ),
3569
3798
  };
3570
3799
  }
3571
- if (findings.length > 0) {
3800
+ if (active.length > 0 || overridden.length > 0) {
3801
+ const messageBase =
3802
+ active.length > 0
3803
+ ? "High-entropy source strings were found without secret-like identifier context; they may be false positives."
3804
+ : "Potential credential-like strings were found in shareable files.";
3805
+ const message =
3806
+ overridden.length > 0
3807
+ ? `${messageBase} ${overridden.length} acknowledged @apifuse-allow override(s).`
3808
+ : messageBase;
3572
3809
  return {
3573
3810
  id: "secret-scan",
3574
3811
  category: "security",
@@ -3576,11 +3813,10 @@ function scoreSecrets(providerRoot: string, provider?: ProviderDefinition): Subm
3576
3813
  status: "warn",
3577
3814
  points: 8,
3578
3815
  maxPoints: CATEGORY_MAX_POINTS.security,
3579
- message:
3580
- "High-entropy source strings were found without secret-like identifier context; they may be false positives.",
3816
+ message,
3581
3817
  remediation:
3582
- '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.',
3583
- evidence: findings.map(
3818
+ '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>`.',
3819
+ evidence: [...active, ...overridden].map(
3584
3820
  (finding) =>
3585
3821
  finding.evidence ??
3586
3822
  `${finding.file}${finding.line ? `:${finding.line}` : ""}: ${finding.label}`,
@@ -3611,8 +3847,19 @@ function findSecretFindings(providerRoot: string, providerId = "<ID>"): SecretFi
3611
3847
  if (!existsSync(filePath)) continue;
3612
3848
  const content = readFileSync(filePath, "utf8");
3613
3849
  for (const [label, pattern] of SECRET_PATTERNS) {
3614
- if (pattern.test(content)) {
3615
- findings.push({ label, file: relativePath });
3850
+ // Locate every match to its line so pattern findings carry the line
3851
+ // information hasAllowOverride needs: `// @apifuse-allow secret-scan`
3852
+ // must behave uniformly across entropy findings and pattern findings.
3853
+ const globalPattern = new RegExp(
3854
+ pattern.source,
3855
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
3856
+ );
3857
+ const seenLines = new Set<number>();
3858
+ for (const match of content.matchAll(globalPattern)) {
3859
+ const line = offsetToLine(content, match.index);
3860
+ if (seenLines.has(line)) continue;
3861
+ seenLines.add(line);
3862
+ findings.push({ label, file: relativePath, line });
3616
3863
  }
3617
3864
  }
3618
3865
  }
@@ -3670,7 +3917,7 @@ export function extractStringLiteralCandidates(line: string): string[] {
3670
3917
  continue;
3671
3918
  }
3672
3919
  if (char === quote) {
3673
- if (cursor - contentStart >= 20) {
3920
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
3674
3921
  candidates.push(line.slice(contentStart, cursor));
3675
3922
  }
3676
3923
  index = cursor;
@@ -3695,6 +3942,25 @@ function classifyEntropyCandidate(input: {
3695
3942
  if (!charset) return undefined;
3696
3943
  const entropy = shannonEntropy(value);
3697
3944
  const secretishContext = SECRETISH_IDENTIFIER_PATTERN.test(input.line);
3945
+ // Word-like SCREAMING_SNAKE values (e.g. error-code constants such as
3946
+ // "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED") may contain secret-ish words
3947
+ // (AUTH/PASSWORD/...) in their own text and would otherwise be permanently
3948
+ // blocker-flagged. They are never skipped — entropy classification always
3949
+ // runs — but when the secret-ish context comes solely from identifier-
3950
+ // constant-shaped literal text (the line with those literals stripped
3951
+ // carries no secret-ish identifier), the finding is capped at a
3952
+ // non-blocking warning instead of a blocker. Stripping constant-shaped
3953
+ // siblings — not just the candidate — matters for lines holding several
3954
+ // constants (e.g. an ERROR_CODES array), while quoted property keys and
3955
+ // header names ("Authorization", "apiKey") stay visible as genuine
3956
+ // external context. Assignments to `apiKey`/`token`/`secret`-style names
3957
+ // still escalate to blockers via the identifier side, and
3958
+ // `// @apifuse-allow secret-scan` remains the reviewed way to silence the
3959
+ // warning.
3960
+ const selfContextOnlyConstant =
3961
+ secretishContext &&
3962
+ isScreamingSnakeConstantValue(value) &&
3963
+ !SECRETISH_IDENTIFIER_PATTERN.test(stripIdentifierConstantLiterals(input.line, value));
3698
3964
  const threshold = charset === "hex" ? 3.0 : secretishContext ? 4.0 : 4.5;
3699
3965
  if (entropy < threshold) return undefined;
3700
3966
 
@@ -3705,16 +3971,163 @@ function classifyEntropyCandidate(input: {
3705
3971
  charset === "hex"
3706
3972
  ? `high-entropy hex string (${entropy.toFixed(2)} bits/char)`
3707
3973
  : `high-entropy base64-like string (${entropy.toFixed(2)} bits/char)`;
3974
+ const contextNote = selfContextOnlyConstant
3975
+ ? "; identifier-like constant (downgraded to warning)"
3976
+ : secretishContext
3977
+ ? ""
3978
+ : "; may be a false positive";
3708
3979
  return {
3709
3980
  label,
3710
3981
  file: input.file,
3711
3982
  line: input.lineNumber,
3712
- level: secretishContext ? "blocker" : "warn",
3983
+ level: secretishContext && !selfContextOnlyConstant ? "blocker" : "warn",
3713
3984
  remediation: `Move ${location} to an env var read via \`ctx.env.get("${envName}")\` and rotate the leaked credential.`,
3714
- evidence: `${location}: ${label}; preview ${preview}${secretishContext ? "" : "; may be a false positive"}`,
3985
+ evidence: `${location}: ${label}; preview ${preview}${contextNote}`,
3715
3986
  };
3716
3987
  }
3717
3988
 
3989
+ // Word-like SCREAMING_SNAKE identifier shape: at least two underscore-
3990
+ // separated segments, each essentially pure alphabetic — letters optionally
3991
+ // followed by a SHORT digit suffix (at most 2, e.g. version markers like
3992
+ // "V2") — and at most 15% digits across the whole value. Dictionary-style
3993
+ // constants like "AUTH_PASSWORD_LOGIN_CAPTCHA_REQUIRED" or
3994
+ // "PROVIDER_CONTRACT_V2_REQUIRED" match; digit-heavy segmented material
3995
+ // (e.g. license/credential shapes like "ABCD1234_EFGH5678_IJKL9012"),
3996
+ // uppercase blobs ("XK9J_Q2ZP_M7VN"), and underscore-free hex-like values
3997
+ // ("A1B2C3D4...") do not. This shape gate never skips entropy classification;
3998
+ // it only decides whether a finding whose secret-ish context comes solely
3999
+ // from the literal's own text is downgraded from blocker to warning, so it
4000
+ // deliberately stays strict: values that merely contain a secret-ish word but
4001
+ // are not word-like constants keep full blocker severity.
4002
+ function isScreamingSnakeConstantValue(value: string): boolean {
4003
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value) || !value.includes("_")) return false;
4004
+ const segments = value.split("_");
4005
+ if (segments.length < 2) return false;
4006
+ if (!segments.every((segment) => /^[A-Z]+[0-9]{0,2}$/.test(segment))) return false;
4007
+ const digitCount = value.match(/[0-9]/g)?.length ?? 0;
4008
+ return digitCount / value.length <= 0.15;
4009
+ }
4010
+
4011
+ type LineStringLiteral = {
4012
+ // Index of the opening quote.
4013
+ start: number;
4014
+ // Index just past the closing quote (line end when unterminated).
4015
+ end: number;
4016
+ content: string;
4017
+ closed: boolean;
4018
+ role: "key" | "value";
4019
+ // Nearest unclosed bracket enclosing the literal's start, if any.
4020
+ container?: { bracket: "[" | "(" | "{"; index: number };
4021
+ };
4022
+
4023
+ // Stable identity for the container a literal sits in ("top" when the
4024
+ // literal is not inside any bracket on the line).
4025
+ function literalContainerKey(literal: LineStringLiteral): string {
4026
+ return literal.container
4027
+ ? `${literal.container.bracket}${literal.container.index}`
4028
+ : "top";
4029
+ }
4030
+
4031
+ // Single-pass line tokenizer: extracts every string literal with its span and
4032
+ // classifies its syntactic role once. A literal is a KEY when it is preceded
4033
+ // (ignoring whitespace) by "{", ",", "(", or the line start AND followed
4034
+ // (ignoring whitespace) by ":" — i.e. it names the value next to it. Every
4035
+ // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
4036
+ // elements, call arguments, and assignment right-hand sides, even when a
4037
+ // ternary's ":" happens to follow them. Uses the same quote/escape walking as
4038
+ // extractStringLiteralCandidates.
4039
+ function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
4040
+ const literals: LineStringLiteral[] = [];
4041
+ const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
4042
+ let index = 0;
4043
+ while (index < line.length) {
4044
+ const char = line[index];
4045
+ if (char !== '"' && char !== "'" && char !== "`") {
4046
+ if (char === "[" || char === "(" || char === "{") {
4047
+ bracketStack.push({ bracket: char, index });
4048
+ } else if (char === "]" || char === ")" || char === "}") {
4049
+ bracketStack.pop();
4050
+ }
4051
+ index += 1;
4052
+ continue;
4053
+ }
4054
+ const quote = char;
4055
+ const start = index;
4056
+ const contentStart = index + 1;
4057
+ let cursor = contentStart;
4058
+ let closed = false;
4059
+ while (cursor < line.length) {
4060
+ const inner = line[cursor];
4061
+ if (inner === "\\") {
4062
+ cursor += 2;
4063
+ continue;
4064
+ }
4065
+ if (inner === quote) {
4066
+ closed = true;
4067
+ break;
4068
+ }
4069
+ cursor += 1;
4070
+ }
4071
+ const contentEnd = Math.min(cursor, line.length);
4072
+ const end = closed ? cursor + 1 : line.length;
4073
+ const before = line.slice(0, start).trimEnd();
4074
+ const keyPreceded = before === "" || /[{,(]$/.test(before);
4075
+ const keyFollowed = closed && /^\s*:/.test(line.slice(end));
4076
+ literals.push({
4077
+ start,
4078
+ end,
4079
+ content: line.slice(contentStart, contentEnd),
4080
+ closed,
4081
+ role: keyPreceded && keyFollowed ? "key" : "value",
4082
+ container: bracketStack[bracketStack.length - 1],
4083
+ });
4084
+ index = end;
4085
+ }
4086
+ return literals;
4087
+ }
4088
+
4089
+ // Builds the context text used to decide whether a candidate's secret-ish
4090
+ // context is genuine. The candidate's own literal is ALWAYS stripped
4091
+ // (self-context rule). SIBLING identifier-constant-shaped candidate literals
4092
+ // (VALUE role, SCREAMING_SNAKE shape, >= ENTROPY_CANDIDATE_MIN_LENGTH) are
4093
+ // stripped only when they share the candidate's non-call container — the same
4094
+ // `[...]` array, the same `{...}` object value list, or the bracket-free top
4095
+ // level (ternary arms) — so a value list of error codes cannot poison its own
4096
+ // members' context. Call-argument siblings (inside `(...)`) always keep their
4097
+ // context: in `headers.set("X_LONG_AUTH_TOKEN_NAME", "QWERTY_...")` the first
4098
+ // argument genuinely describes the second, so stripping it would erase real
4099
+ // auth/token context. KEY-role literals are never stripped.
4100
+ function stripIdentifierConstantLiterals(line: string, candidate: string): string {
4101
+ const literals = tokenizeLineStringLiterals(line);
4102
+ const candidateContainers = new Set<string>();
4103
+ for (const literal of literals) {
4104
+ if (literal.content === candidate) {
4105
+ candidateContainers.add(literalContainerKey(literal));
4106
+ }
4107
+ }
4108
+ let result = "";
4109
+ let previousEnd = 0;
4110
+ for (const literal of literals) {
4111
+ result += line.slice(previousEnd, literal.start);
4112
+ const isSelf = literal.content === candidate;
4113
+ const isSameNonCallContainerSibling =
4114
+ literal.role === "value" &&
4115
+ literal.content.length >= ENTROPY_CANDIDATE_MIN_LENGTH &&
4116
+ isScreamingSnakeConstantValue(literal.content) &&
4117
+ literal.container?.bracket !== "(" &&
4118
+ candidateContainers.has(literalContainerKey(literal));
4119
+ if (isSelf || isSameNonCallContainerSibling) {
4120
+ const quote = line[literal.start] ?? "";
4121
+ result += quote + (literal.closed ? quote : "");
4122
+ } else {
4123
+ result += line.slice(literal.start, literal.end);
4124
+ }
4125
+ previousEnd = literal.end;
4126
+ }
4127
+ result += line.slice(previousEnd);
4128
+ return result;
4129
+ }
4130
+
3718
4131
  function shouldConsiderEntropyValue(value: string): boolean {
3719
4132
  const lower = value.toLowerCase();
3720
4133
  if (/^(?:dev-only|local|example|sample|your-|replace|<)/i.test(value)) {
@@ -3730,7 +4143,7 @@ function shouldConsiderEntropyValue(value: string): boolean {
3730
4143
  if (lower.includes("/") && /\.[a-z0-9]{1,8}(?:$|[/?#])/i.test(value)) {
3731
4144
  return false;
3732
4145
  }
3733
- return value.length >= 20;
4146
+ return value.length >= ENTROPY_CANDIDATE_MIN_LENGTH;
3734
4147
  }
3735
4148
 
3736
4149
  function classifyEntropyCharset(value: string): "base64" | "hex" | undefined {
@@ -3764,6 +4177,11 @@ function guessSecretName(line: string): string {
3764
4177
 
3765
4178
  const SECRETISH_IDENTIFIER_PATTERN = /key|token|secret|password|credential|auth/i;
3766
4179
 
4180
+ // Minimum length for a string literal to be considered an entropy candidate.
4181
+ // Shared by candidate extraction, entropy screening, and the context strip so
4182
+ // the three stay coherent.
4183
+ const ENTROPY_CANDIDATE_MIN_LENGTH = 20;
4184
+
3767
4185
  const SECRET_PATTERNS: Array<[string, RegExp]> = [
3768
4186
  ["JWT-like token", /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/],
3769
4187
  ["GitHub token", /gh[pousr]_[A-Za-z0-9_]{30,}/],
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {
4
+ formatPromptAssetIssues,
5
+ installedSdkVersion,
6
+ syncPromptAssets,
7
+ verifyPromptAssets,
8
+ } from "../src/cli/prompt-assets.js";
9
+ import { resolveProviderRoot } from "./apifuse-check.js";
10
+
11
+ const HELP_TEXT = `Usage: apifuse sync-assets [path] [--check]
12
+ Example: apifuse sync-assets .
13
+ Default: apifuse sync-assets .
14
+
15
+ Regenerates the SDK-managed agent prompt assets for the installed SDK version:
16
+ AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and the
17
+ .apifuse/prompt-assets.json manifest. Legacy top-level skills/ layouts are
18
+ migrated. Idempotent.
19
+
20
+ Options:
21
+ --check Verify only; exit 1 with a diff list when assets are stale/missing/modified
22
+ --help, -h Show this help`;
23
+
24
+ export async function main() {
25
+ const args = normalizeArgs(process.argv.slice(2));
26
+
27
+ if (args.includes("--help") || args.includes("-h")) {
28
+ console.log(HELP_TEXT);
29
+ return;
30
+ }
31
+
32
+ let checkOnly = false;
33
+ let inputPath: string | undefined;
34
+ for (const arg of args) {
35
+ if (arg === "--check") {
36
+ checkOnly = true;
37
+ continue;
38
+ }
39
+ if (arg.startsWith("-")) {
40
+ throw new Error(`Unknown option: ${arg}`);
41
+ }
42
+ if (inputPath !== undefined) {
43
+ throw new Error(`Unexpected argument: ${arg}`);
44
+ }
45
+ inputPath = arg;
46
+ }
47
+
48
+ const providerRoot = resolveProviderRoot(inputPath ?? ".");
49
+
50
+ if (checkOnly) {
51
+ const verification = verifyPromptAssets(providerRoot);
52
+ if (verification.ok) {
53
+ console.log(
54
+ `Prompt assets are in sync with the installed SDK (${installedSdkVersion()}): ${providerRoot}`,
55
+ );
56
+ return;
57
+ }
58
+ console.error(`Prompt assets are out of sync in ${providerRoot}:`);
59
+ for (const issue of formatPromptAssetIssues(verification)) {
60
+ console.error(` - ${issue}`);
61
+ }
62
+ console.error("\nRun `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate.");
63
+ process.exit(1);
64
+ }
65
+
66
+ const result = syncPromptAssets(providerRoot);
67
+
68
+ // Honesty gate: writes alone never imply success. sync-assets intentionally
69
+ // PRESERVES (does not delete) unauthorized skills and symlinks under
70
+ // .agents/skills, so it can return changed:false while verify still fails.
71
+ // Re-verify AFTER writing and let the true post-sync state drive the exit
72
+ // code — never claim success while the freshness gate would reject the tree.
73
+ const verification = verifyPromptAssets(providerRoot);
74
+
75
+ if (result.changed) {
76
+ for (const removed of result.removed) {
77
+ console.log(`removed ${removed}`);
78
+ }
79
+ for (const wrote of result.wroteFiles) {
80
+ console.log(`wrote ${wrote}`);
81
+ }
82
+ for (const link of result.createdSymlinks) {
83
+ console.log(`symlink ${link}`);
84
+ }
85
+ console.log(`manifest ${result.manifestPath} (sdkVersion ${installedSdkVersion()})`);
86
+ }
87
+
88
+ if (!verification.ok) {
89
+ console.error(`\nPrompt assets are still out of sync in ${providerRoot}:`);
90
+ for (const issue of formatPromptAssetIssues(verification)) {
91
+ console.error(` - ${issue}`);
92
+ }
93
+ console.error(
94
+ "\nsync-assets never deletes unrecognized content: resolve these by hand — remove any unauthorized skill directory or symlink under .agents/skills/, migrate a real .claude/.codex directory into .agents/ — then re-run `apifuse sync-assets .`.",
95
+ );
96
+ process.exit(1);
97
+ }
98
+
99
+ if (!result.changed) {
100
+ console.log(
101
+ `Prompt assets already in sync with the installed SDK (${installedSdkVersion()}): ${providerRoot}`,
102
+ );
103
+ return;
104
+ }
105
+ console.log(`\nPrompt assets synced: ${providerRoot}`);
106
+ }
107
+
108
+ function normalizeArgs(argv: string[]): string[] {
109
+ return argv[0] === "sync-assets" ? argv.slice(1) : argv;
110
+ }
111
+
112
+ if (import.meta.main) {
113
+ await main().catch((error: unknown) => {
114
+ console.error(error instanceof Error ? error.message : String(error));
115
+ process.exit(1);
116
+ });
117
+ }
@@ -1,4 +1,4 @@
1
- export type ApifuseCommandName = "create" | "dev" | "check" | "submit-check" | "bounty-check" | "record" | "test" | "perf";
1
+ export type ApifuseCommandName = "create" | "dev" | "check" | "sync-assets" | "submit-check" | "bounty-check" | "record" | "test" | "perf";
2
2
  export type ApifuseCommandManifest = {
3
3
  name: ApifuseCommandName;
4
4
  summary: string;