@event4u/agent-config 14.7.0 → 14.9.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +175 -390
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  6. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  7. package/dist/agent-src/commands/pr/create.md +14 -2
  8. package/dist/agent-src/commands/pr/merge.md +383 -0
  9. package/dist/agent-src/commands/review/changes.md +95 -14
  10. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  15. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +100 -2
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  17. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  18. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  19. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  20. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  21. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  22. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  23. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  24. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  25. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  27. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  28. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  29. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  30. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  31. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  32. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  33. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  34. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  35. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  36. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  38. package/dist/agent-src/skills/security/SKILL.md +8 -0
  39. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/roadmaps.md +28 -2
  42. package/dist/cli/registry.js +3 -0
  43. package/dist/cli/registry.js.map +1 -1
  44. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-77GR3VD4.js} +68 -43
  45. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  46. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-CYH5SP6M.js} +4 -23
  47. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  48. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  49. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  50. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  51. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-PLSG6UL5.js} +14 -14
  52. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  53. package/dist/cli-delegate/cmd_conformance.js +101 -25
  54. package/dist/cli-delegate/cmd_converge.js +3 -3
  55. package/dist/cli-delegate/cmd_doctor.js +7 -7
  56. package/dist/cli-delegate/cmd_export.js +6 -6
  57. package/dist/cli-delegate/cmd_handoff.js +7 -6
  58. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  59. package/dist/cli-delegate/cmd_migrate.js +2 -2
  60. package/dist/cli-delegate/cmd_preflight.js +5 -5
  61. package/dist/cli-delegate/cmd_prune.js +6 -6
  62. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  63. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  64. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  65. package/dist/cli-delegate/cmd_settings_get.js +4 -4
  66. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  67. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  68. package/dist/cli-delegate/cmd_sync.js +6 -6
  69. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  70. package/dist/cli-delegate/cmd_update.js +3 -3
  71. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  72. package/dist/cli-delegate/cmd_validate.js +6 -6
  73. package/dist/cli-delegate/cmd_versions.js +3 -3
  74. package/dist/discovery/deprecation-report.md +1 -1
  75. package/dist/discovery/discovery-manifest.json +83 -30
  76. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  77. package/dist/discovery/discovery-manifest.summary.md +4 -4
  78. package/dist/discovery/dormancy-report.md +1 -1
  79. package/dist/discovery/orphan-report.md +1 -1
  80. package/dist/discovery/packs.json +11 -9
  81. package/dist/discovery/trust-report.md +2 -2
  82. package/dist/discovery/workspaces.json +9 -7
  83. package/dist/hooks/dispatch.js +1295 -812
  84. package/dist/install/globalRuleLayers.js +169 -0
  85. package/dist/install/globalRuleLayers.js.map +1 -0
  86. package/dist/install/partitionEligibility.js +156 -0
  87. package/dist/install/partitionEligibility.js.map +1 -1
  88. package/dist/install/ruleLayerPartition.js +157 -0
  89. package/dist/install/ruleLayerPartition.js.map +1 -0
  90. package/dist/mcp/registry-manifest.json +3 -3
  91. package/dist/mcp/server.json +2 -2
  92. package/dist/mcp/server.mjs +1358 -805
  93. package/dist/scripts/_lib/claude_plugin.js +162 -0
  94. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  95. package/dist/scripts/_lib/environment_detector.js +397 -0
  96. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  97. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  98. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  99. package/dist/scripts/_lib/spawn_env.js +129 -0
  100. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  101. package/dist/scripts/ai_council/modes.js +199 -0
  102. package/dist/scripts/ai_council/modes.js.map +1 -0
  103. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  104. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  105. package/dist/scripts/condense.js +52 -89
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/routes/wizard.js +3 -2
  108. package/dist/server/routes/wizard.js.map +1 -1
  109. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  110. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  111. package/dist/ui/index.html +1 -1
  112. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  113. package/docs/guidelines/agent-infra/false-green.md +47 -1
  114. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  115. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  116. package/package.json +7 -3
  117. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  118. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  119. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  120. package/src/config/ci-local-parity.yml +21 -0
  121. package/src/config/estate-count-budget.json +13 -471
  122. package/src/config/evaluator-budgets.json +4 -4
  123. package/src/config/gate-coverage.yml +458 -12
  124. package/src/config/gate-violation-baselines.json +18 -8
  125. package/src/config/gitignore-block.txt +15 -2
  126. package/src/config/hook-latency-budget.json +5 -0
  127. package/src/config/publish-surface.json +60 -0
  128. package/src/config/requirements-trace-budget.json +32 -0
  129. package/src/config/rule-stub-ceilings.json +25 -4
  130. package/src/install/globalRuleLayers.ts +197 -0
  131. package/src/install/partitionEligibility.ts +177 -0
  132. package/src/install/ruleLayerPartition.ts +212 -0
  133. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  134. package/src/scripts/_dispatch.bash +18 -0
  135. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  136. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  137. package/src/scripts/_lib/base_tree.ts +175 -0
  138. package/src/scripts/_lib/billing_grant.ts +165 -0
  139. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  140. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  141. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  142. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  143. package/src/scripts/_lib/host_capability.ts +59 -5
  144. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  145. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  146. package/src/scripts/_lib/quota_parked.ts +133 -0
  147. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  148. package/src/scripts/_lib/review_telemetry.ts +196 -0
  149. package/src/scripts/_lib/subagent_response.ts +92 -0
  150. package/src/scripts/adr/evidence_census.ts +899 -0
  151. package/src/scripts/adr/regenerate_index.ts +25 -38
  152. package/src/scripts/adr_cite_check.ts +540 -39
  153. package/src/scripts/ai_council/clients.ts +12 -1
  154. package/src/scripts/ai_council/config.ts +72 -115
  155. package/src/scripts/ai_council/fallback_config.ts +19 -7
  156. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  157. package/src/scripts/ai_council/orchestrator.ts +2 -2
  158. package/src/scripts/ai_council/py_format.ts +124 -0
  159. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  160. package/src/scripts/audit_adr_coverage.ts +134 -21
  161. package/src/scripts/bench_hook_latency.ts +204 -0
  162. package/src/scripts/build_archive_index.ts +34 -0
  163. package/src/scripts/check_adr_frontmatter.ts +574 -39
  164. package/src/scripts/check_council_pin_staleness.ts +300 -0
  165. package/src/scripts/check_estate_count.ts +420 -293
  166. package/src/scripts/check_gate_coverage.ts +91 -0
  167. package/src/scripts/check_hook_bundle_content.ts +219 -0
  168. package/src/scripts/check_new_adr_evidence.ts +529 -0
  169. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  170. package/src/scripts/check_pack_size.ts +234 -1
  171. package/src/scripts/check_publish_surface.ts +297 -0
  172. package/src/scripts/check_references.ts +18 -0
  173. package/src/scripts/check_requirements_trace.ts +378 -0
  174. package/src/scripts/check_rule_layer_partition.ts +531 -0
  175. package/src/scripts/check_secret_leak.ts +166 -12
  176. package/src/scripts/check_single_delivery.ts +137 -4
  177. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  178. package/src/scripts/code_graph/cli.ts +44 -5
  179. package/src/scripts/code_graph/extract.ts +87 -0
  180. package/src/scripts/condense.ts +61 -91
  181. package/src/scripts/council_cli.ts +12 -16
  182. package/src/scripts/detect_ai_tells.ts +87 -0
  183. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  184. package/src/scripts/external_sources_denylist.json +4 -1
  185. package/src/scripts/generate_capabilities_index.ts +41 -0
  186. package/src/scripts/git_authorization_hook.ts +54 -4
  187. package/src/scripts/hooks/block_unauthorized_git.ts +41 -3
  188. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  189. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  190. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  191. package/src/scripts/hooks/host_semantics.ts +21 -0
  192. package/src/scripts/hooks/injection_budget.ts +13 -3
  193. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  194. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  195. package/src/scripts/hooks/state_io.ts +88 -1
  196. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  197. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  198. package/src/scripts/injection_scan_hook.ts +257 -15
  199. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  200. package/src/scripts/lint_hook_manifest.ts +49 -0
  201. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  202. package/src/scripts/lint_workflow_security.ts +53 -10
  203. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  204. package/src/scripts/report_envelope_rate.ts +166 -0
  205. package/src/scripts/review_axis_report.ts +218 -0
  206. package/src/scripts/routing_doctor.ts +5 -1
  207. package/src/scripts/run_supervise.ts +46 -1
  208. package/src/scripts/schemas/evals.schema.json +5 -0
  209. package/src/scripts/sync_pr_branch.ts +170 -20
  210. package/dist/cli/commands/commands.candidates.test.js +0 -257
  211. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  212. package/dist/cli/commands/profiles.test.js +0 -88
  213. package/dist/cli/commands/profiles.test.js.map +0 -1
  214. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  215. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  216. package/dist/cli/configRoot.test.js +0 -97
  217. package/dist/cli/configRoot.test.js.map +0 -1
  218. package/dist/cli/mcp/content.test.js +0 -54
  219. package/dist/cli/mcp/content.test.js.map +0 -1
  220. package/dist/cli/mcp/dispatch.test.js +0 -108
  221. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  222. package/dist/server/writeRoot.test.js +0 -91
  223. package/dist/server/writeRoot.test.js.map +0 -1
  224. package/dist/shared/capabilities.test.js +0 -39
  225. package/dist/shared/capabilities.test.js.map +0 -1
  226. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  227. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  228. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  229. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  230. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -135,6 +135,7 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
135
135
  return new Set();
136
136
  }
137
137
  const names = new Set<string>();
138
+ const nudgeRanks = new Map<number, string[]>();
138
139
  for (const [name, spec] of Object.entries(concernsRaw)) {
139
140
  if (!isObject(spec)) {
140
141
  errors.push(
@@ -213,8 +214,56 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
213
214
  }
214
215
  }
215
216
  }
217
+ // `nudge_rank:` — the nudge-exclusivity ordering `injection_budget`
218
+ // arbitrates on. Validated here because `injection_budget._selectNudge`
219
+ // says so in as many words: "a tie is a manifest defect rather than a
220
+ // runtime one, and `lint_hook_manifest` is where it should eventually be
221
+ // caught — that check does not exist yet and this comment is the honest
222
+ // statement of the gap". This is that check.
223
+ //
224
+ // A tie is not a crash. `_selectNudge` keeps the lowest rank and breaks
225
+ // ties on concern name, so a collision resolves stably and arbitrarily:
226
+ // one nudge silently wins forever, and the author who added the second one
227
+ // gets no signal that their concern can never emit. That is the failure
228
+ // mode — silent, not loud.
229
+ if ("nudge_rank" in spec) {
230
+ const rank = spec["nudge_rank"];
231
+ if (typeof rank !== "number" || !Number.isInteger(rank) || rank < 1) {
232
+ errors.push(
233
+ `concerns.${name}: 'nudge_rank' must be a positive integer ` +
234
+ `(got ${pyTypeName(rank)}) — the dispatcher keeps the LOWEST rank, ` +
235
+ `so a malformed value reads as "never wins" rather than as an error`,
236
+ );
237
+ } else {
238
+ const seen = nudgeRanks.get(rank);
239
+ if (seen) {
240
+ seen.push(name);
241
+ } else {
242
+ nudgeRanks.set(rank, [name]);
243
+ }
244
+ }
245
+ }
216
246
  names.add(name);
217
247
  }
248
+ // Uniqueness is enforced GLOBALLY, not per event, and that is a deliberate
249
+ // choice rather than an accident of what is easy to compute. `_selectNudge`
250
+ // compares candidates within one dispatch, so two concerns bound to disjoint
251
+ // events could share a rank harmlessly and the accurate invariant is
252
+ // per-event. Global uniqueness is the strictly-safe superset: it costs
253
+ // nothing at today's two declared ranks, and an author reusing a rank across
254
+ // events is exactly the confusion worth refusing. If a legitimate design
255
+ // ever needs the same rank on two events, NARROW this to concerns sharing at
256
+ // least one bound event — do not delete it.
257
+ for (const [rank, holders] of nudgeRanks) {
258
+ if (holders.length > 1) {
259
+ errors.push(
260
+ `concerns: nudge_rank ${rank} is declared by ${holders.length} concerns ` +
261
+ `(${holders.slice().sort().join(", ")}) — ranks must be unique, because ` +
262
+ `the dispatcher breaks a tie on concern name and the loser can then ` +
263
+ `never emit`,
264
+ );
265
+ }
266
+ }
218
267
  return names;
219
268
  }
220
269
 
@@ -26,18 +26,42 @@
26
26
  * 3. Number cross-check — every N/D figure inside a scope box MUST also
27
27
  * appear, verbatim, somewhere in `docs/CLAIMS.md` — a box number that
28
28
  * drifts from the ledger is a build failure, not a doc nit.
29
+ * 4. Permanence language — an ADR record MUST NOT assert that its decision
30
+ * is permanent in a load-bearing position (its `# ` title, its
31
+ * `decision:` slug, its `## Decision` section, an `Addendum`). ADR-239
32
+ * § 6 is the doctrine and ADR-208 the standing demonstration: its title
33
+ * says the tree is kept forever, its Decision says KEEP permanently, and
34
+ * its own frontmatter carries the conditions under which it is reopened.
35
+ * A record cannot be both. Two escapes, both enforced here: permanence
36
+ * scoped to an EXTERNAL invariant with the condition under which that
37
+ * invariant stops applying stated in the same section, or an owner
38
+ * purpose statement recorded as `authority_basis: owner_intent`.
39
+ * Describing positions — `## Alternatives considered`, `## Consequences`,
40
+ * `## Context`, an `## Amendment`, a quoted open question, a table
41
+ * rationale cell — are NOT violations.
29
42
  *
30
43
  * Scan surfaces: README.md + every `docs/**\/*.md` file (recursive). No
31
44
  * marketing/install-output text file carries this vocabulary today — this is
32
- * the single place to widen SCAN_ROOTS when one does.
45
+ * the single place to widen SCAN_ROOTS when one does. Rule 4 adds no root: it
46
+ * filters the already-walked `docs/**` set down to ADR records
47
+ * ({@link isAdrRecord}) rather than widening the walk, which is why it lives
48
+ * here instead of in a new gate script (a new gate costs seven wiring
49
+ * surfaces for the same coverage).
33
50
  *
34
- * Exit codes: 0 clean · 1 violations found.
51
+ * Two verdicts. Rules 1-3 hard-fail. Rule 4 is a RATCHET against
52
+ * `lint_provenance_vocabulary:permanence-language` in
53
+ * `src/config/gate-violation-baselines.json`, because the corpus carries real
54
+ * violations today and a gate that lands as five instant blockers is a shape
55
+ * this repository has already refused. A new record still hard-fails.
56
+ *
57
+ * Exit codes: 0 clean · 1 violations found (either verdict).
35
58
  */
36
59
 
37
60
  import * as fs from 'node:fs';
38
61
  import * as path from 'node:path';
39
62
  import { fileURLToPath, pathToFileURL } from 'node:url';
40
63
 
64
+ import { checkRatchet } from './_lib/gate_baseline.js';
41
65
  import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
42
66
 
43
67
  const _HERE = fileURLToPath(import.meta.url);
@@ -362,6 +386,387 @@ export function lintFile(text: string, file: string, claimsText: string): Violat
362
386
  ];
363
387
  }
364
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // 4. Permanence language in ADR records
391
+ // ---------------------------------------------------------------------------
392
+
393
+ /**
394
+ * ADR record roots.
395
+ *
396
+ * Both already sit under `docs`, which {@link SCAN_ROOTS} walks — these roots
397
+ * do NOT widen the walk, they IDENTIFY which of the already-read files is an
398
+ * ADR record. Every other `docs/**` file is untouched by this rule.
399
+ */
400
+ export const ADR_RECORD_ROOTS: readonly string[] = ['docs/decisions', 'docs/adrs'];
401
+
402
+ /** Ratchet key in `src/config/gate-violation-baselines.json`. */
403
+ export const PERMANENCE_GATE = 'lint_provenance_vocabulary:permanence-language';
404
+
405
+ /**
406
+ * True for a flat `docs/decisions/ADR-*.md` record or a per-area
407
+ * `docs/adrs/<area>/<n>-*.md` record. A per-area `README.md` is an index, not
408
+ * a record, and is excluded on both surfaces — so are the non-ADR documents
409
+ * that share `docs/decisions/` (sweep artifacts, reclassification logs).
410
+ */
411
+ export function isAdrRecord(rel: string): boolean {
412
+ const posix = rel.split(path.sep).join('/');
413
+ const base = posix.slice(posix.lastIndexOf('/') + 1);
414
+ if (!base.endsWith('.md') || base === 'README.md') return false;
415
+ if (posix.startsWith('docs/decisions/')) return /^ADR-/.test(base);
416
+ if (posix.startsWith('docs/adrs/')) return /^\d/.test(base);
417
+ return false;
418
+ }
419
+
420
+ /**
421
+ * The four positions where a permanence claim is LOAD-BEARING — the record is
422
+ * asserting permanence rather than describing, historicising, or rejecting it.
423
+ *
424
+ * - `title` — the `# ` heading; it is the record's name in every citation.
425
+ * - `slug` — the `decision:` frontmatter value; machine-visible, so a
426
+ * permanence claim there is carried by every tool that reads the field.
427
+ * - `decision` — the `## Decision` section's own prose.
428
+ * - `addendum` — an `Addendum` heading at any level. Both corpus instances
429
+ * (ADR-122, ADR-124) assert a permanently-off default, which is the same
430
+ * claim shape as ADR-208's Decision, so they are in scope. `Amendment` is
431
+ * deliberately NOT: an amendment revises or historicises an earlier claim.
432
+ *
433
+ * Everything else is out of scope by construction: `## Alternatives
434
+ * considered` rejects a permanence option, `## Consequences` reports one,
435
+ * `## Context` recounts one, and an `## Open question` quotes one. Scoping is
436
+ * the whole gate — the same word list applied to whole files matches 41 lines
437
+ * across 30 records, and the large majority of them are legitimate.
438
+ */
439
+ export type PermanenceRegion = 'title' | 'slug' | 'decision' | 'addendum';
440
+
441
+ interface Region {
442
+ kind: PermanenceRegion;
443
+ /** First line index of the region (0-based, inclusive). */
444
+ start: number;
445
+ /** Last line index of the region (0-based, inclusive). */
446
+ end: number;
447
+ }
448
+
449
+ const FM_DELIM_RE = /^---[ \t]*$/;
450
+ const ANY_HEADING_RE = /^#{1,6}[ \t]+/;
451
+ const H1_RE = /^#[ \t]+/;
452
+ const DECISION_KEY_RE = /^decision:/;
453
+ // `## Decision`, `## Decisions`, `## Decision — GO`, `## Decision (Reading B)`,
454
+ // `### Decision`. Deliberately NOT `### Decision matrix` / `### Decision owner`
455
+ // / `### The owner's decision`: the word has to BE the heading, not open it.
456
+ const DECISION_HEADING_RE = /^#{2,6}[ \t]+Decisions?[ \t]*(?:[—–(:\-]|$)/i;
457
+ const ADDENDUM_HEADING_RE = /^#{2,6}[ \t]+Addendum\b/i;
458
+
459
+ /**
460
+ * Escape 2 — an owner purpose statement, keyed on the SCHEMA field.
461
+ *
462
+ * This originally matched a bare `owner_intent: currently binding` key, which
463
+ * the roadmap's own draft prose named and which no schema declares anywhere in
464
+ * the tree (`grep -rn owner_intent docs/ src/` found it only in that prose). It
465
+ * was usable — nothing rejects an unknown frontmatter key — and that is exactly
466
+ * what made it wrong: a lint reading a field the validator does not know is a
467
+ * second vocabulary for one concept, and the tree would have taught two.
468
+ *
469
+ * `authority_basis: owner_intent` is the declared field
470
+ * (`adr-layout § Provenance and evidence`, validated by
471
+ * `check_adr_frontmatter.check_descriptive_axes`), and it already carries the
472
+ * temporal meaning the phrase was reaching for: the burden table reads it as
473
+ * "binding until the owner changes it". So one field, already validated, rather
474
+ * than a phrase that only this lint could see.
475
+ */
476
+ const OWNER_INTENT_ESCAPE_RE = /^[ \t]*authority_basis:[ \t]*owner_intent[ \t]*$/m;
477
+
478
+ /** The literal an owner purpose statement writes instead of "forever". */
479
+ export const OWNER_INTENT_ESCAPE = 'authority_basis: owner_intent';
480
+
481
+ /** The YAML frontmatter block, or '' when the record carries none. */
482
+ function _frontmatterText(lines: readonly string[]): string {
483
+ if (lines.length === 0 || !FM_DELIM_RE.test(lines[0] ?? '')) return '';
484
+ for (let i = 1; i < lines.length; i++) {
485
+ if (FM_DELIM_RE.test(lines[i] ?? '')) return lines.slice(1, i).join('\n');
486
+ }
487
+ return '';
488
+ }
489
+
490
+ /**
491
+ * Single permanence words. `permanent` is matched only in a **predicative**
492
+ * position (see {@link PROSE_WORD_RES}) because attributively it names a
493
+ * thing, not the decision: "two permanent regression fixtures" (ADR-126) and
494
+ * "permanent migration stubs" (ADR-057) are objects that happen to persist.
495
+ */
496
+ export const PERMANENCE_WORDS: readonly string[] = ['forever', 'permanently', 'permanent'];
497
+
498
+ /**
499
+ * Multi-word equivalents. The first three are named by ADR-239 § 6;
500
+ * `now or in the future` is the corpus instance that made the list necessary
501
+ * (ADR-108's Decision statement says it instead of "forever"). `in perpetuity`
502
+ * and `for all time` have zero corpus hits today and are listed because they
503
+ * are the standard idioms for the same claim.
504
+ */
505
+ export const PERMANENCE_PHRASES: readonly string[] = [
506
+ 'never revisit',
507
+ 'never reconsider',
508
+ 'settled forever',
509
+ 'now or in the future',
510
+ 'in perpetuity',
511
+ 'for all time',
512
+ ];
513
+
514
+ /** Prose matchers. `forever` / `permanently` reject a trailing hyphen so a
515
+ * compound modifier ("forever-incomplete", "permanently-parked") describing
516
+ * something else is not read as a permanence claim about the decision. */
517
+ const PROSE_WORD_RES: readonly RegExp[] = [
518
+ /(?<![A-Za-z0-9])forever(?![A-Za-z0-9-])/gi,
519
+ /(?<![A-Za-z0-9])permanently(?![A-Za-z0-9-])/gi,
520
+ /(?<![A-Za-z0-9])permanent(?=[ \t]*(?:[.,;:!?)*\]]|$))/gi,
521
+ ];
522
+
523
+ const PHRASE_RES: readonly RegExp[] = PERMANENCE_PHRASES.map(
524
+ (p) =>
525
+ new RegExp(
526
+ `(?<![A-Za-z0-9])${p.split(' ').map(_escapeRegExp).join('[ \\t]+')}(?![A-Za-z0-9])`,
527
+ 'gi',
528
+ ),
529
+ );
530
+
531
+ /** A hyphen-joined slug carries its words without spaces, so the slug region
532
+ * matches on hyphen boundaries too (`dist-agent-src-keep-forever`). */
533
+ const SLUG_RE = /(?<![A-Za-z0-9])(?:forever|permanent(?:ly)?)(?![A-Za-z0-9])/gi;
534
+
535
+ const TABLE_ROW_RE = /^[ \t]*\|/;
536
+ const LIST_ITEM_RE = /^[ \t]*(?:[-*+]|\d+[.)])[ \t]/;
537
+ // A sentence break, tolerating a bold/paren/backtick close after the stop.
538
+ const SENTENCE_SPLIT_RE = /(?<=[.!?][)*"'\]`]{0,3})[ \t]+(?=[A-Z*`[("'])/g;
539
+
540
+ /**
541
+ * Negation and conditional markers that make a permanence word DESCRIPTIVE.
542
+ *
543
+ * Deliberately short. A wider list was tried and rejected against the corpus:
544
+ * `would` kills ADR-107's live claim ("commercial ship **would** require …"
545
+ * sits two sentences from "the suite is open-source forever"), and a bare `no`
546
+ * kills ADR-108's ("There is **no** commercial tier … now or in the future").
547
+ * Both are real violations, so a marker that silences them is worse than the
548
+ * noise it removes.
549
+ */
550
+ const NEGATION_RE =
551
+ /\b(?:cannot|can't|could not|couldn't|does not|doesn't|do not|don't|did not|is not|isn't|are not|aren't|was not|were not|no longer|rather than|instead of|not a|not an|not the|neither)\b/i;
552
+ const CONDITIONAL_RE = /(?:^|[\s(*_"'`])(?:if|unless)\b/i;
553
+
554
+ /** Escape 1, half one: the permanence is scoped to an EXTERNAL invariant. */
555
+ const EXTERNAL_INVARIANT_RE =
556
+ /\b(?:protocol|api compatibility|wire format|statutory|regulatory|legal obligation|licen[sc]e|licensing|contractual|upstream|external constraint|standards? body|rfc[ \t]*\d+)\b/i;
557
+ /** Escape 1, half two: the condition under which that invariant stops. */
558
+ const STOP_CONDITION_RE =
559
+ /\b(?:stops? applying|no longer applies|ceases? to apply|until|reopen(?:s|ed)?[ \t]+when|revisit(?:ed)?[ \t]+(?:if|when)|is[ \t]+retired|is[ \t]+withdrawn|expires?)\b/i;
560
+
561
+ const PERMANENCE_CONSEQUENCE_BOUND =
562
+ 'a record cannot both assert permanence and carry the conditions under which it ' +
563
+ 'is reopened (ADR-239 § 6; ADR-208 is the standing demonstration). Two escapes: ' +
564
+ 'scope the permanence to an external invariant AND state in the same section when ' +
565
+ `that invariant stops applying, or record an owner purpose statement as \`${OWNER_INTENT_ESCAPE}\``;
566
+
567
+ /** Split a record into its load-bearing regions. */
568
+ export function permanenceRegions(lines: readonly string[]): Region[] {
569
+ const regions: Region[] = [];
570
+ let bodyStart = 0;
571
+
572
+ if (lines.length > 0 && FM_DELIM_RE.test(lines[0] ?? '')) {
573
+ let close = -1;
574
+ for (let i = 1; i < lines.length; i++) {
575
+ if (FM_DELIM_RE.test(lines[i] ?? '')) {
576
+ close = i;
577
+ break;
578
+ }
579
+ }
580
+ if (close > 0) {
581
+ bodyStart = close + 1;
582
+ for (let i = 1; i < close; i++) {
583
+ if (!DECISION_KEY_RE.test(lines[i] ?? '')) continue;
584
+ // A folded value (`decision: >-`) continues on indented lines.
585
+ let end = i;
586
+ for (let j = i + 1; j < close; j++) {
587
+ if (!/^[ \t]+\S/.test(lines[j] ?? '')) break;
588
+ end = j;
589
+ }
590
+ regions.push({ kind: 'slug', start: i, end });
591
+ break;
592
+ }
593
+ }
594
+ }
595
+
596
+ let open: Region | null = null;
597
+ for (let i = bodyStart; i < lines.length; i++) {
598
+ const line = lines[i] ?? '';
599
+ if (!ANY_HEADING_RE.test(line)) continue;
600
+ if (open !== null) {
601
+ open.end = i - 1;
602
+ regions.push(open);
603
+ open = null;
604
+ }
605
+ if (H1_RE.test(line)) {
606
+ regions.push({ kind: 'title', start: i, end: i });
607
+ continue;
608
+ }
609
+ if (DECISION_HEADING_RE.test(line)) {
610
+ open = { kind: 'decision', start: i, end: lines.length - 1 };
611
+ continue;
612
+ }
613
+ if (ADDENDUM_HEADING_RE.test(line)) {
614
+ open = { kind: 'addendum', start: i, end: lines.length - 1 };
615
+ }
616
+ }
617
+ if (open !== null) regions.push(open);
618
+ return regions;
619
+ }
620
+
621
+ /**
622
+ * The sentence containing the hit.
623
+ *
624
+ * Hard-wrapped prose puts a sentence's start on a previous line (ADR-055's
625
+ * "If Step 8 slips," is one line above its "permanent"), so the paragraph is
626
+ * rejoined first. The walk stops at a list-item start in both directions: the
627
+ * corpus writes numbered Decision clauses one item per line, and merging them
628
+ * imports a neighbour's markers into this item's verdict.
629
+ */
630
+ function _sentenceAround(
631
+ lines: readonly string[],
632
+ region: Region,
633
+ lineIdx: number,
634
+ col: number,
635
+ ): string {
636
+ let top = lineIdx;
637
+ while (
638
+ top > region.start &&
639
+ !LIST_ITEM_RE.test(lines[top] ?? '') &&
640
+ (lines[top - 1] ?? '').trim() !== '' &&
641
+ !ANY_HEADING_RE.test(lines[top - 1] ?? '') &&
642
+ !TABLE_ROW_RE.test(lines[top - 1] ?? '')
643
+ ) {
644
+ top -= 1;
645
+ }
646
+ let bottom = lineIdx;
647
+ while (
648
+ bottom < region.end &&
649
+ (lines[bottom + 1] ?? '').trim() !== '' &&
650
+ !ANY_HEADING_RE.test(lines[bottom + 1] ?? '') &&
651
+ !LIST_ITEM_RE.test(lines[bottom + 1] ?? '') &&
652
+ !TABLE_ROW_RE.test(lines[bottom + 1] ?? '')
653
+ ) {
654
+ bottom += 1;
655
+ }
656
+
657
+ let joined = '';
658
+ let hitOffset = 0;
659
+ for (let i = top; i <= bottom; i++) {
660
+ const raw = lines[i] ?? '';
661
+ const sep = joined === '' ? '' : ' ';
662
+ if (i === lineIdx) {
663
+ const lead = raw.length - raw.trimStart().length;
664
+ hitOffset = joined.length + sep.length + Math.max(0, col - lead);
665
+ }
666
+ joined += sep + raw.trim();
667
+ }
668
+
669
+ const parts: Array<{ text: string; start: number }> = [];
670
+ let cursor = 0;
671
+ let m: RegExpExecArray | null;
672
+ SENTENCE_SPLIT_RE.lastIndex = 0;
673
+ while ((m = SENTENCE_SPLIT_RE.exec(joined)) !== null) {
674
+ parts.push({ text: joined.slice(cursor, m.index), start: cursor });
675
+ cursor = m.index + m[0].length;
676
+ }
677
+ parts.push({ text: joined.slice(cursor), start: cursor });
678
+ for (const p of parts) {
679
+ if (hitOffset >= p.start && hitOffset < p.start + p.text.length) return p.text;
680
+ }
681
+ return joined;
682
+ }
683
+
684
+ interface RawHit {
685
+ term: string;
686
+ index: number;
687
+ }
688
+
689
+ function _hits(line: string, res: readonly RegExp[]): RawHit[] {
690
+ const out: RawHit[] = [];
691
+ for (const re of res) {
692
+ re.lastIndex = 0;
693
+ let m: RegExpExecArray | null;
694
+ while ((m = re.exec(line)) !== null) {
695
+ out.push({ term: m[0], index: m.index });
696
+ if (m.index === re.lastIndex) re.lastIndex += 1;
697
+ }
698
+ }
699
+ return out;
700
+ }
701
+
702
+ /**
703
+ * Rule 4: permanence language in an ADR record's load-bearing positions.
704
+ *
705
+ * Returns [] for a file {@link isAdrRecord} rejects, so a caller may hand it
706
+ * any prose file.
707
+ */
708
+ export function permanenceViolations(text: string, file: string): Violation[] {
709
+ if (!isAdrRecord(file)) return [];
710
+
711
+ const rawLines = text.split('\n');
712
+ // Fence-stripped, index-preserving: a `## Decision` inside a code fence is
713
+ // an example, not a section, and must not open a region.
714
+ const prose = _stripFences(rawLines);
715
+ const regions = permanenceRegions(prose);
716
+ const ownerIntentEscape = OWNER_INTENT_ESCAPE_RE.test(_frontmatterText(rawLines));
717
+
718
+ const violations: Violation[] = [];
719
+ for (const region of regions) {
720
+ // The slug is machine-visible in every citation and hyphen-joined, so
721
+ // it takes the unconditional match and neither escape: a field cannot
722
+ // qualify a name that tools print without reading the field.
723
+ if (region.kind === 'slug') {
724
+ for (let i = region.start; i <= region.end; i++) {
725
+ for (const hit of _hits(prose[i] ?? '', [SLUG_RE])) {
726
+ violations.push({
727
+ file,
728
+ rule: 'permanence-language',
729
+ msg:
730
+ `line ${i + 1}: permanence language "${hit.term}" in the \`decision:\` slug — ` +
731
+ `${PERMANENCE_CONSEQUENCE_BOUND} (the slug takes neither escape)`,
732
+ });
733
+ }
734
+ }
735
+ continue;
736
+ }
737
+
738
+ const regionText = prose.slice(region.start, region.end + 1).join('\n');
739
+ const hasStopCondition = STOP_CONDITION_RE.test(regionText);
740
+
741
+ for (let i = region.start; i <= region.end; i++) {
742
+ const line = prose[i] ?? '';
743
+ const isProseRegion = region.kind === 'decision' || region.kind === 'addendum';
744
+ // A table cell is a per-row detail with its own rationale column,
745
+ // not the decision statement (ADR-074's "probing forever" is a
746
+ // failure mode being bounded, in the rationale column).
747
+ if (isProseRegion && TABLE_ROW_RE.test(line)) continue;
748
+
749
+ for (const hit of _hits(line, [...PROSE_WORD_RES, ...PHRASE_RES])) {
750
+ if (_isQuotedSpan(line, hit.index)) continue;
751
+ if (isProseRegion) {
752
+ if (ownerIntentEscape) continue;
753
+ const sentence = _sentenceAround(prose, region, i, hit.index);
754
+ if (NEGATION_RE.test(sentence) || CONDITIONAL_RE.test(sentence)) continue;
755
+ if (hasStopCondition && EXTERNAL_INVARIANT_RE.test(sentence)) continue;
756
+ }
757
+ violations.push({
758
+ file,
759
+ rule: 'permanence-language',
760
+ msg:
761
+ `line ${i + 1}: permanence language "${hit.term}" in the record's ${region.kind} — ` +
762
+ `${PERMANENCE_CONSEQUENCE_BOUND} (context: ${line.trim().slice(0, 90)})`,
763
+ });
764
+ }
765
+ }
766
+ }
767
+ return violations;
768
+ }
769
+
365
770
  // ---------------------------------------------------------------------------
366
771
  // Filesystem walk + CLI
367
772
  // ---------------------------------------------------------------------------
@@ -404,6 +809,42 @@ export function lintProvenanceVocabulary(repoRoot: string = REPO_ROOT): Violatio
404
809
  return violations;
405
810
  }
406
811
 
812
+ /**
813
+ * Rule 4 over the ADR corpus.
814
+ *
815
+ * Separate from {@link lintProvenanceVocabulary} because the two carry
816
+ * different verdicts: rules 1-3 hard-fail, rule 4 is ratcheted against
817
+ * `${PERMANENCE_GATE}` in `src/config/gate-violation-baselines.json` because
818
+ * the corpus contains real violations today (ADR-107, ADR-108, ADR-122,
819
+ * ADR-124, ADR-208) and a gate that lands as five instant blockers is a shape
820
+ * this repository has refused before. A NEW record still hard-fails: the
821
+ * ratchet only turns one way.
822
+ */
823
+ export function lintAdrPermanence(repoRoot: string = REPO_ROOT): Violation[] {
824
+ const records: string[] = [];
825
+ for (const root of ADR_RECORD_ROOTS) {
826
+ for (const abs of _collectMd(path.join(repoRoot, root))) {
827
+ const rel = path.relative(repoRoot, abs);
828
+ if (isAdrRecord(rel)) records.push(abs);
829
+ }
830
+ }
831
+ // A `## Decision`-scoped rule that read zero ADR records would print a
832
+ // green line while enforcing nothing — the exact failure the shared
833
+ // scope-assert exists for.
834
+ assertScanned({
835
+ gate: PERMANENCE_GATE,
836
+ scanned: records.length,
837
+ units: 'ADR record(s)',
838
+ roots: ADR_RECORD_ROOTS,
839
+ });
840
+ const violations: Violation[] = [];
841
+ for (const abs of records.sort()) {
842
+ const rel = path.relative(repoRoot, abs);
843
+ violations.push(...permanenceViolations(fs.readFileSync(abs, 'utf-8'), rel));
844
+ }
845
+ return violations;
846
+ }
847
+
407
848
  function _print(quiet: boolean, msg: string): void {
408
849
  if (!quiet) process.stdout.write(`${msg}\n`);
409
850
  }
@@ -411,8 +852,10 @@ function _print(quiet: boolean, msg: string): void {
411
852
  export function main(): number {
412
853
  const quiet = process.argv.slice(2).includes('--quiet');
413
854
  let violations: Violation[];
855
+ let permanence: Violation[];
414
856
  try {
415
857
  violations = lintProvenanceVocabulary();
858
+ permanence = lintAdrPermanence();
416
859
  } catch (e) {
417
860
  // 1 is the only failure code this gate defines, so a dead scan root and
418
861
  // a real violation share it — the message distinguishes them.
@@ -422,14 +865,39 @@ export function main(): number {
422
865
  }
423
866
  throw e;
424
867
  }
868
+
869
+ let failed = false;
425
870
  if (violations.length > 0) {
426
871
  for (const v of violations) {
427
872
  process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
428
873
  }
429
874
  process.stderr.write(`\n❌ provenance-vocabulary — ${violations.length} violation(s)\n`);
430
- return 1;
875
+ failed = true;
876
+ }
877
+
878
+ // Rule 4 reports every finding either way: at baseline they are the known
879
+ // debt this ratchet is walking down, and printing them is what makes the
880
+ // next lowering possible.
881
+ const verdict = checkRatchet({
882
+ gate: PERMANENCE_GATE,
883
+ actual: permanence.length,
884
+ repoRoot: REPO_ROOT,
885
+ });
886
+ if (!verdict.ok) {
887
+ for (const v of permanence) {
888
+ process.stderr.write(`❌ [${v.rule}] ${v.file}: ${v.msg}\n`);
889
+ }
890
+ process.stderr.write(`\n❌ ${verdict.message}\n`);
891
+ failed = true;
892
+ } else if (!quiet) {
893
+ for (const v of permanence) {
894
+ process.stdout.write(` [${v.rule}] ${v.file}: ${v.msg}\n`);
895
+ }
896
+ _print(quiet, `✅ ${verdict.message}`);
431
897
  }
432
- _print(quiet, '✅ provenance-vocabulary — no banned phrases, every approved-term use is co-located with a valid scope box, every box figure matches docs/CLAIMS.md');
898
+
899
+ if (failed) return 1;
900
+ _print(quiet, '✅ provenance-vocabulary — no banned phrases, every approved-term use is co-located with a valid scope box, every box figure matches docs/CLAIMS.md, no new permanence language in an ADR record');
433
901
  return 0;
434
902
  }
435
903
 
@@ -17,8 +17,17 @@
17
17
  * permissions: write-all;
18
18
  * npm install / npm ci without --ignore-scripts in a
19
19
  * pull_request_target workflow.
20
- * MEDIUM — third-party actions pinned by mutable tag instead of full SHA
21
- * (first-party actions/* are skipped).
20
+ * MEDIUM — actions pinned by mutable tag instead of full SHA;
21
+ * an actions/checkout step that keeps its credential.
22
+ *
23
+ * The MEDIUM tier's original wording read "third-party actions … (first-party
24
+ * actions/* are skipped)". Corrected 2026-08-22: the first-party exemption is
25
+ * GONE (see the removal note below), so the parenthetical documented an
26
+ * exemption that no longer exists — a false claim in a tracked artefact, which
27
+ * is the defect class the change that removed it was written to repair. The
28
+ * TIERS are untouched; only the description of what MEDIUM covers now matches
29
+ * the code. Re-tiering the locked model is a separate decision, recorded as
30
+ * unresolved in agents/evidence/analysis/workflow-security-net-degraded-decision.md.
22
31
  *
23
32
  * Script-injection detection (regex-based) is intentionally deferred — it
24
33
  * requires an AST-aware pass to avoid false positives on quoted / escaped
@@ -53,8 +62,23 @@ function _setAllowlistPathForTest(p: string): void {
53
62
  // Triggers that expose the repository token to untrusted pull-request context
54
63
  const DANGEROUS_TRIGGERS: ReadonlySet<string> = new Set(['pull_request_target', 'workflow_run']);
55
64
 
56
- // First-party action owners — mutable tags on these are acceptable
57
- const FIRST_PARTY_OWNERS: ReadonlySet<string> = new Set(['actions', 'github']);
65
+ // REMOVED 2026-08-22 (road-to-ci-supply-chain-integrity 1.4). This was
66
+ // `FIRST_PARTY_OWNERS = {'actions', 'github'}` and it exempted first-party
67
+ // owners from the mutable-tag rule. The premise — "mutable tags on these are
68
+ // acceptable" — is the one a supply-chain rule cannot hold: a first-party tag
69
+ // is still a mutable pointer, and `actions/checkout@v7` moving under the repo
70
+ // is the same class of event as any other owner's tag moving.
71
+ //
72
+ // What it cost, measured before removal: 100 of the 112 unpinned references in
73
+ // this tree were first-party, so the gate saw 12 of 112 defects and reported
74
+ // green on the rest. An exemption sized like that is not an exemption, it is
75
+ // the rule being off.
76
+ //
77
+ // The removal ships in the SAME change as the 112 pins, and in that order:
78
+ // pin first, then lift, so the transition is red-then-green inside one
79
+ // reviewable diff. Reverting the pins locally must make this gate exit
80
+ // non-zero — that is the assertion that the gate can still see the defect,
81
+ // which a green-only check cannot make.
58
82
 
59
83
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
60
84
  type JsonObject = { [k: string]: JsonValue };
@@ -262,13 +286,33 @@ function _mutable_third_party_actions(doc: JsonObject, _workflow_name: string):
262
286
  if (!uses || !uses.includes('@')) {
263
287
  continue;
264
288
  }
289
+ // `actions/checkout` leaves the repository token in the runner's git
290
+ // config for the rest of the job unless told not to. Measured before
291
+ // this rule existed: 0 of 50 checkouts in this tree set the flag, so
292
+ // every job carried a usable credential it did not need.
293
+ //
294
+ // `true` is a legitimate answer — two jobs here push — so the rule
295
+ // asks for the key to be PRESENT, not for a particular value. An
296
+ // explicit `true` is a reviewable decision; a missing key is an
297
+ // ambient one, and only the second is a finding.
298
+ if (uses.slice(0, uses.indexOf('@')).toLowerCase() === 'actions/checkout') {
299
+ const withObj = _asObject(stepObj['with'] as JsonValue);
300
+ const hasKey =
301
+ withObj !== null &&
302
+ Object.prototype.hasOwnProperty.call(withObj, 'persist-credentials');
303
+ if (!hasKey) {
304
+ findings.push({
305
+ severity: 'MEDIUM',
306
+ rule: 'persist-credentials',
307
+ detail:
308
+ 'actions/checkout without an explicit persist-credentials — ' +
309
+ 'the repository token stays in the runner git config for the rest of the job',
310
+ location: `job:${job_name}/step:${i + 1}`,
311
+ });
312
+ }
313
+ }
265
314
  const at = uses.indexOf('@');
266
- const action_ref = uses.slice(0, at);
267
315
  const pin = uses.slice(at + 1);
268
- const owner = action_ref.includes('/') ? (action_ref.split('/')[0] as string) : action_ref;
269
- if (FIRST_PARTY_OWNERS.has(owner.toLowerCase())) {
270
- continue;
271
- }
272
316
  // A full SHA pin is 40 hex chars; anything else is mutable
273
317
  if (pin.length === 40 && [...pin.toLowerCase()].every((c) => '0123456789abcdef'.includes(c))) {
274
318
  continue;
@@ -650,7 +694,6 @@ export {
650
694
  ALLOWLIST_PATH,
651
695
  ALLOWLIST_CAP,
652
696
  DANGEROUS_TRIGGERS,
653
- FIRST_PARTY_OWNERS,
654
697
  _setWorkflowsDirForTest,
655
698
  _setAllowlistPathForTest,
656
699
  load_allowlist,