@raishin/vanguard-frontier-agentic 3.15.0 → 3.17.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 (374) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +3 -1
  3. package/.cursor-plugin/plugin.json +3 -1
  4. package/.github/plugin/marketplace.json +1 -1
  5. package/README.md +11 -11
  6. package/agents/AGENTS.md +1 -1
  7. package/agents/README.md +1 -1
  8. package/agents/cross-functional/agentic-security-review-agent/AGENT.md +168 -0
  9. package/agents/cross-functional/agentic-security-review-agent/harnesses/claude-code.agent.md +158 -0
  10. package/agents/cross-functional/agentic-security-review-agent/harnesses/codex.toml +165 -0
  11. package/agents/cross-functional/agentic-security-review-agent/harnesses/copilot.agent.md +163 -0
  12. package/agents/cross-functional/agentic-security-review-agent/harnesses/cursor.agent.md +158 -0
  13. package/agents/cross-functional/agentic-security-review-agent/harnesses/gemini.agent.md +164 -0
  14. package/agents/cross-functional/agentic-security-review-agent/harnesses/kiro-cli.agent.json +5 -0
  15. package/agents/cross-functional/agentic-security-review-agent/harnesses/kiro-ide.agent.md +157 -0
  16. package/agents/cross-functional/agentic-security-review-agent/metadata.json +45 -0
  17. package/agents/cross-functional/agentic-system-architecture-review-agent/AGENT.md +157 -0
  18. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/claude-code.agent.md +147 -0
  19. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/codex.toml +154 -0
  20. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/copilot.agent.md +152 -0
  21. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/cursor.agent.md +147 -0
  22. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/gemini.agent.md +153 -0
  23. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/kiro-cli.agent.json +5 -0
  24. package/agents/cross-functional/agentic-system-architecture-review-agent/harnesses/kiro-ide.agent.md +146 -0
  25. package/agents/cross-functional/agentic-system-architecture-review-agent/metadata.json +46 -0
  26. package/agents/typescript/typescript-maestro-agent/AGENT.md +1 -1
  27. package/agents/typescript/typescript-maestro-agent/harnesses/claude-code.agent.md +1 -1
  28. package/agents/typescript/typescript-maestro-agent/harnesses/codex.toml +1 -1
  29. package/agents/typescript/typescript-maestro-agent/harnesses/copilot.agent.md +1 -1
  30. package/agents/typescript/typescript-maestro-agent/harnesses/cursor.agent.md +1 -1
  31. package/agents/typescript/typescript-maestro-agent/harnesses/gemini.agent.md +1 -1
  32. package/agents/typescript/typescript-maestro-agent/harnesses/kiro-cli.agent.json +1 -1
  33. package/agents/typescript/typescript-maestro-agent/harnesses/kiro-ide.agent.md +1 -1
  34. package/agents/typescript/typescript-mcp-tool-contract-agent/AGENT.md +1 -1
  35. package/agents/typescript/typescript-mcp-tool-contract-agent/harnesses/claude-code.agent.md +1 -1
  36. package/agents/typescript/typescript-mcp-tool-contract-agent/harnesses/copilot.agent.md +1 -1
  37. package/agents/typescript/typescript-mcp-tool-contract-agent/harnesses/cursor.agent.md +1 -1
  38. package/agents/typescript/typescript-mcp-tool-contract-agent/harnesses/gemini.agent.md +1 -1
  39. package/agents/typescript/typescript-mcp-tool-contract-agent/harnesses/kiro-cli.agent.json +1 -1
  40. package/agents/typescript/typescript-mcp-tool-contract-agent/harnesses/kiro-ide.agent.md +1 -1
  41. package/catalog/AGENTS.md +2 -1
  42. package/catalog/agents.json +71 -0
  43. package/catalog/asset-integrity.json +1639 -92
  44. package/catalog/index.json +3 -2
  45. package/catalog/install-roles.json +24 -3
  46. package/catalog/model-assignments.json +66 -0
  47. package/catalog/mods.json +131 -0
  48. package/catalog/skill-manifest.json +134 -8
  49. package/catalog/skills.json +89 -0
  50. package/mods/README.md +220 -0
  51. package/mods/audit-trail/README.md +70 -0
  52. package/mods/audit-trail/bin/hook.mjs +65 -0
  53. package/mods/audit-trail/claude-code/UNSUPPORTED.md +11 -0
  54. package/mods/audit-trail/claude-code/settings.hooks.json +14 -0
  55. package/mods/audit-trail/codex/hooks.json +14 -0
  56. package/mods/audit-trail/kiro/UNSUPPORTED.md +7 -0
  57. package/mods/audit-trail/kiro/audit-trail-post-tool-use-hook.json +14 -0
  58. package/mods/audit-trail/lib/common.mjs +213 -0
  59. package/mods/audit-trail/lib/core.mjs +50 -0
  60. package/mods/audit-trail/lib/runtime.mjs +140 -0
  61. package/mods/audit-trail/mod.toml +37 -0
  62. package/mods/blast-radius-guard/.claude-plugin/plugin.json +5 -0
  63. package/mods/blast-radius-guard/README.md +53 -0
  64. package/mods/blast-radius-guard/bin/hook.mjs +18 -0
  65. package/mods/blast-radius-guard/claude-code/settings.hooks.json +14 -0
  66. package/mods/blast-radius-guard/codex/UNSUPPORTED.md +7 -0
  67. package/mods/blast-radius-guard/codex/hooks.json +14 -0
  68. package/mods/blast-radius-guard/hooks/hooks.json +5 -0
  69. package/mods/blast-radius-guard/hooks/register.test.ts +97 -0
  70. package/mods/blast-radius-guard/hooks/register.ts +13 -0
  71. package/mods/blast-radius-guard/kiro/UNSUPPORTED.md +7 -0
  72. package/mods/blast-radius-guard/kiro/blast-radius-guard-pre-tool-use-hook.json +14 -0
  73. package/mods/blast-radius-guard/lib/common.mjs +213 -0
  74. package/mods/blast-radius-guard/lib/core.mjs +66 -0
  75. package/mods/blast-radius-guard/lib/patterns.mjs +102 -0
  76. package/mods/blast-radius-guard/lib/runtime.mjs +140 -0
  77. package/mods/blast-radius-guard/mod.toml +34 -0
  78. package/mods/blast-radius-guard/patterns.json +101 -0
  79. package/mods/context-burn/.claude-plugin/plugin.json +5 -0
  80. package/mods/context-burn/README.md +66 -0
  81. package/mods/context-burn/bin/hook.mjs +38 -0
  82. package/mods/context-burn/claude-code/UNSUPPORTED.md +7 -0
  83. package/mods/context-burn/claude-code/settings.hooks.json +24 -0
  84. package/mods/context-burn/codex/UNSUPPORTED.md +7 -0
  85. package/mods/context-burn/codex/hooks.json +24 -0
  86. package/mods/context-burn/hooks/hooks.json +5 -0
  87. package/mods/context-burn/hooks/register.test.ts +151 -0
  88. package/mods/context-burn/hooks/register.ts +35 -0
  89. package/mods/context-burn/kiro/UNSUPPORTED.md +7 -0
  90. package/mods/context-burn/kiro/context-burn-post-tool-use-hook.json +14 -0
  91. package/mods/context-burn/kiro/context-burn-session-start-hook.json +14 -0
  92. package/mods/context-burn/lib/common.mjs +213 -0
  93. package/mods/context-burn/lib/core.mjs +79 -0
  94. package/mods/context-burn/lib/runtime.mjs +140 -0
  95. package/mods/context-burn/mod.toml +37 -0
  96. package/mods/live-guard-gate/.claude-plugin/plugin.json +5 -0
  97. package/mods/live-guard-gate/README.md +43 -0
  98. package/mods/live-guard-gate/bin/hook.mjs +12 -0
  99. package/mods/live-guard-gate/claude-code/settings.hooks.json +14 -0
  100. package/mods/live-guard-gate/codex/UNSUPPORTED.md +7 -0
  101. package/mods/live-guard-gate/codex/hooks.json +14 -0
  102. package/mods/live-guard-gate/hooks/hooks.json +5 -0
  103. package/mods/live-guard-gate/hooks/register.test.ts +59 -0
  104. package/mods/live-guard-gate/hooks/register.ts +11 -0
  105. package/mods/live-guard-gate/kiro/UNSUPPORTED.md +7 -0
  106. package/mods/live-guard-gate/kiro/live-guard-gate-pre-tool-use-hook.json +14 -0
  107. package/mods/live-guard-gate/lib/common.mjs +213 -0
  108. package/mods/live-guard-gate/lib/core.mjs +24 -0
  109. package/mods/live-guard-gate/lib/live-agents.mjs +65 -0
  110. package/mods/live-guard-gate/lib/patterns.mjs +523 -0
  111. package/mods/live-guard-gate/lib/runtime.mjs +140 -0
  112. package/mods/live-guard-gate/mod.toml +34 -0
  113. package/mods/live-guard-gate/patterns.json +522 -0
  114. package/mods/secret-redactor/.claude-plugin/plugin.json +5 -0
  115. package/mods/secret-redactor/README.md +86 -0
  116. package/mods/secret-redactor/bin/hook.mjs +35 -0
  117. package/mods/secret-redactor/claude-code/UNSUPPORTED.md +7 -0
  118. package/mods/secret-redactor/claude-code/settings.hooks.json +24 -0
  119. package/mods/secret-redactor/codex/UNSUPPORTED.md +11 -0
  120. package/mods/secret-redactor/codex/hooks.json +14 -0
  121. package/mods/secret-redactor/hooks/hooks.json +5 -0
  122. package/mods/secret-redactor/hooks/register.test.ts +150 -0
  123. package/mods/secret-redactor/hooks/register.ts +19 -0
  124. package/mods/secret-redactor/kiro/UNSUPPORTED.md +11 -0
  125. package/mods/secret-redactor/kiro/secret-redactor-pre-tool-use-hook.json +14 -0
  126. package/mods/secret-redactor/lib/common.mjs +213 -0
  127. package/mods/secret-redactor/lib/core.mjs +61 -0
  128. package/mods/secret-redactor/lib/patterns.mjs +75 -0
  129. package/mods/secret-redactor/lib/runtime.mjs +140 -0
  130. package/mods/secret-redactor/mod.toml +43 -0
  131. package/mods/secret-redactor/patterns.json +74 -0
  132. package/package.json +9 -4
  133. package/plugins/vanguard-frontier-agentic/.codex-plugin/plugin.json +1 -1
  134. package/powers/vanguard-generic/POWER.md +6 -6
  135. package/powers/vanguard-kubernetes/POWER.md +1 -1
  136. package/powers/vanguard-multi-cloud/POWER.md +1 -1
  137. package/schemas/agentic-handoff.schema.json +443 -0
  138. package/scripts/generate-kiro-powers.mjs +17 -6
  139. package/scripts/generate-mods.mjs +784 -0
  140. package/scripts/mods/common.mjs +213 -0
  141. package/scripts/mods/runtime.mjs +140 -0
  142. package/scripts/typescript_data/agents/00-typescript-maestro-agent.json +2 -2
  143. package/scripts/typescript_data/agents/11-typescript-mcp-tool-contract-agent.json +4 -4
  144. package/skills/cross-functional/agentic-board-protocol/SKILL.md +85 -0
  145. package/skills/cross-functional/agentic-board-protocol/metadata.json +28 -0
  146. package/skills/cross-functional/agentic-board-protocol/references/decision-record.md +134 -0
  147. package/skills/cross-functional/agentic-board-protocol/references/handoff-envelope.md +208 -0
  148. package/skills/cross-functional/agentic-board-protocol/references/risk-tier-crosswalk.md +88 -0
  149. package/skills/cross-functional/agentic-board-protocol/references/stop-conditions.md +122 -0
  150. package/skills/cross-functional/agentic-board-protocol/references/value-case.md +159 -0
  151. package/skills/cross-functional/agentic-security-review/SKILL.md +78 -0
  152. package/skills/cross-functional/agentic-security-review/metadata.json +30 -0
  153. package/skills/cross-functional/agentic-security-review/references/identity-and-delegated-authorization.md +144 -0
  154. package/skills/cross-functional/agentic-security-review/references/supply-chain-and-provenance.md +111 -0
  155. package/skills/cross-functional/agentic-security-review/references/threat-model-owasp-asi.md +55 -0
  156. package/skills/cross-functional/agentic-security-review/references/tools-memory-and-data-reach.md +125 -0
  157. package/skills/cross-functional/agentic-security-review/references/workflow-and-output.md +210 -0
  158. package/skills/cross-functional/agentic-system-architecture-review/SKILL.md +74 -0
  159. package/skills/cross-functional/agentic-system-architecture-review/metadata.json +31 -0
  160. package/skills/cross-functional/agentic-system-architecture-review/references/delegation-and-handoff-review.md +107 -0
  161. package/skills/cross-functional/agentic-system-architecture-review/references/pattern-selection.md +81 -0
  162. package/skills/cross-functional/agentic-system-architecture-review/references/reliability-and-containment.md +86 -0
  163. package/skills/cross-functional/agentic-system-architecture-review/references/state-memory-and-durability.md +102 -0
  164. package/skills/cross-functional/agentic-system-architecture-review/references/workflow-and-output.md +135 -0
  165. package/skills/typescript/typescript-maestro/SKILL.md +1 -1
  166. package/skills/typescript/typescript-maestro/references/routing-taxonomy.md +1 -1
  167. package/skills/typescript/typescript-mcp-tool-contract/SKILL.md +1 -1
  168. package/tests/agentic-board-suite/_build_fixtures.py +1751 -0
  169. package/tests/agentic-board-suite/_build_harnesses.py +123 -0
  170. package/tests/agentic-board-suite/agentic_board_rules.py +857 -0
  171. package/tests/agentic-board-suite/fixtures/invalid/CH-PROPOSAL-LINK--challenge-names-a-later-envelope.json +244 -0
  172. package/tests/agentic-board-suite/fixtures/invalid/CH-PROPOSAL-LINK--dangling-challenge-beside-a-request.json +410 -0
  173. package/tests/agentic-board-suite/fixtures/invalid/CH-PROPOSAL-LINK--dangling-proposal.json +187 -0
  174. package/tests/agentic-board-suite/fixtures/invalid/CH-ROUND-COUNTED--round-two-declared-as-one.json +481 -0
  175. package/tests/agentic-board-suite/fixtures/invalid/CH-ROUND-COUNTED--self-declared-round.json +187 -0
  176. package/tests/agentic-board-suite/fixtures/invalid/DR-ALTERNATIVES--no-alternatives.json +340 -0
  177. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-COMPLETE--hard-gate-left-behind-by-proposer-swap.json +532 -0
  178. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-COMPLETE--hard-gate-left-unattached.json +480 -0
  179. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-COMPLETE--hard-gate-on-root-left-unattached.json +410 -0
  180. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-COMPLETE--late-challenge-before-record.json +410 -0
  181. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-COMPLETE--superseded-request-missed-earlier-challenge.json +565 -0
  182. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-PRECEDES--withdrawal-attached-before-it-was-sent.json +483 -0
  183. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-PRECEDES--withdrawal-descends-from-the-record.json +547 -0
  184. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-REQUIRED--r1-flag-without-challenge.json +349 -0
  185. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-REQUIRED--r2-without-challenge.json +339 -0
  186. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-REQUIRED--r3-no-flags-without-challenge.json +339 -0
  187. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-RESOLVES--dangling-challenge.json +347 -0
  188. package/tests/agentic-board-suite/fixtures/invalid/DR-CHALLENGE-TARGETS-PROPOSAL--challenge-of-requester-message.json +415 -0
  189. package/tests/agentic-board-suite/fixtures/invalid/DR-CLASS-FROM-ROOT--root-declares-no-class.json +345 -0
  190. package/tests/agentic-board-suite/fixtures/invalid/DR-CLASS-FROM-ROOT--root-declares-other-class.json +346 -0
  191. package/tests/agentic-board-suite/fixtures/invalid/DR-DESIGNATED-CHALLENGER--designated-challenger-only-on-the-framing.json +346 -0
  192. package/tests/agentic-board-suite/fixtures/invalid/DR-DESIGNATED-CHALLENGER--investment-without-finance.json +436 -0
  193. package/tests/agentic-board-suite/fixtures/invalid/DR-DESIGNATED-CHALLENGER--stopped-designated-challenge.json +348 -0
  194. package/tests/agentic-board-suite/fixtures/invalid/DR-HARD-GATE--conditional-over-hard-gate.json +357 -0
  195. package/tests/agentic-board-suite/fixtures/invalid/DR-HARD-GATE--unresolved-hard-gate.json +357 -0
  196. package/tests/agentic-board-suite/fixtures/invalid/DR-NO-APPROVE-OVER-DISSENT--approve-over-a-stopped-challenge.json +413 -0
  197. package/tests/agentic-board-suite/fixtures/invalid/DR-NO-APPROVE-OVER-DISSENT--approve-over-dissent.json +346 -0
  198. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--re-ask-without-supersedes.json +426 -0
  199. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--request-superseded-twice.json +581 -0
  200. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--supersedes-a-challenge.json +427 -0
  201. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--supersedes-a-dangling-id.json +427 -0
  202. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--supersedes-a-later-request.json +500 -0
  203. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--superseding-request-from-another-proposer.json +621 -0
  204. package/tests/agentic-board-suite/fixtures/invalid/DR-ONE-REQUEST--supersession-cycle.json +501 -0
  205. package/tests/agentic-board-suite/fixtures/invalid/DR-OWNER-FROM-ROOT--agent-opens-decision-path.json +480 -0
  206. package/tests/agentic-board-suite/fixtures/invalid/DR-OWNER-FROM-ROOT--owner-is-the-requester.json +273 -0
  207. package/tests/agentic-board-suite/fixtures/invalid/DR-OWNER-FROM-ROOT--request-to-later-sorting-non-owner.json +273 -0
  208. package/tests/agentic-board-suite/fixtures/invalid/DR-OWNER-FROM-ROOT--request-to-non-owner.json +273 -0
  209. package/tests/agentic-board-suite/fixtures/invalid/DR-OWNER-FROM-ROOT--root-names-no-owner.json +341 -0
  210. package/tests/agentic-board-suite/fixtures/invalid/DR-PROPOSER-IS-SENDER--challenger-assembles-request.json +337 -0
  211. package/tests/agentic-board-suite/fixtures/invalid/DR-PROPOSER-NOT-CHALLENGER--self-challenge.json +411 -0
  212. package/tests/agentic-board-suite/fixtures/invalid/DR-RISK-CARRIED--concurrent-higher-tier-envelope.json +403 -0
  213. package/tests/agentic-board-suite/fixtures/invalid/DR-RISK-CARRIED--r5-decided-through-lower-tier-request.json +433 -0
  214. package/tests/agentic-board-suite/fixtures/invalid/DR-RISK-CARRIED--request-drops-materiality-flags.json +479 -0
  215. package/tests/agentic-board-suite/fixtures/invalid/DR-RISK-CARRIED--risk-raised-after-request-before-decision.json +403 -0
  216. package/tests/agentic-board-suite/fixtures/invalid/DR-TO-PRINCIPAL--request-to-agent.json +272 -0
  217. package/tests/agentic-board-suite/fixtures/invalid/DR-UNRESOLVED-DISSENT--dissent-dropped.json +341 -0
  218. package/tests/agentic-board-suite/fixtures/invalid/DR-UNRESOLVED-DISSENT--stopped-challenge-recorded-withdrawn.json +408 -0
  219. package/tests/agentic-board-suite/fixtures/invalid/DR-UNRESOLVED-DISSENT--unresolved-without-dissent.json +341 -0
  220. package/tests/agentic-board-suite/fixtures/invalid/DR-VALUE-CASE-REQUIRED--investment-without-value-case.json +353 -0
  221. package/tests/agentic-board-suite/fixtures/invalid/DRC-AFTER-REQUEST--decided-before-request.json +408 -0
  222. package/tests/agentic-board-suite/fixtures/invalid/DRC-AFTER-REQUEST--request-descends-from-its-record.json +408 -0
  223. package/tests/agentic-board-suite/fixtures/invalid/DRC-APPROVAL-STATE--state-contradicts-outcome.json +346 -0
  224. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-approves-a-redesign.json +433 -0
  225. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-contradicts-outcome.json +433 -0
  226. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-does-not-descend-from-its-record.json +499 -0
  227. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-is-the-parent-of-its-record.json +500 -0
  228. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-names-another-approver.json +438 -0
  229. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-nobody-asked-for.json +421 -0
  230. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGN-BINDING--co-sign-sent-before-the-record.json +499 -0
  231. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGNED--decision-in-force-not-co-signed.json +591 -0
  232. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGNED--later-sorting-second-approver-never-signs.json +359 -0
  233. package/tests/agentic-board-suite/fixtures/invalid/DRC-CO-SIGNED--named-second-approver-never-signs.json +359 -0
  234. package/tests/agentic-board-suite/fixtures/invalid/DRC-COMPLETED--record-left-dispatched.json +346 -0
  235. package/tests/agentic-board-suite/fixtures/invalid/DRC-DECIDER-IS-ADDRESSEE--decided-by-non-addressee.json +408 -0
  236. package/tests/agentic-board-suite/fixtures/invalid/DRC-DECIDER-IS-ADDRESSEE--earlier-sorting-non-addressee-decides.json +408 -0
  237. package/tests/agentic-board-suite/fixtures/invalid/DRC-DECIDER-IS-SENDER--relayed-decision.json +408 -0
  238. package/tests/agentic-board-suite/fixtures/invalid/DRC-HARD-GATE-ACCEPTANCE--override-without-acceptance.json +356 -0
  239. package/tests/agentic-board-suite/fixtures/invalid/DRC-HARD-GATE-ACCEPTANCE--same-round-withdrawal.json +423 -0
  240. package/tests/agentic-board-suite/fixtures/invalid/DRC-HARD-GATE-ACCEPTANCE--stopped-over-cap-withdrawal.json +623 -0
  241. package/tests/agentic-board-suite/fixtures/invalid/DRC-HARD-GATE-ACCEPTANCE--unrelated-withdrawal-does-not-clear-gate.json +482 -0
  242. package/tests/agentic-board-suite/fixtures/invalid/DRC-HARD-GATE-ACCEPTANCE--withdrawal-names-an-undelivered-envelope.json +482 -0
  243. package/tests/agentic-board-suite/fixtures/invalid/DRC-HARD-GATE-ACCEPTANCE--withdrawal-on-another-proposers-revision.json +600 -0
  244. package/tests/agentic-board-suite/fixtures/invalid/DRC-NOT-REQUESTER--second-approver-is-requester.json +433 -0
  245. package/tests/agentic-board-suite/fixtures/invalid/DRC-ONE-RECORD--contradicting-records.json +416 -0
  246. package/tests/agentic-board-suite/fixtures/invalid/DRC-ONE-RECORD--duplicate-co-signature.json +507 -0
  247. package/tests/agentic-board-suite/fixtures/invalid/DRC-PRINCIPAL-SOURCE--agent-writes-decision.json +345 -0
  248. package/tests/agentic-board-suite/fixtures/invalid/DRC-REQUEST-BINDING--points-at-challenge.json +346 -0
  249. package/tests/agentic-board-suite/fixtures/invalid/DRC-REQUEST-BINDING--record-names-a-missing-request.json +346 -0
  250. package/tests/agentic-board-suite/fixtures/invalid/DRC-REQUEST-BINDING--request-edited-after-decision.json +346 -0
  251. package/tests/agentic-board-suite/fixtures/invalid/DRC-SECOND-APPROVER--r5-request-decided-on-lower-tier-branch.json +418 -0
  252. package/tests/agentic-board-suite/fixtures/invalid/DRC-SECOND-APPROVER--r5-single-approver.json +354 -0
  253. package/tests/agentic-board-suite/fixtures/invalid/DRC-SECOND-APPROVER--second-approver-is-decider.json +359 -0
  254. package/tests/agentic-board-suite/fixtures/invalid/HO-AGENT-NO-APPROVAL--agent-claims-approval.json +132 -0
  255. package/tests/agentic-board-suite/fixtures/invalid/HO-APPROVAL-ONLY-ON-RECORD--principal-delegate-claims-approval.json +132 -0
  256. package/tests/agentic-board-suite/fixtures/invalid/HO-ATTEN-ACTIONS--child-widens-actions.json +345 -0
  257. package/tests/agentic-board-suite/fixtures/invalid/HO-ATTEN-BUDGET--child-raises-limit.json +132 -0
  258. package/tests/agentic-board-suite/fixtures/invalid/HO-ATTEN-BUDGET--currency-changed.json +132 -0
  259. package/tests/agentic-board-suite/fixtures/invalid/HO-BLOCK-AS-CHALLENGE--block-sent-as-return.json +184 -0
  260. package/tests/agentic-board-suite/fixtures/invalid/HO-CHAIN-CUSTODY--parent-sender-continues-unstopped.json +187 -0
  261. package/tests/agentic-board-suite/fixtures/invalid/HO-CHAIN-CUSTODY--reply-from-outside-chain.json +132 -0
  262. package/tests/agentic-board-suite/fixtures/invalid/HO-CHAIN-PARENT--hop-skips.json +132 -0
  263. package/tests/agentic-board-suite/fixtures/invalid/HO-CHAIN-TIME--sent-before-parent.json +132 -0
  264. package/tests/agentic-board-suite/fixtures/invalid/HO-DESTINATION-TIER--delegate-to-live-guard.json +132 -0
  265. package/tests/agentic-board-suite/fixtures/invalid/HO-DESTINATION-TIER--delegate-to-untiered-agent.json +132 -0
  266. package/tests/agentic-board-suite/fixtures/invalid/HO-ESCALATE-TO-PRINCIPAL--escalate-to-agent.json +131 -0
  267. package/tests/agentic-board-suite/fixtures/invalid/HO-ESCALATE-TO-PRINCIPAL--escalated-delegate-to-agent.json +132 -0
  268. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-CLASS--classification-lowered.json +346 -0
  269. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-DEADLINE--deadline-extended.json +132 -0
  270. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-MATERIALITY--flag-dropped.json +434 -0
  271. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-MAX-HOPS--max-hops-raised.json +132 -0
  272. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-PROHIBITIONS--prohibition-dropped.json +131 -0
  273. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-SPENT--one-counter-decreases.json +346 -0
  274. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-SPENT--spent-counter-reset.json +346 -0
  275. package/tests/agentic-board-suite/fixtures/invalid/HO-MONO-TIER--tier-lowered.json +346 -0
  276. package/tests/agentic-board-suite/fixtures/invalid/HO-NO-EXECUTION--deploy-authorized.json +133 -0
  277. package/tests/agentic-board-suite/fixtures/invalid/HO-NO-SELF-ADDRESS--challenge-to-self.json +187 -0
  278. package/tests/agentic-board-suite/fixtures/invalid/HO-ONE-CORRELATION--two-correlations.json +132 -0
  279. package/tests/agentic-board-suite/fixtures/invalid/HO-ONE-ROOT--second-root-beside-a-decision.json +410 -0
  280. package/tests/agentic-board-suite/fixtures/invalid/HO-ONE-ROOT--two-roots.json +190 -0
  281. package/tests/agentic-board-suite/fixtures/invalid/HO-PAYLOAD-CHALLENGE--challenge-without-payload.json +180 -0
  282. package/tests/agentic-board-suite/fixtures/invalid/HO-PAYLOAD-DECISION-RECORD--record-without-payload.json +331 -0
  283. package/tests/agentic-board-suite/fixtures/invalid/HO-PAYLOAD-DECISION-REQUEST--request-without-payload.json +324 -0
  284. package/tests/agentic-board-suite/fixtures/invalid/HO-PAYLOAD-ESCALATE--escalate-without-payload.json +128 -0
  285. package/tests/agentic-board-suite/fixtures/invalid/HO-PAYLOAD-EXCLUSIVE--return-with-escalation.json +136 -0
  286. package/tests/agentic-board-suite/fixtures/invalid/HO-PAYLOAD-RETURN--return-without-verdict.json +124 -0
  287. package/tests/agentic-board-suite/fixtures/invalid/HO-REASON-HAS-STATUS--reason-on-completed.json +134 -0
  288. package/tests/agentic-board-suite/fixtures/invalid/HO-RETURN-EVIDENCE--pass-on-inference-only.json +132 -0
  289. package/tests/agentic-board-suite/fixtures/invalid/HO-RETURN-EVIDENCE--pass-over-blocking-gap.json +137 -0
  290. package/tests/agentic-board-suite/fixtures/invalid/HO-RETURN-EVIDENCE--pass-with-no-evidence.json +127 -0
  291. package/tests/agentic-board-suite/fixtures/invalid/HO-RETURN-TO-SENDER--return-skips-the-delegator.json +185 -0
  292. package/tests/agentic-board-suite/fixtures/invalid/HO-ROOT-PARENT--root-with-parent.json +132 -0
  293. package/tests/agentic-board-suite/fixtures/invalid/HO-SCHEMA--unknown-credential-field.json +133 -0
  294. package/tests/agentic-board-suite/fixtures/invalid/HO-STOP-NOTE--conflict-without-note.json +133 -0
  295. package/tests/agentic-board-suite/fixtures/invalid/HO-STOPPED-HAS-REASON--stopped-without-reason.json +132 -0
  296. package/tests/agentic-board-suite/fixtures/invalid/HO-UNIQUE-TASK-ID--duplicate-task-id.json +198 -0
  297. package/tests/agentic-board-suite/fixtures/invalid/STOP-BUDGET--budget-undeclared.json +123 -0
  298. package/tests/agentic-board-suite/fixtures/invalid/STOP-BUDGET--return-overspent.json +132 -0
  299. package/tests/agentic-board-suite/fixtures/invalid/STOP-CHALLENGE-ROUNDS--revisions-carried-in-rebuttal-challenges.json +461 -0
  300. package/tests/agentic-board-suite/fixtures/invalid/STOP-CHALLENGE-ROUNDS--third-round.json +454 -0
  301. package/tests/agentic-board-suite/fixtures/invalid/STOP-CHALLENGE-ROUNDS--third-version-by-return-naming-the-original.json +461 -0
  302. package/tests/agentic-board-suite/fixtures/invalid/STOP-DEADLINE--sent-after-deadline.json +132 -0
  303. package/tests/agentic-board-suite/fixtures/invalid/STOP-DEPTH--depth-undeclared.json +182 -0
  304. package/tests/agentic-board-suite/fixtures/invalid/STOP-INSTRUCTION-BAIT--claimed-approval-dispatched.json +132 -0
  305. package/tests/agentic-board-suite/fixtures/invalid/STOP-LIVE-GUARD--mutation-dispatched.json +130 -0
  306. package/tests/agentic-board-suite/fixtures/invalid/STOP-MISSING-EVIDENCE--blocking-gap-dispatched.json +137 -0
  307. package/tests/agentic-board-suite/fixtures/invalid/STOP-MISSING-EVIDENCE--decision-request-blocking-gap.json +351 -0
  308. package/tests/agentic-board-suite/fixtures/invalid/STOP-ROUTING-LOOP--exchanges-on-the-original-envelope.json +381 -0
  309. package/tests/agentic-board-suite/fixtures/invalid/STOP-ROUTING-LOOP--loop-undeclared.json +182 -0
  310. package/tests/agentic-board-suite/fixtures/invalid/STOP-ROUTING-LOOP--revisit-to-challenger-of-another-party.json +256 -0
  311. package/tests/agentic-board-suite/fixtures/invalid/STOP-ROUTING-LOOP--second-revisit-to-challenger.json +540 -0
  312. package/tests/agentic-board-suite/fixtures/invalid/STOP-TERMINAL--challenge-answers-stopped-delegate.json +247 -0
  313. package/tests/agentic-board-suite/fixtures/invalid/STOP-TERMINAL--decision-on-escalated-request.json +353 -0
  314. package/tests/agentic-board-suite/fixtures/invalid/STOP-TERMINAL--decision-on-stopped-request.json +422 -0
  315. package/tests/agentic-board-suite/fixtures/invalid/STOP-TERMINAL--reply-to-stopped-delegate.json +244 -0
  316. package/tests/agentic-board-suite/fixtures/invalid/VC-BENEFIT-CEILING--benefit-above-baseline.json +436 -0
  317. package/tests/agentic-board-suite/fixtures/invalid/VC-COST-SIGN--negative-risk-loss.json +436 -0
  318. package/tests/agentic-board-suite/fixtures/invalid/VC-COST-SIGN--risk-loss-low-minus-one.json +436 -0
  319. package/tests/agentic-board-suite/fixtures/invalid/VC-COUNTERFACTUALS--single-agent-counterfactual-missing.json +427 -0
  320. package/tests/agentic-board-suite/fixtures/invalid/VC-COUNTERFACTUALS--workflow-counterfactual-missing.json +427 -0
  321. package/tests/agentic-board-suite/fixtures/invalid/VC-NAV-ARITHMETIC--nav-off-by-one.json +436 -0
  322. package/tests/agentic-board-suite/fixtures/invalid/VC-ORDER--inverted-range.json +436 -0
  323. package/tests/agentic-board-suite/fixtures/invalid/VC-OVERSIGHT--free-oversight.json +436 -0
  324. package/tests/agentic-board-suite/fixtures/invalid/VC-PROMOTION--measured-but-not-incremental.json +436 -0
  325. package/tests/agentic-board-suite/fixtures/invalid/VC-PROMOTION--promoted-on-estimate.json +436 -0
  326. package/tests/agentic-board-suite/fixtures/invalid/VC-RECOMMEND-INCREMENTAL--recommend-non-incremental.json +436 -0
  327. package/tests/agentic-board-suite/fixtures/invalid/VC-RUNS-BOUND--more-successes-than-runs.json +436 -0
  328. package/tests/agentic-board-suite/fixtures/invalid/VC-SYNTHETIC--synthetic-claims-measured.json +436 -0
  329. package/tests/agentic-board-suite/fixtures/invalid/VC-UNIT-COST--no-successes-nonzero-cost.json +436 -0
  330. package/tests/agentic-board-suite/fixtures/invalid/VC-UNIT-COST--unit-cost-mismatch.json +436 -0
  331. package/tests/agentic-board-suite/fixtures/valid/v01-r0-single-review.json +129 -0
  332. package/tests/agentic-board-suite/fixtures/valid/v01x-block-return-on-inference.json +125 -0
  333. package/tests/agentic-board-suite/fixtures/valid/v01x-block-return-to-principal.json +125 -0
  334. package/tests/agentic-board-suite/fixtures/valid/v01x-pass-return-documentation-evidence.json +129 -0
  335. package/tests/agentic-board-suite/fixtures/valid/v01x-pass-return-mixed-evidence.json +133 -0
  336. package/tests/agentic-board-suite/fixtures/valid/v01x-pass-return-nonblocking-gap.json +134 -0
  337. package/tests/agentic-board-suite/fixtures/valid/v02-r2-architecture-dissent.json +343 -0
  338. package/tests/agentic-board-suite/fixtures/valid/v02x-challenge-after-decision.json +407 -0
  339. package/tests/agentic-board-suite/fixtures/valid/v02x-challenge-after-record-same-second.json +464 -0
  340. package/tests/agentic-board-suite/fixtures/valid/v02x-redispatch-after-cured-stop.json +534 -0
  341. package/tests/agentic-board-suite/fixtures/valid/v02x-security-proposal-returned-to-its-delegator.json +407 -0
  342. package/tests/agentic-board-suite/fixtures/valid/v03-investment-loses-to-workflow.json +433 -0
  343. package/tests/agentic-board-suite/fixtures/valid/v04-r3-hard-gate-risk-acceptance.json +354 -0
  344. package/tests/agentic-board-suite/fixtures/valid/v04x-live-hard-gate-recommend-escalate.json +354 -0
  345. package/tests/agentic-board-suite/fixtures/valid/v04x-live-hard-gate-recommend-reject.json +354 -0
  346. package/tests/agentic-board-suite/fixtures/valid/v05-live-guard-escalation.json +129 -0
  347. package/tests/agentic-board-suite/fixtures/valid/v06-r5-dual-approval.json +430 -0
  348. package/tests/agentic-board-suite/fixtures/valid/v06x-co-sign-same-second.json +430 -0
  349. package/tests/agentic-board-suite/fixtures/valid/v06x-re-asked-r5-decision-co-signed-again.json +662 -0
  350. package/tests/agentic-board-suite/fixtures/valid/v06x-unsigned-approve-superseded-by-co-signed-reject.json +588 -0
  351. package/tests/agentic-board-suite/fixtures/valid/v07-depth-and-routing-loop.json +180 -0
  352. package/tests/agentic-board-suite/fixtures/valid/v08-budget-exhausted.json +186 -0
  353. package/tests/agentic-board-suite/fixtures/valid/v09-instruction-bait.json +131 -0
  354. package/tests/agentic-board-suite/fixtures/valid/v09x-requester-restates-after-bait.json +251 -0
  355. package/tests/agentic-board-suite/fixtures/valid/v10-r1-optional-challenge.json +184 -0
  356. package/tests/agentic-board-suite/fixtures/valid/v10x-report-up-after-challenge.json +246 -0
  357. package/tests/agentic-board-suite/fixtures/valid/v11-two-rounds-hard-gate-withdrawn.json +478 -0
  358. package/tests/agentic-board-suite/fixtures/valid/v11x-designated-challenge-first-answer-over-cap.json +598 -0
  359. package/tests/agentic-board-suite/fixtures/valid/v11x-interleaved-challenger-round-two.json +604 -0
  360. package/tests/agentic-board-suite/fixtures/valid/v11x-proposal-handed-to-the-challenger-capped.json +860 -0
  361. package/tests/agentic-board-suite/fixtures/valid/v11x-round-three-escalation-path.json +747 -0
  362. package/tests/agentic-board-suite/fixtures/valid/v11x-same-second-round-two.json +478 -0
  363. package/tests/agentic-board-suite/fixtures/valid/v11x-two-findings-one-round.json +549 -0
  364. package/tests/agentic-board-suite/fixtures/valid/v12-superseded-request.json +497 -0
  365. package/tests/agentic-board-suite/fixtures/valid/v12x-challenge-after-the-first-decision.json +566 -0
  366. package/tests/agentic-board-suite/fixtures/valid/v12x-late-challenge-cured-by-supersede.json +566 -0
  367. package/tests/agentic-board-suite/fixtures/valid/v12x-supersede-in-the-same-second.json +497 -0
  368. package/tests/agentic-board-suite/test_agentic_board.py +502 -0
  369. package/tests/fixtures/README.md +1 -1
  370. package/tests/fuzz-properties.test.mjs +254 -0
  371. package/tests/mods.test.mjs +768 -0
  372. package/tests/validate-agent-tool-tiers.py +7 -5
  373. package/tests/validate-asset-integrity.py +1 -0
  374. package/tests/validate-mods.py +821 -0
@@ -0,0 +1,821 @@
1
+ #!/usr/bin/env python3
2
+ """Validation gate for the cross-harness mods under `mods/`.
3
+
4
+ Every mod is `mods/<id>/` with a `mod.toml` source of truth. This gate checks the
5
+ contract in `.claude/plan/cross-harness-mods.md`: the declared footprint matches
6
+ the derived reach level, every event is covered or declared unsupported per
7
+ harness, the generated manifests are well formed and in sync, the hand-written
8
+ code stays inside its declared capabilities, and the pattern data compiles.
9
+
10
+ Failures print `FAIL [mods] <path> <description>` and warnings `WARN [mods] ...`.
11
+ Stdlib only, deterministic (no clock, no network); `node` is the only subprocess.
12
+ The capability and env scans are line-oriented heuristics, not a JS parser: they
13
+ catch accidents, not deliberate obfuscation.
14
+
15
+ Usage: python3 tests/validate-mods.py [--root <dir>]
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import posixpath
23
+ import re
24
+ import subprocess
25
+ import sys
26
+ import tomllib
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ HARNESSES = ("claude-code", "kiro", "codex")
31
+ EVENTS = ("PreToolUse", "PostToolUse", "SessionStart")
32
+ SURFACES = ("native", "classic")
33
+ EV_KEBAB = {"PreToolUse": "pre-tool-use", "PostToolUse": "post-tool-use", "SessionStart": "session-start"}
34
+ NATIVE_EVENT = {"PreToolUse": "tool.check", "PostToolUse": "tool.call", "SessionStart": "session.start"}
35
+ KIRO_HOOK_KEYS = {"name", "description", "trigger", "matcher", "action", "timeout", "enabled", "confirm"}
36
+ CATALOGS = ("agents", "skills", "rules", "mcp-references", "workflows")
37
+ TIMEOUT = 60
38
+
39
+ # Capability tokens: (label, regex, exemption). exemption "net" is lifted by
40
+ # egress_hosts, "spawn" by spawns; None is never allowed. `Function\(` also
41
+ # covers `new Function(`. `\b` cannot precede `$`, hence the bare `\$`.
42
+ CAPS = (
43
+ ("fetch(", r"\bfetch\s*\(", "net"),
44
+ ("WebSocket", r"\bWebSocket\b", "net"),
45
+ ("XMLHttpRequest", r"\bXMLHttpRequest\b", "net"),
46
+ ("node:net", r"node:net\b", "net"),
47
+ ("node:http", r"node:http\b", "net"),
48
+ ("node:https", r"node:https\b", "net"),
49
+ ("node:tls", r"node:tls\b", "net"),
50
+ ("node:dgram", r"node:dgram\b", "net"),
51
+ ("$.network", r"\$\.network\b", "net"),
52
+ ("child_process", r"\bchild_process\b", "spawn"),
53
+ ("$.process", r"\$\.process\b", "spawn"),
54
+ ("node:worker_threads", r"node:worker_threads\b", None),
55
+ ("eval(", r"\beval\s*\(", None),
56
+ ("Function(", r"\bFunction\s*\(", None),
57
+ ("dynamic import(", r"\bimport\s*\(", None),
58
+ ("require(", r"\brequire\s*\(", None),
59
+ )
60
+ # The spec list plus the promise/callback and other mutating siblings, so
61
+ # `fs.promises.writeFile` is not a free bypass.
62
+ WRITE_API = re.compile(
63
+ r"\b(?:writeFileSync|appendFileSync|writeSync|writevSync|mkdirSync|renameSync|unlinkSync|rmSync|rmdirSync"
64
+ r"|copyFileSync|truncateSync|ftruncateSync|symlinkSync|linkSync|createWriteStream|writeFile|appendFile)\b"
65
+ r"|\$\.fs\.write\w*"
66
+ )
67
+ OPEN_SYNC = re.compile(r"\bopenSync\s*\(")
68
+ IMPORT_RE = re.compile(r"""\b(?:import|export)\b(?:[^;'"`]*?\bfrom)?\s*(['"])([^'"\n]+)\1""")
69
+ ON_RE = re.compile(r"""\bon\(\s*(['"])([\w.]+)\1""")
70
+ ENV_LITERAL = (
71
+ (re.compile(r"\bprocess\s*\.\s*env\s*\.\s*([A-Za-z_$][\w$]*)"), 1),
72
+ (re.compile(r"""\bprocess\s*\.\s*env\s*\[\s*(['"])([^'"\n]*)\1\s*\]"""), 2),
73
+ (re.compile(r"""\$\.env\.get\(\s*(['"])([^'"\n]*)\1\s*\)"""), 2),
74
+ )
75
+ ENV_ANY = re.compile(r"""\bprocess\s*(?:\.\s*env\b|\[\s*['"]env['"]\s*\])|\$\.env\b""")
76
+ INLINE_FLAG = re.compile(r"(?<!\\)(?:\\\\)*\(\?[-aiLmsux]+[:)]")
77
+ NET_MODULES = {"node:net", "node:http", "node:https", "node:tls", "node:dgram"}
78
+
79
+
80
+ class Report:
81
+ def __init__(self) -> None:
82
+ self.lines: list[str] = []
83
+ self.failed = 0
84
+
85
+ def _add(self, level: str, path: str, desc: str) -> None:
86
+ head, *rest = (desc.strip().splitlines() or [""])
87
+ self.lines.append(f"{level} [mods] {path} {head}".rstrip())
88
+ self.lines.extend(f" {line}" for line in rest)
89
+
90
+ def fail(self, path: str, desc: str) -> None:
91
+ self.failed += 1
92
+ self._add("FAIL", path, desc)
93
+
94
+ def warn(self, path: str, desc: str) -> None:
95
+ self._add("WARN", path, desc)
96
+
97
+
98
+ @dataclass
99
+ class Mod:
100
+ id: str
101
+ dir: Path
102
+ rel: str
103
+ t: dict
104
+
105
+ def h(self, name: str) -> dict:
106
+ return self.t["harnesses"][name]
107
+
108
+ def sup(self, name: str) -> bool:
109
+ return self.h(name)["supported"]
110
+
111
+ def ev(self, name: str) -> list[str]:
112
+ return self.h(name).get("events", []) if self.sup(name) else []
113
+
114
+ @property
115
+ def native(self) -> bool:
116
+ return self.sup("claude-code") and "native" in self.h("claude-code").get("surfaces", [])
117
+
118
+ @property
119
+ def classic(self) -> bool:
120
+ return self.sup("claude-code") and "classic" in self.h("claude-code").get("surfaces", [])
121
+
122
+ def fp(self, key: str) -> list[str]:
123
+ return self.t["footprint"][key]
124
+
125
+
126
+ def is_strs(v: object) -> bool:
127
+ return isinstance(v, list) and all(isinstance(x, str) for x in v)
128
+
129
+
130
+ def exact(v: object, keys: set[str]) -> bool:
131
+ return isinstance(v, dict) and set(v) == keys
132
+
133
+
134
+ def jdump(v: object) -> str:
135
+ # sort_keys + strict types: `1` and `true` stay distinct, unlike dict equality.
136
+ return json.dumps(v, sort_keys=True, default=str)
137
+
138
+
139
+ def run_node(root: Path, *args: str) -> tuple[int | None, str, str]:
140
+ """Run node with an argument list; return (returncode, stdout, stderr).
141
+
142
+ returncode is None, with the reason in stderr, when node could not run at all.
143
+ """
144
+ try:
145
+ p = subprocess.run(
146
+ ["node", *args], cwd=root, capture_output=True, text=True, errors="replace", timeout=TIMEOUT
147
+ )
148
+ except FileNotFoundError:
149
+ return None, "", "node executable not found"
150
+ except subprocess.TimeoutExpired:
151
+ return None, "", f"timed out after {TIMEOUT}s"
152
+ return p.returncode, p.stdout, p.stderr
153
+
154
+
155
+ def tail(text: str, n: int = 20) -> str:
156
+ return "\n".join(text.strip().splitlines()[-n:])
157
+
158
+
159
+ # ---------------------------------------------------------------- loading
160
+
161
+
162
+ def shape_errors(t: dict) -> list[str]:
163
+ """Structural problems that would make the policy checks crash."""
164
+ mod, fp, hs = t.get("mod"), t.get("footprint"), t.get("harnesses")
165
+ if not (isinstance(mod, dict) and isinstance(fp, dict) and isinstance(hs, dict)):
166
+ return ["[mod], [footprint] and [harnesses] tables are all required"]
167
+ errs = [f"[mod].{k} must be a string" for k in ("id", "name", "description", "version") if not isinstance(mod.get(k), str)]
168
+ rl = mod.get("reach_level")
169
+ if not isinstance(rl, int) or isinstance(rl, bool):
170
+ errs.append("[mod].reach_level must be an integer")
171
+ if not is_strs(mod.get("events")) or not mod["events"]:
172
+ errs.append("[mod].events must be a non-empty array of strings")
173
+ errs += [f"[footprint].{k} must be an array of strings" for k in ("env_reads", "file_writes", "spawns", "egress_hosts") if not is_strs(fp.get(k))]
174
+ for name in HARNESSES:
175
+ ht = hs.get(name)
176
+ if not isinstance(ht, dict) or not isinstance(ht.get("supported"), bool):
177
+ errs.append(f"[harnesses.{name}] with boolean `supported` is required")
178
+ continue
179
+ if ht["supported"]:
180
+ if not is_strs(ht.get("events")):
181
+ errs.append(f"[harnesses.{name}].events must be an array of strings")
182
+ if name == "claude-code" and not is_strs(ht.get("surfaces")):
183
+ errs.append("[harnesses.claude-code].surfaces must be an array of strings")
184
+ elif not isinstance(ht.get("reason"), str) or not ht["reason"].strip():
185
+ errs.append(f"[harnesses.{name}] has supported = false but no `reason`")
186
+ for tbl in ("unsupported", "degraded"):
187
+ v = ht.get(tbl, {})
188
+ if not (isinstance(v, dict) and all(isinstance(x, str) for x in v.values())):
189
+ errs.append(f"[harnesses.{name}.{tbl}] must map strings to strings")
190
+ return errs
191
+
192
+
193
+ def load_mod(r: Report, d: Path) -> Mod | None:
194
+ rel = f"mods/{d.name}"
195
+ p = d / "mod.toml"
196
+ if not p.is_file():
197
+ r.fail(f"{rel}/mod.toml", "is missing")
198
+ return None
199
+ try:
200
+ with p.open("rb") as f:
201
+ t = tomllib.load(f)
202
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError, OSError) as exc:
203
+ r.fail(f"{rel}/mod.toml", f"does not parse: {exc}")
204
+ return None
205
+ errs = shape_errors(t)
206
+ for e in errs:
207
+ r.fail(f"{rel}/mod.toml", e)
208
+ if errs:
209
+ return None
210
+ if t["mod"]["id"] != d.name:
211
+ r.fail(f"{rel}/mod.toml", f"[mod].id {t['mod']['id']!r} does not equal directory name {d.name!r}")
212
+ return Mod(d.name, d, rel, t)
213
+
214
+
215
+ # ---------------------------------------------------------------- mod.toml policy
216
+
217
+
218
+ def check_toml_policy(r: Report, m: Mod) -> None:
219
+ path = f"{m.rel}/mod.toml"
220
+ mod = m.t["mod"]
221
+ # 2. derived reach
222
+ derived = 3 if m.fp("egress_hosts") else 2 if m.fp("spawns") else 1 if m.fp("file_writes") else 0
223
+ if mod["reach_level"] != derived:
224
+ r.fail(path, f"reach_level {mod['reach_level']} does not equal derived level {derived}")
225
+ if derived == 3:
226
+ r.warn(path, "reach level 3: declares network egress")
227
+ if not re.fullmatch(r"\d+\.\d+\.\d+", mod["version"]):
228
+ r.fail(path, f"version {mod['version']!r} must be MAJOR.MINOR.PATCH")
229
+ # 7. env prefix
230
+ prefix = "VANGUARD_" + m.id.upper().replace("-", "_") + "_"
231
+ for name in m.fp("env_reads"):
232
+ if not name.startswith(prefix):
233
+ r.fail(path, f"env read {name!r} must start with {prefix}")
234
+ # 3-5. events, surfaces, degraded
235
+ mod_events = mod["events"]
236
+ for e in mod_events:
237
+ if e not in EVENTS:
238
+ r.fail(path, f"unknown event {e!r} in [mod].events")
239
+ if len(set(mod_events)) != len(mod_events):
240
+ r.fail(path, "[mod].events has duplicates")
241
+ for h in HARNESSES:
242
+ if not m.sup(h):
243
+ continue
244
+ ht, ev = m.h(h), m.ev(h)
245
+ un = set(ht.get("unsupported", {}))
246
+ un_events = un - set(SURFACES) if h == "claude-code" else un
247
+ if len(set(ev)) != len(ev):
248
+ r.fail(path, f"harnesses.{h}.events has duplicates")
249
+ for e in ev:
250
+ if e not in mod_events:
251
+ r.fail(path, f"harnesses.{h}.events lists {e}, which is not in [mod].events")
252
+ for e in sorted(un_events - set(mod_events)):
253
+ r.fail(path, f"harnesses.{h}.unsupported key {e!r} is not a [mod].events entry" + (" or surface" if h == "claude-code" else ""))
254
+ for e in mod_events:
255
+ n = (e in ev) + (e in un_events)
256
+ if n == 0:
257
+ r.fail(path, f"event {e} is in neither harnesses.{h}.events nor harnesses.{h}.unsupported")
258
+ elif n == 2:
259
+ r.fail(path, f"event {e} is in both harnesses.{h}.events and harnesses.{h}.unsupported")
260
+ surfaces = set(ht.get("surfaces", [])) if h == "claude-code" else set()
261
+ if h == "claude-code":
262
+ for s in sorted(surfaces - set(SURFACES)):
263
+ r.fail(path, f"unknown surface {s!r} in harnesses.claude-code.surfaces")
264
+ un_surfaces = un & set(SURFACES)
265
+ for s in sorted(surfaces & un_surfaces):
266
+ r.fail(path, f"surface {s} is in both surfaces and harnesses.claude-code.unsupported")
267
+ if (surfaces | un_surfaces) != set(SURFACES):
268
+ r.fail(path, "harnesses.claude-code surfaces plus unsupported surfaces must be exactly {native, classic}")
269
+ for k in ht.get("degraded", {}):
270
+ if k not in ev and k not in surfaces:
271
+ r.fail(path, f"harnesses.{h}.degraded key {k!r} is not a supported event" + (" or surface" if h == "claude-code" else ""))
272
+ # An event no harness runs is dead weight in [mod].events, not a portable feature.
273
+ for e in mod_events:
274
+ if not any(e in m.ev(h) for h in HARNESSES):
275
+ r.fail(path, f"event {e} is supported by no harness")
276
+ # 6. UNSUPPORTED.md iff
277
+ for h in HARNESSES:
278
+ ht = m.h(h)
279
+ need = (not ht["supported"]) or bool(ht.get("unsupported")) or bool(ht.get("degraded"))
280
+ exists = (m.dir / h / "UNSUPPORTED.md").is_file()
281
+ if need and not exists:
282
+ r.fail(f"{m.rel}/{h}/UNSUPPORTED.md", "is missing (harness is unsupported, or has unsupported/degraded entries)")
283
+ elif exists and not need:
284
+ r.fail(f"{m.rel}/{h}/UNSUPPORTED.md", "must not exist: nothing is unsupported or degraded")
285
+
286
+
287
+ # ---------------------------------------------------------------- source scans
288
+
289
+
290
+ def strip_comments(src: str) -> str:
291
+ """Drop `//` and `/* */` comments, leaving string contents alone.
292
+
293
+ ponytail: quote-aware but not regex-literal-aware; a regex literal holding a
294
+ quote can hide the rest of its line. Upgrade to a real JS tokenizer if that bites.
295
+ """
296
+ out: list[str] = []
297
+ i, n, q = 0, len(src), ""
298
+ while i < n:
299
+ c = src[i]
300
+ if q:
301
+ out.append(c)
302
+ if c == "\\" and i + 1 < n:
303
+ out.append(src[i + 1])
304
+ i += 2
305
+ continue
306
+ if c == q or (c == "\n" and q != "`"):
307
+ q = ""
308
+ i += 1
309
+ elif src.startswith("//", i):
310
+ j = src.find("\n", i)
311
+ i = n if j < 0 else j
312
+ elif src.startswith("/*", i):
313
+ j = src.find("*/", i + 2)
314
+ i = n if j < 0 else j + 2
315
+ out.append(" ")
316
+ else:
317
+ if c in "'\"`":
318
+ q = c
319
+ out.append(c)
320
+ i += 1
321
+ return "".join(out)
322
+
323
+
324
+ def call_args(text: str, start: int) -> list[str]:
325
+ """Top-level comma-split arguments of the call whose `(` is just before `start`."""
326
+ args, depth, cur, q, i = [], 0, [], "", start
327
+ while i < len(text):
328
+ c = text[i]
329
+ if q:
330
+ cur.append(c)
331
+ if c == "\\":
332
+ cur.append(text[i + 1 : i + 2])
333
+ i += 1
334
+ elif c == q:
335
+ q = ""
336
+ elif c in "'\"`":
337
+ q = c
338
+ cur.append(c)
339
+ elif c in "([{":
340
+ depth += 1
341
+ cur.append(c)
342
+ elif c in ")]}":
343
+ if depth == 0:
344
+ break
345
+ depth -= 1
346
+ cur.append(c)
347
+ elif c == "," and depth == 0:
348
+ args.append("".join(cur).strip())
349
+ cur = []
350
+ else:
351
+ cur.append(c)
352
+ i += 1
353
+ args.append("".join(cur).strip())
354
+ return args
355
+
356
+
357
+ def write_hits(text: str) -> list[str]:
358
+ hits = [m.group(0) for m in WRITE_API.finditer(text)]
359
+ for m in OPEN_SYNC.finditer(text):
360
+ args = call_args(text, m.end())
361
+ if len(args) < 2:
362
+ continue # openSync(path) defaults to read-only
363
+ lit = re.fullmatch(r"""(['"`])(.*)\1""", args[1])
364
+ # a flag that is not a string literal cannot be judged, so it counts as a write
365
+ if lit is None or set(lit.group(2)) & set("wa+"):
366
+ hits.append(f"openSync(.., {args[1]})")
367
+ return hits
368
+
369
+
370
+ def scan_files(m: Mod) -> dict[str, str]:
371
+ """relpath-in-mod -> comment-stripped source for lib/*.mjs, bin/*.mjs, hooks/*.ts (not tests)."""
372
+ found = sorted([*m.dir.glob("lib/*.mjs"), *m.dir.glob("bin/*.mjs"), *m.dir.glob("hooks/*.ts")])
373
+ return {
374
+ p.relative_to(m.dir).as_posix(): strip_comments(p.read_text(encoding="utf-8", errors="replace"))
375
+ for p in found
376
+ if p.is_file() and not p.name.endswith(".test.ts")
377
+ }
378
+
379
+
380
+ def check_env_scan(r: Report, m: Mod, files: dict[str, str]) -> None:
381
+ declared = set(m.fp("env_reads"))
382
+ used: dict[str, str] = {}
383
+ for rel, text in files.items():
384
+ for am in ENV_ANY.finditer(text):
385
+ name = None
386
+ for rx, grp in ENV_LITERAL:
387
+ lm = rx.match(text, am.start())
388
+ if lm:
389
+ name = lm.group(grp)
390
+ break
391
+ if name is None:
392
+ snippet = " ".join(text[am.start() : am.start() + 40].split())
393
+ r.fail(f"{m.rel}/{rel}", f"non-literal env access `{snippet}`; use process.env.NAME or $.env.get('NAME')")
394
+ else:
395
+ used.setdefault(name, rel)
396
+ for name, rel in sorted(used.items()):
397
+ if name not in declared:
398
+ r.fail(f"{m.rel}/{rel}", f"reads env {name} which is not declared in [footprint].env_reads")
399
+ for name in sorted(declared - set(used)):
400
+ r.fail(f"{m.rel}/mod.toml", f"declared env read {name} is not used in lib/, bin/ or hooks/")
401
+
402
+
403
+ def import_allowed(m: Mod, rel: str, spec: str) -> bool:
404
+ """Import policy per file role (see spec check 14 and 13)."""
405
+ # runtime.mjs imports node:fs, so nothing the Node-free native runtime loads may import it.
406
+ if rel.startswith("hooks/"):
407
+ return spec == "claude-code" or (spec != "../lib/runtime.mjs" and re.fullmatch(r"\.\./lib/[a-z0-9-]+\.mjs", spec) is not None)
408
+ if rel.startswith("bin/") or rel == "lib/runtime.mjs":
409
+ ok = {"node:fs", "node:path"}
410
+ if m.fp("spawns"):
411
+ ok.add("node:child_process")
412
+ if m.fp("egress_hosts"):
413
+ ok |= NET_MODULES
414
+ if spec in ok:
415
+ return True
416
+ if spec.startswith("./") or spec.startswith("../"):
417
+ return not posixpath.normpath(posixpath.join(posixpath.dirname(rel), spec)).startswith("..")
418
+ return False
419
+ return spec != "./runtime.mjs" and re.fullmatch(r"\./[A-Za-z0-9._-]+\.mjs", spec) is not None # other lib/*.mjs: Node-free runtime
420
+
421
+
422
+ def check_capabilities(r: Report, m: Mod, files: dict[str, str]) -> None:
423
+ exempt = {"net": bool(m.fp("egress_hosts")), "spawn": bool(m.fp("spawns")), None: False}
424
+ can_write = bool(m.fp("file_writes"))
425
+ for rel, text in files.items():
426
+ path = f"{m.rel}/{rel}"
427
+ for im in IMPORT_RE.finditer(text):
428
+ spec = im.group(2)
429
+ if not import_allowed(m, rel, spec):
430
+ hint = f" (use node:{spec})" if spec in ("fs", "path") else ""
431
+ r.fail(path, f"import of {spec!r} is not allowed here{hint}")
432
+ for label, rx, ex in CAPS:
433
+ if re.search(rx, text) and not exempt[ex]:
434
+ need = {"net": " without egress_hosts", "spawn": " without spawns", None: ""}[ex]
435
+ r.fail(path, f"uses {label}{need}")
436
+ hits = write_hits(text)
437
+ if hits and rel in ("lib/common.mjs", "lib/runtime.mjs"):
438
+ r.fail(path, f"shared module must not use write APIs: {', '.join(sorted(set(hits)))}")
439
+ elif hits and not can_write:
440
+ r.fail(path, f"uses write API {', '.join(sorted(set(hits)))} without file_writes")
441
+
442
+
443
+ def check_register(r: Report, m: Mod, text: str) -> None:
444
+ path = f"{m.rel}/hooks/register.ts"
445
+ if not re.search(r"\bexport\s+const\s+register\b", text):
446
+ r.fail(path, "must contain `export const register`")
447
+ registered = [x.group(2) for x in ON_RE.finditer(text)]
448
+ want = {NATIVE_EVENT[e] for e in m.ev("claude-code") if e in NATIVE_EVENT}
449
+ if set(registered) != want:
450
+ r.fail(path, f"registers native events {sorted(set(registered))}, expected {sorted(want)} from harnesses.claude-code.events")
451
+ guarded = sum(e in ("tool.check", "tool.call") for e in registered)
452
+ catches = len(re.findall(r"\.catch\(", text))
453
+ if catches < guarded:
454
+ r.fail(path, f"has {catches} .catch( for {guarded} tool.check/tool.call registrations")
455
+
456
+
457
+ def check_sources(r: Report, root: Path, m: Mod) -> None:
458
+ files = scan_files(m)
459
+ check_env_scan(r, m, files)
460
+ check_capabilities(r, m, files)
461
+ if "hooks/register.ts" in files:
462
+ check_register(r, m, files["hooks/register.ts"])
463
+ # 11. syntax
464
+ for p in sorted(m.dir.rglob("*.mjs")):
465
+ rc, out, err = run_node(root, "--check", str(p))
466
+ rel = f"{m.rel}/{p.relative_to(m.dir).as_posix()}"
467
+ if rc is None:
468
+ r.fail(rel, f"node --check could not run: {err}")
469
+ elif rc != 0:
470
+ r.fail(rel, f"node --check failed:\n{tail(err or out, 8)}")
471
+
472
+
473
+ def check_required_files(r: Report, m: Mod) -> None:
474
+ need = ["lib/common.mjs", "lib/runtime.mjs", "lib/core.mjs", "bin/hook.mjs", "README.md"]
475
+ if m.native:
476
+ need += ["hooks/hooks.json", "hooks/register.ts", "hooks/register.test.ts"]
477
+ for rel in need:
478
+ if not (m.dir / rel).is_file():
479
+ r.fail(f"{m.rel}/{rel}", "is missing")
480
+ if not m.native and (m.dir / "hooks").exists():
481
+ r.fail(f"{m.rel}/hooks", "must not exist: the native surface is not supported")
482
+
483
+
484
+ # ---------------------------------------------------------------- generated shapes
485
+
486
+
487
+ def read_json(r: Report, path: Path, rel: str) -> object | None:
488
+ try:
489
+ return json.loads(path.read_text(encoding="utf-8"))
490
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as exc:
491
+ r.fail(rel, f"is not valid JSON: {exc}")
492
+ return None
493
+
494
+
495
+ def check_command(r: Report, rel: str, cmd: str, harness: str, event: str) -> None:
496
+ if "bin/hook.mjs" not in cmd:
497
+ r.fail(rel, f"{event}: command does not run bin/hook.mjs")
498
+ if re.findall(r"--harness\s+(\S+)", cmd) != [harness]:
499
+ r.fail(rel, f"{event}: command must carry exactly `--harness {harness}`")
500
+ if re.findall(r"--event\s+(\S+)", cmd) != [event]:
501
+ r.fail(rel, f"{event}: command must carry exactly `--event {event}`")
502
+
503
+
504
+ def check_hook_map(r: Report, rel: str, data: object, harness: str, events: list[str]) -> None:
505
+ if not exact(data, {"hooks"}) or not isinstance(data["hooks"], dict):
506
+ r.fail(rel, 'must be {"hooks": {<Event>: [{"hooks": [{"type": "command", "command": ...}]}]}}')
507
+ return
508
+ hooks = data["hooks"]
509
+ if set(hooks) != set(events):
510
+ r.fail(rel, f"event keys {sorted(hooks)} must equal harnesses.{harness}.events {sorted(events)}")
511
+ for ev, groups in sorted(hooks.items()):
512
+ ok = (
513
+ isinstance(groups, list)
514
+ and len(groups) == 1
515
+ and exact(groups[0], {"hooks"})
516
+ and isinstance(groups[0]["hooks"], list)
517
+ and len(groups[0]["hooks"]) == 1
518
+ and exact(groups[0]["hooks"][0], {"type", "command"})
519
+ and groups[0]["hooks"][0]["type"] == "command"
520
+ and isinstance(groups[0]["hooks"][0]["command"], str)
521
+ )
522
+ if ok:
523
+ check_command(r, rel, groups[0]["hooks"][0]["command"], harness, ev)
524
+ else:
525
+ r.fail(rel, f'{ev}: must be exactly [{{"hooks": [{{"type": "command", "command": <string>}}]}}]')
526
+
527
+
528
+ def check_kiro(r: Report, m: Mod) -> None:
529
+ d = m.dir / "kiro"
530
+ files = sorted(p.name for p in d.glob("*.json")) if d.is_dir() else []
531
+ events = m.ev("kiro")
532
+ seen: set[str] = set()
533
+ for name in files:
534
+ rel = f"{m.rel}/kiro/{name}"
535
+ if not m.sup("kiro"):
536
+ r.fail(rel, "must not exist: kiro is not supported")
537
+ continue
538
+ data = read_json(r, d / name, rel)
539
+ if data is None:
540
+ continue
541
+ if not (
542
+ exact(data, {"version", "hooks"})
543
+ and data["version"] == "v1"
544
+ and isinstance(data["hooks"], list)
545
+ and len(data["hooks"]) == 1
546
+ and isinstance(data["hooks"][0], dict)
547
+ ):
548
+ r.fail(rel, 'must be {"version": "v1", "hooks": [<exactly one hook>]}')
549
+ continue
550
+ hook = data["hooks"][0]
551
+ extra = set(hook) - KIRO_HOOK_KEYS
552
+ if extra:
553
+ r.fail(rel, f"hook has unknown keys {sorted(extra)}")
554
+ trig = hook.get("trigger")
555
+ if trig not in events:
556
+ r.fail(rel, f"trigger {trig!r} is not in harnesses.kiro.events")
557
+ continue
558
+ seen.add(trig)
559
+ if name != f"{m.id}-{EV_KEBAB[trig]}-hook.json":
560
+ r.fail(rel, f"file name must be {m.id}-{EV_KEBAB[trig]}-hook.json")
561
+ if hook.get("name") != f"{m.id}:{trig}":
562
+ r.fail(rel, f"hook name must be {m.id}:{trig}, got {hook.get('name')!r}")
563
+ act = hook.get("action")
564
+ if not (exact(act, {"type", "command"}) and act["type"] == "command" and isinstance(act["command"], str)):
565
+ r.fail(rel, 'action must be {"type": "command", "command": <string>}')
566
+ else:
567
+ check_command(r, rel, act["command"], "kiro", trig)
568
+ for e in events:
569
+ if e not in seen and m.sup("kiro") and e in EV_KEBAB:
570
+ r.fail(f"{m.rel}/kiro/{m.id}-{EV_KEBAB[e]}-hook.json", "is missing")
571
+
572
+
573
+ def check_generated(r: Report, m: Mod) -> None:
574
+ check_kiro(r, m)
575
+ for h, rel, want in (
576
+ ("codex", "codex/hooks.json", m.sup("codex")),
577
+ ("claude-code", "claude-code/settings.hooks.json", m.classic),
578
+ ):
579
+ path = m.dir / rel
580
+ if not want:
581
+ if path.exists():
582
+ r.fail(f"{m.rel}/{rel}", f"must not exist: {h} is not supported here")
583
+ elif not path.is_file():
584
+ r.fail(f"{m.rel}/{rel}", "is missing")
585
+ elif (data := read_json(r, path, f"{m.rel}/{rel}")) is not None:
586
+ check_hook_map(r, f"{m.rel}/{rel}", data, h, m.ev(h))
587
+ plugin = m.dir / ".claude-plugin" / "plugin.json"
588
+ rel = f"{m.rel}/.claude-plugin/plugin.json"
589
+ if not m.native:
590
+ if plugin.exists():
591
+ r.fail(rel, "must not exist: the native surface is not supported")
592
+ elif not plugin.is_file():
593
+ r.fail(rel, "is missing")
594
+ elif (data := read_json(r, plugin, rel)) is not None:
595
+ mod = m.t["mod"]
596
+ for key, want in (("name", m.id), ("version", mod["version"]), ("description", mod["description"])):
597
+ if not isinstance(data, dict) or data.get(key) != want:
598
+ r.fail(rel, f"{key} must equal {want!r}")
599
+ if m.native and (m.dir / "hooks" / "hooks.json").is_file():
600
+ data = read_json(r, m.dir / "hooks" / "hooks.json", f"{m.rel}/hooks/hooks.json")
601
+ if data is not None and data != {"modules": ["./register.ts"]}:
602
+ r.fail(f"{m.rel}/hooks/hooks.json", 'must be {"modules": ["./register.ts"]}')
603
+
604
+
605
+ # ---------------------------------------------------------------- patterns
606
+
607
+
608
+ def check_patterns(r: Report, m: Mod) -> None:
609
+ p = m.dir / "patterns.json"
610
+ if not p.is_file():
611
+ return
612
+ rel = f"{m.rel}/patterns.json"
613
+ data = read_json(r, p, rel)
614
+ if data is None:
615
+ return
616
+ if not isinstance(data, list):
617
+ r.fail(rel, "must be an array of pattern objects")
618
+ return
619
+ required = {"id": str, "name": str, "description": str, "regex": str, "flags": str, "context": list}
620
+ optional = {"severity": str, "agents": list}
621
+ ids: set[str] = set()
622
+ for i, e in enumerate(data):
623
+ if not isinstance(e, dict):
624
+ r.fail(rel, f"entry {i} is not an object")
625
+ continue
626
+ pid = e.get("id", f"#{i}")
627
+ bad = [k for k, ty in required.items() if not isinstance(e.get(k), ty)]
628
+ bad += [k for k, ty in optional.items() if k in e and not isinstance(e[k], ty)]
629
+ for k in bad:
630
+ r.fail(rel, f"pattern {pid}: field {k!r} is missing or has the wrong type")
631
+ for k in sorted(set(e) - set(required) - set(optional)):
632
+ r.fail(rel, f"pattern {pid}: unknown field {k!r}")
633
+ if bad:
634
+ continue
635
+ for k in ("context", "agents"):
636
+ if k in e and not is_strs(e[k]):
637
+ r.fail(rel, f"pattern {pid}: {k} must be an array of strings")
638
+ if pid in ids:
639
+ r.fail(rel, f"duplicate pattern id {pid!r}")
640
+ ids.add(pid)
641
+ if e["flags"] not in ("", "i"):
642
+ r.fail(rel, f"pattern {pid}: flags {e['flags']!r} must be '' or 'i'")
643
+ continue
644
+ if INLINE_FLAG.search(e["regex"]):
645
+ r.fail(rel, f"pattern {pid}: inline flag group in regex; use the `flags` field")
646
+ try:
647
+ re.compile(e["regex"], re.ASCII | (re.I if e["flags"] == "i" else 0))
648
+ except re.error as exc:
649
+ r.fail(rel, f"pattern {pid}: regex does not compile under re.ASCII: {exc}")
650
+
651
+
652
+ # ---------------------------------------------------------------- repo-level checks
653
+
654
+
655
+ def check_generator(r: Report, root: Path, mods: list[Mod], mod_dirs: list[str]) -> None:
656
+ gen = "scripts/generate-mods.mjs"
657
+ if not (root / gen).is_file():
658
+ r.fail(gen, "is missing")
659
+ return
660
+ rc, out, err = run_node(root, gen, "--check")
661
+ if rc is None:
662
+ r.fail(gen, f"--check could not run: {err}")
663
+ return
664
+ if rc != 0:
665
+ r.fail(gen, f"--check exited {rc}:\n{tail(err or out)}")
666
+ rc, out, err = run_node(root, gen, "--print-manifests")
667
+ if rc != 0:
668
+ r.fail(gen, f"--print-manifests exited {rc}:\n{tail(err or out)}")
669
+ return
670
+ try:
671
+ printed = json.loads(out)
672
+ except json.JSONDecodeError as exc:
673
+ r.fail(gen, f"--print-manifests output is not JSON: {exc}")
674
+ return
675
+ if not isinstance(printed, dict):
676
+ r.fail(gen, "--print-manifests output must be a JSON object keyed by mod id")
677
+ return
678
+ for m in mods:
679
+ if m.id not in printed:
680
+ r.fail(f"{m.rel}/mod.toml", "missing from generator --print-manifests output")
681
+ elif jdump(printed[m.id]) != jdump(m.t):
682
+ r.fail(f"{m.rel}/mod.toml", "generator parse differs from tomllib parse")
683
+ for extra in sorted(set(printed) - set(mod_dirs)):
684
+ r.fail(gen, f"--print-manifests lists {extra!r}, which has no mods/{extra}/")
685
+
686
+
687
+ def check_catalog_mods(r: Report, root: Path, mods: list[Mod], mod_dirs: list[str]) -> None:
688
+ rel = "catalog/mods.json"
689
+ p = root / rel
690
+ if not p.is_file():
691
+ r.fail(rel, "is missing")
692
+ return
693
+ cat = read_json(r, p, rel)
694
+ if cat is None:
695
+ return
696
+ if not isinstance(cat, list) or not all(isinstance(e, dict) for e in cat):
697
+ r.fail(rel, "must be an array of objects")
698
+ return
699
+ ids = [e.get("id") for e in cat]
700
+ if ids != sorted(ids, key=str):
701
+ r.fail(rel, "is not sorted by id")
702
+ for i in sorted({x for x in ids if ids.count(x) > 1}, key=str):
703
+ r.fail(rel, f"duplicate entry for {i!r}")
704
+ for i in sorted(set(mod_dirs) - set(ids)):
705
+ r.fail(rel, f"has no entry for mods/{i}")
706
+ for i in sorted(set(ids) - set(mod_dirs), key=str):
707
+ r.fail(rel, f"has an entry for {i!r}, which has no mods/ directory")
708
+ by_id = {e.get("id"): e for e in cat}
709
+ for m in mods:
710
+ mod = m.t["mod"]
711
+ want = {
712
+ "id": m.id,
713
+ "name": mod["name"],
714
+ "version": mod["version"],
715
+ "description": mod["description"],
716
+ "reach_level": mod["reach_level"],
717
+ "events": mod["events"],
718
+ "harnesses": sorted(h for h in HARNESSES if m.sup(h)),
719
+ "path": m.rel,
720
+ "footprint": {key: m.fp(key) for key in ("env_reads", "file_writes", "spawns", "egress_hosts")},
721
+ }
722
+ got = by_id.get(m.id)
723
+ if got is not None and jdump(got) != jdump(want):
724
+ keys = sorted(k for k in set(want) | set(got) if jdump(want.get(k, "<absent>")) != jdump(got.get(k, "<absent>")))
725
+ r.fail(rel, f"entry {m.id!r} differs from mod.toml in: {', '.join(keys)}")
726
+
727
+
728
+ def check_catalog_ids(r: Report, root: Path, mods: list[Mod]) -> None:
729
+ for name in CATALOGS:
730
+ rel = f"catalog/{name}.json"
731
+ p = root / rel
732
+ data = read_json(r, p, rel) if p.is_file() else None
733
+ if data is None:
734
+ if not p.is_file():
735
+ r.fail(rel, "is missing, so mod id uniqueness cannot be checked")
736
+ continue
737
+ if name == "workflows":
738
+ data = data.get("workflows") if isinstance(data, dict) else None
739
+ if not isinstance(data, list):
740
+ r.fail(rel, "must contain a workflows array")
741
+ continue
742
+ if not isinstance(data, list):
743
+ r.fail(rel, "must be a top-level array")
744
+ continue
745
+ taken = {e.get("id") for e in data if isinstance(e, dict)}
746
+ for m in mods:
747
+ if m.id in taken:
748
+ r.fail(f"{m.rel}/mod.toml", f"id {m.id!r} collides with an id in {rel}")
749
+
750
+
751
+ def check_write_overlap(r: Report, mods: list[Mod]) -> None:
752
+ def prefix(glob: str) -> str:
753
+ return re.split(r"[*?\[{]", glob, maxsplit=1)[0]
754
+
755
+ globs = [(m, g) for m in mods for g in m.fp("file_writes")]
756
+ for i, (ma, ga) in enumerate(globs):
757
+ for mb, gb in globs[i + 1 :]:
758
+ if ma.id == mb.id:
759
+ continue
760
+ pa, pb = prefix(ga), prefix(gb)
761
+ if pa.startswith(pb) or pb.startswith(pa):
762
+ r.fail(f"{ma.rel}/mod.toml", f"file_writes {ga!r} overlaps {gb!r} in {mb.rel}")
763
+
764
+
765
+ def check_node_tests(r: Report, root: Path) -> None:
766
+ rel = "tests/mods.test.mjs"
767
+ if not (root / rel).is_file():
768
+ r.fail(rel, "is missing")
769
+ return
770
+ rc, out, err = run_node(root, "--test", rel)
771
+ if rc is None:
772
+ r.fail(rel, f"could not run: {err}")
773
+ elif rc != 0:
774
+ r.fail(rel, f"node --test exited {rc}:\n{tail(out + err)}")
775
+
776
+
777
+ def main(argv: list[str] | None = None) -> int:
778
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
779
+ ap.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1], help="repository root (default: this repo)")
780
+ root = ap.parse_args(argv).root.resolve()
781
+ r = Report()
782
+
783
+ mods_dir = root / "mods"
784
+ if not mods_dir.is_dir():
785
+ r.fail("mods/", "directory is missing")
786
+ print("\n".join(r.lines))
787
+ return 1
788
+ dirs = sorted(d for d in mods_dir.iterdir() if d.is_dir() and not d.name.startswith("."))
789
+ if not dirs:
790
+ r.fail("mods/", "contains no mod directories")
791
+ if not (mods_dir / "README.md").is_file():
792
+ r.fail("mods/README.md", "is missing")
793
+
794
+ mods: list[Mod] = []
795
+ for d in dirs:
796
+ m = load_mod(r, d)
797
+ if m is None:
798
+ continue
799
+ mods.append(m)
800
+ check_toml_policy(r, m)
801
+ check_required_files(r, m)
802
+ check_generated(r, m)
803
+ check_sources(r, root, m)
804
+ check_patterns(r, m)
805
+
806
+ mod_dirs = [d.name for d in dirs]
807
+ check_generator(r, root, mods, mod_dirs)
808
+ check_catalog_mods(r, root, mods, mod_dirs)
809
+ check_catalog_ids(r, root, mods)
810
+ check_write_overlap(r, mods)
811
+ check_node_tests(r, root)
812
+
813
+ if r.failed == 0:
814
+ n = {h: sum(m.sup(h) for m in mods) for h in HARNESSES}
815
+ r.lines.append(f"OK: {len(mods)} mods valid (claude-code {n['claude-code']}, kiro {n['kiro']}, codex {n['codex']})")
816
+ print("\n".join(r.lines))
817
+ return 1 if r.failed else 0
818
+
819
+
820
+ if __name__ == "__main__":
821
+ sys.exit(main())