@codyswann/lisa 4.74.9 → 4.75.1

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 (805) hide show
  1. package/.claude-plugin/marketplace.json +60 -0
  2. package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +73 -40
  3. package/all/copy-overwrite/scripts/lib/npm-update-invariants.mjs +54 -0
  4. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +3 -2
  5. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  6. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  7. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  8. package/dist/core/lisa-skill-sources.d.ts +3 -3
  9. package/dist/core/lisa-skill-sources.d.ts.map +1 -1
  10. package/dist/core/lisa-skill-sources.js +3 -2
  11. package/dist/core/lisa-skill-sources.js.map +1 -1
  12. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  13. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  14. package/dist/core/upstream-evidence-manifest.js +735 -8
  15. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  16. package/package.json +4 -4
  17. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +11 -0
  26. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +8 -0
  27. package/plugins/lisa-cdk-devin/skills/setup-aws-accounts/SKILL.md +292 -0
  28. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-devin/.claude-plugin/plugin.json +8 -0
  31. package/plugins/lisa-devin/.devin-plugin/plugin.json +8 -0
  32. package/plugins/lisa-devin/.mcp.json +8 -0
  33. package/plugins/lisa-devin/agents/architecture-specialist.md +32 -0
  34. package/plugins/lisa-devin/agents/bug-fixer.md +43 -0
  35. package/plugins/lisa-devin/agents/builder.md +44 -0
  36. package/plugins/lisa-devin/agents/confluence-prd-intake.md +65 -0
  37. package/plugins/lisa-devin/agents/debug-specialist.md +34 -0
  38. package/plugins/lisa-devin/agents/eval-specialist.md +33 -0
  39. package/plugins/lisa-devin/agents/git-history-analyzer.md +183 -0
  40. package/plugins/lisa-devin/agents/github-agent.md +155 -0
  41. package/plugins/lisa-devin/agents/github-build-intake.md +64 -0
  42. package/plugins/lisa-devin/agents/github-prd-intake.md +66 -0
  43. package/plugins/lisa-devin/agents/jira-agent.md +135 -0
  44. package/plugins/lisa-devin/agents/jira-build-intake.md +64 -0
  45. package/plugins/lisa-devin/agents/learner.md +85 -0
  46. package/plugins/lisa-devin/agents/learning-judge.md +119 -0
  47. package/plugins/lisa-devin/agents/learnings-synthesizer.md +155 -0
  48. package/plugins/lisa-devin/agents/linear-agent.md +138 -0
  49. package/plugins/lisa-devin/agents/linear-build-intake.md +64 -0
  50. package/plugins/lisa-devin/agents/linear-prd-intake.md +66 -0
  51. package/plugins/lisa-devin/agents/notion-prd-intake.md +63 -0
  52. package/plugins/lisa-devin/agents/performance-specialist.md +26 -0
  53. package/plugins/lisa-devin/agents/pr-mining-specialist.md +85 -0
  54. package/plugins/lisa-devin/agents/product-specialist.md +30 -0
  55. package/plugins/lisa-devin/agents/quality-specialist.md +30 -0
  56. package/plugins/lisa-devin/agents/security-specialist.md +43 -0
  57. package/plugins/lisa-devin/agents/skill-evaluator.md +205 -0
  58. package/plugins/lisa-devin/agents/spec-conformance-specialist.md +56 -0
  59. package/plugins/lisa-devin/agents/test-specialist.md +32 -0
  60. package/plugins/lisa-devin/agents/tracker-mining-specialist.md +85 -0
  61. package/plugins/lisa-devin/agents/verification-specialist.md +145 -0
  62. package/plugins/lisa-devin/hooks/auto-update.mjs +824 -0
  63. package/plugins/lisa-devin/hooks/auto-update.sh +31 -0
  64. package/plugins/lisa-devin/hooks/block-blind-automerge.sh +1242 -0
  65. package/plugins/lisa-devin/hooks/block-direct-issue-create.sh +2603 -0
  66. package/plugins/lisa-devin/hooks/block-host-name-leak.mjs +412 -0
  67. package/plugins/lisa-devin/hooks/block-host-name-leak.sh +61 -0
  68. package/plugins/lisa-devin/hooks/block-instruction-file-edits.sh +207 -0
  69. package/plugins/lisa-devin/hooks/block-managed-file-edits.sh +1175 -0
  70. package/plugins/lisa-devin/hooks/block-no-verify.sh +2288 -0
  71. package/plugins/lisa-devin/hooks/block-shell-json-parsing.sh +263 -0
  72. package/plugins/lisa-devin/hooks/cleanup-stale-worktrees.sh +122 -0
  73. package/plugins/lisa-devin/hooks/devin-tool-name-shim.sh +30 -0
  74. package/plugins/lisa-devin/hooks/discharge-work-item-gates.sh +90 -0
  75. package/plugins/lisa-devin/hooks/enforce-verification-gate.sh +506 -0
  76. package/plugins/lisa-devin/hooks/enforcement-vintage-npm.mjs +320 -0
  77. package/plugins/lisa-devin/hooks/enforcement-vintage.mjs +472 -0
  78. package/plugins/lisa-devin/hooks/enforcement-vintage.sh +48 -0
  79. package/plugins/lisa-devin/hooks/failure-signature-index.mjs +717 -0
  80. package/plugins/lisa-devin/hooks/failure-signature-index.sh +26 -0
  81. package/plugins/lisa-devin/hooks/guard-dedupe.bash +374 -0
  82. package/plugins/lisa-devin/hooks/inject-resolved-config.mjs +903 -0
  83. package/plugins/lisa-devin/hooks/inject-resolved-config.sh +107 -0
  84. package/plugins/lisa-devin/hooks/inject-rules.sh +49 -0
  85. package/plugins/lisa-devin/hooks/install-pkgs.sh +84 -0
  86. package/plugins/lisa-devin/hooks/operational-hazards.mjs +736 -0
  87. package/plugins/lisa-devin/hooks/operational-hazards.sh +28 -0
  88. package/plugins/lisa-devin/hooks/parity-safety-net-heredoc.py +1004 -0
  89. package/plugins/lisa-devin/hooks/parity-safety-net.sh +3275 -0
  90. package/plugins/lisa-devin/hooks/secrets-preflight.sh +72 -0
  91. package/plugins/lisa-devin/hooks/setup-jira-cli.sh +205 -0
  92. package/plugins/lisa-devin/hooks/shell-write-nudge.sh +86 -0
  93. package/plugins/lisa-devin/hooks/sonar-secrets.sh +205 -0
  94. package/plugins/lisa-devin/hooks/threshold-ratchet-compare.mjs +518 -0
  95. package/plugins/lisa-devin/hooks/threshold-ratchet-families.mjs +543 -0
  96. package/plugins/lisa-devin/hooks/threshold-ratchet.mjs +568 -0
  97. package/plugins/lisa-devin/hooks/threshold-ratchet.sh +22 -0
  98. package/plugins/lisa-devin/hooks/withdrawn-rulings.mjs +600 -0
  99. package/plugins/lisa-devin/hooks/withdrawn-rulings.sh +24 -0
  100. package/plugins/lisa-devin/hooks/worktree-binding-guard.mjs +1383 -0
  101. package/plugins/lisa-devin/hooks/worktree-binding-guard.sh +79 -0
  102. package/plugins/lisa-devin/hooks.json +247 -0
  103. package/plugins/lisa-devin/rules/eager/00-rule-index.md +82 -0
  104. package/plugins/lisa-devin/rules/eager/base-rules.md +85 -0
  105. package/plugins/lisa-devin/rules/eager/coding-philosophy.md +27 -0
  106. package/plugins/lisa-devin/rules/eager/config-resolution.md +37 -0
  107. package/plugins/lisa-devin/rules/eager/do-it-now.md +11 -0
  108. package/plugins/lisa-devin/rules/eager/empirical-inquiry.md +24 -0
  109. package/plugins/lisa-devin/rules/eager/falsifiable-checks.md +48 -0
  110. package/plugins/lisa-devin/rules/eager/history-audit.md +27 -0
  111. package/plugins/lisa-devin/rules/eager/integration-access-layer.md +19 -0
  112. package/plugins/lisa-devin/rules/eager/intent-routing.md +18 -0
  113. package/plugins/lisa-devin/rules/eager/learnings-ladder.md +18 -0
  114. package/plugins/lisa-devin/rules/eager/local-ci-first.md +15 -0
  115. package/plugins/lisa-devin/rules/eager/measurement-integrity.md +18 -0
  116. package/plugins/lisa-devin/rules/eager/never-name-a-downstream-project.md +9 -0
  117. package/plugins/lisa-devin/rules/eager/not-blocked-just-waiting.md +13 -0
  118. package/plugins/lisa-devin/rules/eager/operational-hazards.md +50 -0
  119. package/plugins/lisa-devin/rules/eager/report-actionability.md +26 -0
  120. package/plugins/lisa-devin/rules/eager/security-audit-handling.md +40 -0
  121. package/plugins/lisa-devin/rules/eager/session-status-updates.md +20 -0
  122. package/plugins/lisa-devin/rules/eager/settled-decisions.md +75 -0
  123. package/plugins/lisa-devin/rules/eager/stale-state-claims.md +26 -0
  124. package/plugins/lisa-devin/rules/eager/tool-access-gate.md +23 -0
  125. package/plugins/lisa-devin/rules/eager/tracked-work.md +9 -0
  126. package/plugins/lisa-devin/rules/eager/upstream-to-lisa.md +14 -0
  127. package/plugins/lisa-devin/rules/eager/verification.md +33 -0
  128. package/plugins/lisa-devin/rules/eager/wiki-knowledge-source.md +13 -0
  129. package/plugins/lisa-devin/rules/eager/withdrawn-rulings.md +102 -0
  130. package/plugins/lisa-devin/rules/reference/automation-runbook-contract.md +355 -0
  131. package/plugins/lisa-devin/rules/reference/base-rules.md +136 -0
  132. package/plugins/lisa-devin/rules/reference/bdd-e2e-coverage.md +301 -0
  133. package/plugins/lisa-devin/rules/reference/blocker-containment.md +165 -0
  134. package/plugins/lisa-devin/rules/reference/claim-archaeology.md +184 -0
  135. package/plugins/lisa-devin/rules/reference/claim-evidence-mapping.md +362 -0
  136. package/plugins/lisa-devin/rules/reference/claim-time-guards.md +135 -0
  137. package/plugins/lisa-devin/rules/reference/coding-philosophy.md +428 -0
  138. package/plugins/lisa-devin/rules/reference/config-resolution.md +1225 -0
  139. package/plugins/lisa-devin/rules/reference/control-reachability.md +166 -0
  140. package/plugins/lisa-devin/rules/reference/convergent-review.md +145 -0
  141. package/plugins/lisa-devin/rules/reference/credential-substrate-precedence.md +249 -0
  142. package/plugins/lisa-devin/rules/reference/dependency-decision-records.md +177 -0
  143. package/plugins/lisa-devin/rules/reference/dependency-internalization-kit.md +244 -0
  144. package/plugins/lisa-devin/rules/reference/dependency-trust-classes.md +291 -0
  145. package/plugins/lisa-devin/rules/reference/deployed-state-readback.md +142 -0
  146. package/plugins/lisa-devin/rules/reference/derived-branch-plan.md +214 -0
  147. package/plugins/lisa-devin/rules/reference/design-source-of-truth.md +260 -0
  148. package/plugins/lisa-devin/rules/reference/design-value-binding.md +311 -0
  149. package/plugins/lisa-devin/rules/reference/do-it-now.md +19 -0
  150. package/plugins/lisa-devin/rules/reference/documentation-source-paths.md +31 -0
  151. package/plugins/lisa-devin/rules/reference/empirical-inquiry.md +28 -0
  152. package/plugins/lisa-devin/rules/reference/factory-model.md +111 -0
  153. package/plugins/lisa-devin/rules/reference/falsifiable-checks.md +275 -0
  154. package/plugins/lisa-devin/rules/reference/history-audit.md +42 -0
  155. package/plugins/lisa-devin/rules/reference/integration-access-layer.md +89 -0
  156. package/plugins/lisa-devin/rules/reference/intent-routing.md +428 -0
  157. package/plugins/lisa-devin/rules/reference/leaf-only-lifecycle.md +227 -0
  158. package/plugins/lisa-devin/rules/reference/learnings-ladder.md +41 -0
  159. package/plugins/lisa-devin/rules/reference/local-ci-first.md +34 -0
  160. package/plugins/lisa-devin/rules/reference/measurement-integrity.md +119 -0
  161. package/plugins/lisa-devin/rules/reference/never-name-a-downstream-project.md +37 -0
  162. package/plugins/lisa-devin/rules/reference/not-blocked-just-waiting.md +41 -0
  163. package/plugins/lisa-devin/rules/reference/observability-audit.md +146 -0
  164. package/plugins/lisa-devin/rules/reference/operational-hazards.md +115 -0
  165. package/plugins/lisa-devin/rules/reference/prd-definition-of-ready.md +118 -0
  166. package/plugins/lisa-devin/rules/reference/prd-lifecycle-rollup.md +192 -0
  167. package/plugins/lisa-devin/rules/reference/pre-flight-autofill.md +250 -0
  168. package/plugins/lisa-devin/rules/reference/project-learnings.md +159 -0
  169. package/plugins/lisa-devin/rules/reference/promotion-contract.md +148 -0
  170. package/plugins/lisa-devin/rules/reference/readiness-rubric.md +242 -0
  171. package/plugins/lisa-devin/rules/reference/ready-role-filing.md +212 -0
  172. package/plugins/lisa-devin/rules/reference/rejection-detection.md +308 -0
  173. package/plugins/lisa-devin/rules/reference/repo-scope-split.md +119 -0
  174. package/plugins/lisa-devin/rules/reference/report-actionability.md +61 -0
  175. package/plugins/lisa-devin/rules/reference/reset-seed-coverage.md +286 -0
  176. package/plugins/lisa-devin/rules/reference/security-audit-handling.md +55 -0
  177. package/plugins/lisa-devin/rules/reference/session-status-updates.md +111 -0
  178. package/plugins/lisa-devin/rules/reference/settled-decisions.md +39 -0
  179. package/plugins/lisa-devin/rules/reference/stale-state-claims.md +88 -0
  180. package/plugins/lisa-devin/rules/reference/tool-access-gate.md +114 -0
  181. package/plugins/lisa-devin/rules/reference/tracked-work.md +49 -0
  182. package/plugins/lisa-devin/rules/reference/upstream-to-lisa.md +26 -0
  183. package/plugins/lisa-devin/rules/reference/usage-accounting.md +261 -0
  184. package/plugins/lisa-devin/rules/reference/verification.md +278 -0
  185. package/plugins/lisa-devin/rules/reference/wiki-knowledge-source.md +37 -0
  186. package/plugins/lisa-devin/rules/reference/withdrawn-rulings.md +148 -0
  187. package/plugins/lisa-devin/rules/reference/work-item-definition-of-ready.md +177 -0
  188. package/plugins/lisa-devin/rules/reference/work-item-trailer-definition.md +129 -0
  189. package/plugins/lisa-devin/scripts/automation-run-record.mjs +678 -0
  190. package/plugins/lisa-devin/scripts/automation-status-claude-adapter.mjs +804 -0
  191. package/plugins/lisa-devin/scripts/automation-status-codex-adapter.mjs +1081 -0
  192. package/plugins/lisa-devin/scripts/automation-status-contract-drift.mjs +392 -0
  193. package/plugins/lisa-devin/scripts/automation-status-expected-fleet.mjs +508 -0
  194. package/plugins/lisa-devin/scripts/automation-status-report.mjs +200 -0
  195. package/plugins/lisa-devin/scripts/automation-status-run-history.mjs +216 -0
  196. package/plugins/lisa-devin/scripts/automation-status-unrecorded-runs.mjs +127 -0
  197. package/plugins/lisa-devin/scripts/blocker-edge-resolution.mjs +150 -0
  198. package/plugins/lisa-devin/scripts/cross-pollinate.mjs +763 -0
  199. package/plugins/lisa-devin/scripts/design-bindings-probe.mjs +713 -0
  200. package/plugins/lisa-devin/scripts/design-intake-gate.mjs +470 -0
  201. package/plugins/lisa-devin/scripts/design-source-gate.mjs +610 -0
  202. package/plugins/lisa-devin/scripts/design-variable-ids.mjs +567 -0
  203. package/plugins/lisa-devin/scripts/doctor-report.mjs +635 -0
  204. package/plugins/lisa-devin/scripts/install-remote-agent-aws.mjs +443 -0
  205. package/plugins/lisa-devin/scripts/intake-blocker-reprobe.mjs +1065 -0
  206. package/plugins/lisa-devin/scripts/intake-prework-denominator.mjs +249 -0
  207. package/plugins/lisa-devin/scripts/lifecycle-label-trust.mjs +593 -0
  208. package/plugins/lisa-devin/scripts/linear-state-write-target.mjs +362 -0
  209. package/plugins/lisa-devin/scripts/plugin-sync-explain.mjs +654 -0
  210. package/plugins/lisa-devin/scripts/pr-arming-sweep.mjs +561 -0
  211. package/plugins/lisa-devin/scripts/project-ideation-idempotency-harness.mjs +338 -0
  212. package/plugins/lisa-devin/scripts/qa-signal-lifecycle.mjs +523 -0
  213. package/plugins/lisa-devin/scripts/queue-contract-resolution.mjs +583 -0
  214. package/plugins/lisa-devin/scripts/queue-health-classification.mjs +157 -0
  215. package/plugins/lisa-devin/scripts/queue-status-build-readers.mjs +561 -0
  216. package/plugins/lisa-devin/scripts/queue-status-prd-readers.mjs +452 -0
  217. package/plugins/lisa-devin/scripts/remote-agent-aws-setup.sh +677 -0
  218. package/plugins/lisa-devin/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  219. package/plugins/lisa-devin/scripts/resolve-lifecycle-role.mjs +347 -0
  220. package/plugins/lisa-devin/scripts/rollup-blocker-classification.mjs +616 -0
  221. package/plugins/lisa-devin/scripts/scoping-label-audit.mjs +474 -0
  222. package/plugins/lisa-devin/skills/lisa-acceptance-criteria/SKILL.md +73 -0
  223. package/plugins/lisa-devin/skills/lisa-agent-design-best-practices/SKILL.md +242 -0
  224. package/plugins/lisa-devin/skills/lisa-agent-ready/SKILL.md +271 -0
  225. package/plugins/lisa-devin/skills/lisa-analyze-claude-remote/SKILL.md +434 -0
  226. package/plugins/lisa-devin/skills/lisa-atlassian-access/SKILL.md +469 -0
  227. package/plugins/lisa-devin/skills/lisa-atlassian-access/scripts/markdown-to-adf.mjs +236 -0
  228. package/plugins/lisa-devin/skills/lisa-attribute-failure/SKILL.md +142 -0
  229. package/plugins/lisa-devin/skills/lisa-automation-status/SKILL.md +149 -0
  230. package/plugins/lisa-devin/skills/lisa-bug-triage/SKILL.md +23 -0
  231. package/plugins/lisa-devin/skills/lisa-capability-drift/SKILL.md +71 -0
  232. package/plugins/lisa-devin/skills/lisa-codebase-research/SKILL.md +87 -0
  233. package/plugins/lisa-devin/skills/lisa-codify-verification/SKILL.md +213 -0
  234. package/plugins/lisa-devin/skills/lisa-confluence-prd-intake/SKILL.md +417 -0
  235. package/plugins/lisa-devin/skills/lisa-confluence-to-tracker/SKILL.md +469 -0
  236. package/plugins/lisa-devin/skills/lisa-confluence-write-prd/SKILL.md +111 -0
  237. package/plugins/lisa-devin/skills/lisa-cross-pollinate/SKILL.md +175 -0
  238. package/plugins/lisa-devin/skills/lisa-debrief/SKILL.md +96 -0
  239. package/plugins/lisa-devin/skills/lisa-debrief-apply/SKILL.md +106 -0
  240. package/plugins/lisa-devin/skills/lisa-delivery-effectiveness/SKILL.md +62 -0
  241. package/plugins/lisa-devin/skills/lisa-design-intake/SKILL.md +198 -0
  242. package/plugins/lisa-devin/skills/lisa-detect-tooling/SKILL.md +110 -0
  243. package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  244. package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +658 -0
  245. package/plugins/lisa-devin/skills/lisa-doctor/SKILL.md +743 -0
  246. package/plugins/lisa-devin/skills/lisa-drive-pr-to-merge/SKILL.md +1587 -0
  247. package/plugins/lisa-devin/skills/lisa-epic-triage/SKILL.md +28 -0
  248. package/plugins/lisa-devin/skills/lisa-evaluation-suite/SKILL.md +63 -0
  249. package/plugins/lisa-devin/skills/lisa-exploratory-qa/SKILL.md +185 -0
  250. package/plugins/lisa-devin/skills/lisa-fix-linter-error/SKILL.md +45 -0
  251. package/plugins/lisa-devin/skills/lisa-generate-claude-remote-build-script/SKILL.md +235 -0
  252. package/plugins/lisa-devin/skills/lisa-git-commit/SKILL.md +53 -0
  253. package/plugins/lisa-devin/skills/lisa-git-prune/SKILL.md +35 -0
  254. package/plugins/lisa-devin/skills/lisa-git-submit-pr/SKILL.md +170 -0
  255. package/plugins/lisa-devin/skills/lisa-github-add-journey/SKILL.md +118 -0
  256. package/plugins/lisa-devin/skills/lisa-github-build-intake/SKILL.md +745 -0
  257. package/plugins/lisa-devin/skills/lisa-github-claim/SKILL.md +27 -0
  258. package/plugins/lisa-devin/skills/lisa-github-create/SKILL.md +105 -0
  259. package/plugins/lisa-devin/skills/lisa-github-evidence/SKILL.md +120 -0
  260. package/plugins/lisa-devin/skills/lisa-github-journey/SKILL.md +134 -0
  261. package/plugins/lisa-devin/skills/lisa-github-prd-intake/SKILL.md +424 -0
  262. package/plugins/lisa-devin/skills/lisa-github-project-v2/SKILL.md +227 -0
  263. package/plugins/lisa-devin/skills/lisa-github-read-issue/SKILL.md +305 -0
  264. package/plugins/lisa-devin/skills/lisa-github-sync/SKILL.md +201 -0
  265. package/plugins/lisa-devin/skills/lisa-github-to-tracker/SKILL.md +456 -0
  266. package/plugins/lisa-devin/skills/lisa-github-validate-issue/SKILL.md +598 -0
  267. package/plugins/lisa-devin/skills/lisa-github-verify/SKILL.md +60 -0
  268. package/plugins/lisa-devin/skills/lisa-github-write-issue/SKILL.md +531 -0
  269. package/plugins/lisa-devin/skills/lisa-github-write-prd/SKILL.md +156 -0
  270. package/plugins/lisa-devin/skills/lisa-health/SKILL.md +142 -0
  271. package/plugins/lisa-devin/skills/lisa-health-drift-cron/SKILL.md +80 -0
  272. package/plugins/lisa-devin/skills/lisa-implement/SKILL.md +372 -0
  273. package/plugins/lisa-devin/skills/lisa-improve-code-complexity/SKILL.md +44 -0
  274. package/plugins/lisa-devin/skills/lisa-improve-harness/SKILL.md +487 -0
  275. package/plugins/lisa-devin/skills/lisa-improve-max-lines/SKILL.md +45 -0
  276. package/plugins/lisa-devin/skills/lisa-improve-max-lines-per-function/SKILL.md +46 -0
  277. package/plugins/lisa-devin/skills/lisa-improve-test-coverage/SKILL.md +47 -0
  278. package/plugins/lisa-devin/skills/lisa-improve-tests/SKILL.md +50 -0
  279. package/plugins/lisa-devin/skills/lisa-intake/SKILL.md +208 -0
  280. package/plugins/lisa-devin/skills/lisa-intake-explain/SKILL.md +279 -0
  281. package/plugins/lisa-devin/skills/lisa-jam-access/SKILL.md +128 -0
  282. package/plugins/lisa-devin/skills/lisa-jira-add-journey/SKILL.md +139 -0
  283. package/plugins/lisa-devin/skills/lisa-jira-build-intake/SKILL.md +556 -0
  284. package/plugins/lisa-devin/skills/lisa-jira-claim/SKILL.md +27 -0
  285. package/plugins/lisa-devin/skills/lisa-jira-create/SKILL.md +157 -0
  286. package/plugins/lisa-devin/skills/lisa-jira-evidence/SKILL.md +106 -0
  287. package/plugins/lisa-devin/skills/lisa-jira-evidence/scripts/post-evidence.sh +262 -0
  288. package/plugins/lisa-devin/skills/lisa-jira-journey/SKILL.md +168 -0
  289. package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/generate-templates.py +239 -0
  290. package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/parse-plan.py +570 -0
  291. package/plugins/lisa-devin/skills/lisa-jira-read-ticket/SKILL.md +199 -0
  292. package/plugins/lisa-devin/skills/lisa-jira-read-ticket/scripts/download-attachment.sh +211 -0
  293. package/plugins/lisa-devin/skills/lisa-jira-sync/SKILL.md +117 -0
  294. package/plugins/lisa-devin/skills/lisa-jira-validate-ticket/SKILL.md +525 -0
  295. package/plugins/lisa-devin/skills/lisa-jira-verify/SKILL.md +61 -0
  296. package/plugins/lisa-devin/skills/lisa-jira-write-ticket/SKILL.md +440 -0
  297. package/plugins/lisa-devin/skills/lisa-jsdoc-best-practices/SKILL.md +491 -0
  298. package/plugins/lisa-devin/skills/lisa-kane-browser/SKILL.md +80 -0
  299. package/plugins/lisa-devin/skills/lisa-learnings-audit/SKILL.md +490 -0
  300. package/plugins/lisa-devin/skills/lisa-linear-access/SKILL.md +483 -0
  301. package/plugins/lisa-devin/skills/lisa-linear-add-journey/SKILL.md +109 -0
  302. package/plugins/lisa-devin/skills/lisa-linear-build-intake/SKILL.md +548 -0
  303. package/plugins/lisa-devin/skills/lisa-linear-claim/SKILL.md +28 -0
  304. package/plugins/lisa-devin/skills/lisa-linear-create/SKILL.md +150 -0
  305. package/plugins/lisa-devin/skills/lisa-linear-evidence/SKILL.md +124 -0
  306. package/plugins/lisa-devin/skills/lisa-linear-journey/SKILL.md +147 -0
  307. package/plugins/lisa-devin/skills/lisa-linear-prd-intake/SKILL.md +411 -0
  308. package/plugins/lisa-devin/skills/lisa-linear-read-issue/SKILL.md +201 -0
  309. package/plugins/lisa-devin/skills/lisa-linear-sync/SKILL.md +164 -0
  310. package/plugins/lisa-devin/skills/lisa-linear-to-tracker/SKILL.md +454 -0
  311. package/plugins/lisa-devin/skills/lisa-linear-validate-issue/SKILL.md +504 -0
  312. package/plugins/lisa-devin/skills/lisa-linear-verify/SKILL.md +65 -0
  313. package/plugins/lisa-devin/skills/lisa-linear-write-issue/SKILL.md +448 -0
  314. package/plugins/lisa-devin/skills/lisa-linear-write-prd/SKILL.md +98 -0
  315. package/plugins/lisa-devin/skills/lisa-monitor/SKILL.md +224 -0
  316. package/plugins/lisa-devin/skills/lisa-nightly-add-test-coverage/SKILL.md +40 -0
  317. package/plugins/lisa-devin/skills/lisa-nightly-improve-tests/SKILL.md +29 -0
  318. package/plugins/lisa-devin/skills/lisa-nightly-lower-code-complexity/SKILL.md +42 -0
  319. package/plugins/lisa-devin/skills/lisa-notion-access/SKILL.md +301 -0
  320. package/plugins/lisa-devin/skills/lisa-notion-prd-intake/SKILL.md +360 -0
  321. package/plugins/lisa-devin/skills/lisa-notion-to-tracker/SKILL.md +467 -0
  322. package/plugins/lisa-devin/skills/lisa-notion-write-prd/SKILL.md +111 -0
  323. package/plugins/lisa-devin/skills/lisa-parity-code-review/SKILL.md +83 -0
  324. package/plugins/lisa-devin/skills/lisa-parity-code-simplifier/SKILL.md +76 -0
  325. package/plugins/lisa-devin/skills/lisa-parity-coderabbit/SKILL.md +87 -0
  326. package/plugins/lisa-devin/skills/lisa-parity-sentry-sdk-setup/SKILL.md +375 -0
  327. package/plugins/lisa-devin/skills/lisa-parity-sentry-seer/SKILL.md +168 -0
  328. package/plugins/lisa-devin/skills/lisa-parity-skill-creator/SKILL.md +155 -0
  329. package/plugins/lisa-devin/skills/lisa-performance-review/SKILL.md +94 -0
  330. package/plugins/lisa-devin/skills/lisa-persist-learning/SKILL.md +247 -0
  331. package/plugins/lisa-devin/skills/lisa-plan/SKILL.md +64 -0
  332. package/plugins/lisa-devin/skills/lisa-plugin-sync-explain/SKILL.md +53 -0
  333. package/plugins/lisa-devin/skills/lisa-posthog-access/SKILL.md +140 -0
  334. package/plugins/lisa-devin/skills/lisa-prd-backlink/SKILL.md +274 -0
  335. package/plugins/lisa-devin/skills/lisa-prd-source-write/SKILL.md +104 -0
  336. package/plugins/lisa-devin/skills/lisa-prd-ticket-coverage/SKILL.md +187 -0
  337. package/plugins/lisa-devin/skills/lisa-product-walkthrough/SKILL.md +106 -0
  338. package/plugins/lisa-devin/skills/lisa-project-ideation/SKILL.md +417 -0
  339. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/evidence-card-format.md +21 -0
  340. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/host-project-only.md +22 -0
  341. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/idempotency-verification-harness.md +57 -0
  342. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/public-external-inspiration.md +22 -0
  343. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/unavailable-data-rejection.md +22 -0
  344. package/plugins/lisa-devin/skills/lisa-pull-request-review/SKILL.md +132 -0
  345. package/plugins/lisa-devin/skills/lisa-quality-review/SKILL.md +63 -0
  346. package/plugins/lisa-devin/skills/lisa-queue-status/SKILL.md +215 -0
  347. package/plugins/lisa-devin/skills/lisa-remote-dispatch/SKILL.md +109 -0
  348. package/plugins/lisa-devin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +535 -0
  349. package/plugins/lisa-devin/skills/lisa-repair-intake/SKILL.md +1621 -0
  350. package/plugins/lisa-devin/skills/lisa-reproduce-bug/SKILL.md +75 -0
  351. package/plugins/lisa-devin/skills/lisa-research/SKILL.md +92 -0
  352. package/plugins/lisa-devin/skills/lisa-review-implementation/SKILL.md +209 -0
  353. package/plugins/lisa-devin/skills/lisa-review-local/SKILL.md +141 -0
  354. package/plugins/lisa-devin/skills/lisa-rework-triage/SKILL.md +206 -0
  355. package/plugins/lisa-devin/skills/lisa-root-cause-analysis/SKILL.md +135 -0
  356. package/plugins/lisa-devin/skills/lisa-secrets-access/SKILL.md +430 -0
  357. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +433 -0
  358. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +288 -0
  359. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +273 -0
  360. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/envfile.mjs +80 -0
  361. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/inspect-vault.mjs +142 -0
  362. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +1085 -0
  363. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  364. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  365. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +342 -0
  366. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  367. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/providers.mjs +805 -0
  368. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +680 -0
  369. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/read-secret-note.mjs +105 -0
  370. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  371. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +285 -0
  372. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/rotate-secret.mjs +297 -0
  373. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  374. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/surfaces.mjs +480 -0
  375. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +510 -0
  376. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +63 -0
  377. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/validate-config.mjs +550 -0
  378. package/plugins/lisa-devin/skills/lisa-security-review/SKILL.md +124 -0
  379. package/plugins/lisa-devin/skills/lisa-security-zap-scan/SKILL.md +53 -0
  380. package/plugins/lisa-devin/skills/lisa-sentry-access/SKILL.md +140 -0
  381. package/plugins/lisa-devin/skills/lisa-setup-atlassian/SKILL.md +407 -0
  382. package/plugins/lisa-devin/skills/lisa-setup-automations/SKILL.md +430 -0
  383. package/plugins/lisa-devin/skills/lisa-setup-automations/scripts/generate-workflow.mjs +288 -0
  384. package/plugins/lisa-devin/skills/lisa-setup-confluence/SKILL.md +254 -0
  385. package/plugins/lisa-devin/skills/lisa-setup-github/SKILL.md +319 -0
  386. package/plugins/lisa-devin/skills/lisa-setup-github-repo/SKILL.md +128 -0
  387. package/plugins/lisa-devin/skills/lisa-setup-jira/SKILL.md +198 -0
  388. package/plugins/lisa-devin/skills/lisa-setup-kane/SKILL.md +96 -0
  389. package/plugins/lisa-devin/skills/lisa-setup-linear/SKILL.md +375 -0
  390. package/plugins/lisa-devin/skills/lisa-setup-local-env/SKILL.md +95 -0
  391. package/plugins/lisa-devin/skills/lisa-setup-local-env/scripts/local-env.mjs +196 -0
  392. package/plugins/lisa-devin/skills/lisa-setup-notion/SKILL.md +376 -0
  393. package/plugins/lisa-devin/skills/lisa-setup-remote-aws/SKILL.md +149 -0
  394. package/plugins/lisa-devin/skills/lisa-setup-remote-env/SKILL.md +382 -0
  395. package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/materialized-env-authority.mjs +262 -0
  396. package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/session-start.sh +87 -0
  397. package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/setup.sh +216 -0
  398. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
  399. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +378 -0
  400. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +1632 -0
  401. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  402. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +448 -0
  403. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +250 -0
  404. package/plugins/lisa-devin/skills/lisa-setup-sonar/SKILL.md +153 -0
  405. package/plugins/lisa-devin/skills/lisa-setup-workstation/SKILL.md +143 -0
  406. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/bounded-child.mjs +168 -0
  407. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/catalogue.mjs +293 -0
  408. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/cli.mjs +333 -0
  409. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/workstation.mjs +511 -0
  410. package/plugins/lisa-devin/skills/lisa-sonarcloud-access/SKILL.md +202 -0
  411. package/plugins/lisa-devin/skills/lisa-spec-conformance/SKILL.md +205 -0
  412. package/plugins/lisa-devin/skills/lisa-starter-sync/SKILL.md +35 -0
  413. package/plugins/lisa-devin/skills/lisa-sync-down/SKILL.md +209 -0
  414. package/plugins/lisa-devin/skills/lisa-task-decomposition/SKILL.md +195 -0
  415. package/plugins/lisa-devin/skills/lisa-task-triage/SKILL.md +23 -0
  416. package/plugins/lisa-devin/skills/lisa-tdd-implementation/SKILL.md +91 -0
  417. package/plugins/lisa-devin/skills/lisa-tear-down-automations/SKILL.md +85 -0
  418. package/plugins/lisa-devin/skills/lisa-test-strategy/SKILL.md +66 -0
  419. package/plugins/lisa-devin/skills/lisa-ticket-triage/SKILL.md +214 -0
  420. package/plugins/lisa-devin/skills/lisa-track/SKILL.md +129 -0
  421. package/plugins/lisa-devin/skills/lisa-tracker-add-journey/SKILL.md +28 -0
  422. package/plugins/lisa-devin/skills/lisa-tracker-build-intake/SKILL.md +134 -0
  423. package/plugins/lisa-devin/skills/lisa-tracker-claim/SKILL.md +35 -0
  424. package/plugins/lisa-devin/skills/lisa-tracker-create/SKILL.md +28 -0
  425. package/plugins/lisa-devin/skills/lisa-tracker-evidence/SKILL.md +63 -0
  426. package/plugins/lisa-devin/skills/lisa-tracker-journey/SKILL.md +27 -0
  427. package/plugins/lisa-devin/skills/lisa-tracker-read/SKILL.md +28 -0
  428. package/plugins/lisa-devin/skills/lisa-tracker-source-artifacts/SKILL.md +111 -0
  429. package/plugins/lisa-devin/skills/lisa-tracker-sync/SKILL.md +150 -0
  430. package/plugins/lisa-devin/skills/lisa-tracker-validate/SKILL.md +37 -0
  431. package/plugins/lisa-devin/skills/lisa-tracker-verify/SKILL.md +29 -0
  432. package/plugins/lisa-devin/skills/lisa-tracker-write/SKILL.md +55 -0
  433. package/plugins/lisa-devin/skills/lisa-usage-accounting/SKILL.md +205 -0
  434. package/plugins/lisa-devin/skills/lisa-usage-accounting/references/effectiveness.md +63 -0
  435. package/plugins/lisa-devin/skills/lisa-use-the-product/SKILL.md +86 -0
  436. package/plugins/lisa-devin/skills/lisa-validate-tracker-mapping/SKILL.md +467 -0
  437. package/plugins/lisa-devin/skills/lisa-verification-lifecycle/SKILL.md +384 -0
  438. package/plugins/lisa-devin/skills/lisa-verify/SKILL.md +55 -0
  439. package/plugins/lisa-devin/skills/lisa-verify-prd/SKILL.md +394 -0
  440. package/plugins/lisa-devin/skills/lisa-verify-workflow-change/SKILL.md +154 -0
  441. package/plugins/lisa-devin/skills/lisa-wiki-install/SKILL.md +101 -0
  442. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  443. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  444. package/plugins/lisa-expo-agy/plugin.json +1 -1
  445. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  446. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  447. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +11 -0
  448. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +8 -0
  449. package/plugins/lisa-expo-devin/.mcp.json +8 -0
  450. package/plugins/lisa-expo-devin/THIRD-PARTY-NOTICES.md +84 -0
  451. package/plugins/lisa-expo-devin/agents/ops-specialist.md +124 -0
  452. package/plugins/lisa-expo-devin/hooks/devin-tool-name-shim.sh +30 -0
  453. package/plugins/lisa-expo-devin/hooks/register-maestro-mcp.sh +84 -0
  454. package/plugins/lisa-expo-devin/hooks.json +14 -0
  455. package/plugins/lisa-expo-devin/skills/add-app-clip/SKILL.md +280 -0
  456. package/plugins/lisa-expo-devin/skills/add-app-clip/references/native-module.md +96 -0
  457. package/plugins/lisa-expo-devin/skills/apollo-client/SKILL.md +238 -0
  458. package/plugins/lisa-expo-devin/skills/apollo-client/references/mutation-patterns.md +360 -0
  459. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/SKILL.md +369 -0
  460. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/atomic-levels.md +417 -0
  461. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/folder-structure.md +257 -0
  462. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/gluestack-mapping.md +233 -0
  463. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/test_validate_atomic_structure.py +240 -0
  464. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/validate_atomic_structure.py +438 -0
  465. package/plugins/lisa-expo-devin/skills/building-native-ui/SKILL.md +321 -0
  466. package/plugins/lisa-expo-devin/skills/building-native-ui/references/animations.md +220 -0
  467. package/plugins/lisa-expo-devin/skills/building-native-ui/references/controls.md +272 -0
  468. package/plugins/lisa-expo-devin/skills/building-native-ui/references/form-sheet.md +253 -0
  469. package/plugins/lisa-expo-devin/skills/building-native-ui/references/gradients.md +106 -0
  470. package/plugins/lisa-expo-devin/skills/building-native-ui/references/icons.md +213 -0
  471. package/plugins/lisa-expo-devin/skills/building-native-ui/references/media.md +198 -0
  472. package/plugins/lisa-expo-devin/skills/building-native-ui/references/route-structure.md +229 -0
  473. package/plugins/lisa-expo-devin/skills/building-native-ui/references/search.md +248 -0
  474. package/plugins/lisa-expo-devin/skills/building-native-ui/references/storage.md +121 -0
  475. package/plugins/lisa-expo-devin/skills/building-native-ui/references/tabs.md +433 -0
  476. package/plugins/lisa-expo-devin/skills/building-native-ui/references/toolbar-and-headers.md +284 -0
  477. package/plugins/lisa-expo-devin/skills/building-native-ui/references/visual-effects.md +197 -0
  478. package/plugins/lisa-expo-devin/skills/building-native-ui/references/webgpu-three.md +605 -0
  479. package/plugins/lisa-expo-devin/skills/building-native-ui/references/zoom-transitions.md +158 -0
  480. package/plugins/lisa-expo-devin/skills/container-view-pattern/SKILL.md +355 -0
  481. package/plugins/lisa-expo-devin/skills/container-view-pattern/references/examples.md +749 -0
  482. package/plugins/lisa-expo-devin/skills/container-view-pattern/references/patterns.md +318 -0
  483. package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/create_component.py +200 -0
  484. package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/validate_component.py +332 -0
  485. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/SKILL.md +268 -0
  486. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/common-issues.md +619 -0
  487. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/file-extensions.md +340 -0
  488. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/platform-api.md +276 -0
  489. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/scripts/validate_cross_platform.py +416 -0
  490. package/plugins/lisa-expo-devin/skills/directory-structure/SKILL.md +214 -0
  491. package/plugins/lisa-expo-devin/skills/directory-structure/scripts/validate_structure.py +454 -0
  492. package/plugins/lisa-expo-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
  493. package/plugins/lisa-expo-devin/skills/eas-update-insights/SKILL.md +228 -0
  494. package/plugins/lisa-expo-devin/skills/eas-update-insights/references/channel-insights-schema.md +47 -0
  495. package/plugins/lisa-expo-devin/skills/eas-update-insights/references/update-insights-schema.md +69 -0
  496. package/plugins/lisa-expo-devin/skills/expo-api-routes/SKILL.md +369 -0
  497. package/plugins/lisa-expo-devin/skills/expo-brownfield/SKILL.md +54 -0
  498. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-integrated.md +526 -0
  499. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-isolated.md +402 -0
  500. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/comparison.md +63 -0
  501. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/troubleshooting.md +88 -0
  502. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/SKILL.md +92 -0
  503. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/fetch.js +113 -0
  504. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/package.json +11 -0
  505. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/validate.js +85 -0
  506. package/plugins/lisa-expo-devin/skills/expo-deployment/SKILL.md +193 -0
  507. package/plugins/lisa-expo-devin/skills/expo-deployment/references/app-store-metadata.md +479 -0
  508. package/plugins/lisa-expo-devin/skills/expo-deployment/references/ios-app-store.md +355 -0
  509. package/plugins/lisa-expo-devin/skills/expo-deployment/references/play-store.md +264 -0
  510. package/plugins/lisa-expo-devin/skills/expo-deployment/references/testflight.md +58 -0
  511. package/plugins/lisa-expo-devin/skills/expo-deployment/references/workflows.md +200 -0
  512. package/plugins/lisa-expo-devin/skills/expo-dev-client/SKILL.md +164 -0
  513. package/plugins/lisa-expo-devin/skills/expo-env-config/SKILL.md +309 -0
  514. package/plugins/lisa-expo-devin/skills/expo-env-config/references/validation-patterns.md +417 -0
  515. package/plugins/lisa-expo-devin/skills/expo-module/SKILL.md +141 -0
  516. package/plugins/lisa-expo-devin/skills/expo-module/references/config-plugin.md +90 -0
  517. package/plugins/lisa-expo-devin/skills/expo-module/references/create-expo-module.md +206 -0
  518. package/plugins/lisa-expo-devin/skills/expo-module/references/lifecycle.md +127 -0
  519. package/plugins/lisa-expo-devin/skills/expo-module/references/module-config.md +48 -0
  520. package/plugins/lisa-expo-devin/skills/expo-module/references/native-module.md +286 -0
  521. package/plugins/lisa-expo-devin/skills/expo-module/references/native-view.md +171 -0
  522. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/SKILL.md +432 -0
  523. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/official-docs.md +290 -0
  524. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/static-export-verification.md +54 -0
  525. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/scripts/generate-route.py +171 -0
  526. package/plugins/lisa-expo-devin/skills/expo-tailwind-setup/SKILL.md +487 -0
  527. package/plugins/lisa-expo-devin/skills/expo-ui-jetpack-compose/SKILL.md +40 -0
  528. package/plugins/lisa-expo-devin/skills/expo-ui-swift-ui/SKILL.md +39 -0
  529. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/SKILL.md +419 -0
  530. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/color-tokens.md +343 -0
  531. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/component-mapping.md +307 -0
  532. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/spacing-scale.md +300 -0
  533. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/test_validate_styling.py +240 -0
  534. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/validate_styling.py +424 -0
  535. package/plugins/lisa-expo-devin/skills/jira-add-journey/SKILL.md +140 -0
  536. package/plugins/lisa-expo-devin/skills/jira-create/SKILL.md +161 -0
  537. package/plugins/lisa-expo-devin/skills/jira-evidence/SKILL.md +84 -0
  538. package/plugins/lisa-expo-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
  539. package/plugins/lisa-expo-devin/skills/jira-journey/SKILL.md +196 -0
  540. package/plugins/lisa-expo-devin/skills/jira-journey/scripts/generate-templates.py +281 -0
  541. package/plugins/lisa-expo-devin/skills/jira-journey/scripts/parse-plan.py +400 -0
  542. package/plugins/lisa-expo-devin/skills/jira-verify/SKILL.md +27 -0
  543. package/plugins/lisa-expo-devin/skills/local-state/SKILL.md +362 -0
  544. package/plugins/lisa-expo-devin/skills/local-state/references/async-storage.md +505 -0
  545. package/plugins/lisa-expo-devin/skills/local-state/references/persistence-patterns.md +711 -0
  546. package/plugins/lisa-expo-devin/skills/local-state/references/reactive-variables.md +446 -0
  547. package/plugins/lisa-expo-devin/skills/maestro-mcp-setup/SKILL.md +144 -0
  548. package/plugins/lisa-expo-devin/skills/native-data-fetching/SKILL.md +507 -0
  549. package/plugins/lisa-expo-devin/skills/native-data-fetching/references/expo-router-loaders.md +344 -0
  550. package/plugins/lisa-expo-devin/skills/ops-browser-uat/SKILL.md +124 -0
  551. package/plugins/lisa-expo-devin/skills/ops-check-logs/SKILL.md +217 -0
  552. package/plugins/lisa-expo-devin/skills/ops-db-ops/SKILL.md +214 -0
  553. package/plugins/lisa-expo-devin/skills/ops-deploy/SKILL.md +123 -0
  554. package/plugins/lisa-expo-devin/skills/ops-monitor-errors/SKILL.md +99 -0
  555. package/plugins/lisa-expo-devin/skills/ops-performance/SKILL.md +165 -0
  556. package/plugins/lisa-expo-devin/skills/ops-run-local/SKILL.md +169 -0
  557. package/plugins/lisa-expo-devin/skills/ops-verify-health/SKILL.md +101 -0
  558. package/plugins/lisa-expo-devin/skills/owasp-zap/SKILL.md +61 -0
  559. package/plugins/lisa-expo-devin/skills/play-store-access/SKILL.md +226 -0
  560. package/plugins/lisa-expo-devin/skills/playwright-ci-debugging/SKILL.md +140 -0
  561. package/plugins/lisa-expo-devin/skills/playwright-selectors/SKILL.md +496 -0
  562. package/plugins/lisa-expo-devin/skills/reduce-complexity/SKILL.md +251 -0
  563. package/plugins/lisa-expo-devin/skills/reduce-complexity/references/extraction-strategies.md +456 -0
  564. package/plugins/lisa-expo-devin/skills/reduce-complexity/references/refactoring-patterns.md +557 -0
  565. package/plugins/lisa-expo-devin/skills/testing-library/SKILL.md +314 -0
  566. package/plugins/lisa-expo-devin/skills/testing-library/references/async-patterns.md +420 -0
  567. package/plugins/lisa-expo-devin/skills/testing-library/references/expo-router-testing.md +556 -0
  568. package/plugins/lisa-expo-devin/skills/testing-library/references/mocking-patterns.md +612 -0
  569. package/plugins/lisa-expo-devin/skills/testing-library/references/query-priority.md +291 -0
  570. package/plugins/lisa-expo-devin/skills/upgrading-expo/SKILL.md +134 -0
  571. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-audio.md +132 -0
  572. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-video.md +160 -0
  573. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/native-tabs.md +124 -0
  574. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/new-architecture.md +79 -0
  575. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-19.md +79 -0
  576. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-compiler.md +59 -0
  577. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-navigation-to-expo-router.md +61 -0
  578. package/plugins/lisa-expo-devin/skills/use-dom/SKILL.md +417 -0
  579. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  580. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  581. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  582. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  583. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  584. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +11 -0
  585. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +8 -0
  586. package/plugins/lisa-harper-fabric-devin/generated-artifact-globs.txt +5 -0
  587. package/plugins/lisa-harper-fabric-devin/hooks/block-generated-artifact-edits.sh +141 -0
  588. package/plugins/lisa-harper-fabric-devin/hooks/devin-tool-name-shim.sh +30 -0
  589. package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.mjs +196 -0
  590. package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.sh +46 -0
  591. package/plugins/lisa-harper-fabric-devin/hooks/inject-rules.sh +27 -0
  592. package/plugins/lisa-harper-fabric-devin/hooks.json +36 -0
  593. package/plugins/lisa-harper-fabric-devin/rules/eager/harper-fabric.md +31 -0
  594. package/plugins/lisa-harper-fabric-devin/rules/reference/harper-fabric.md +52 -0
  595. package/plugins/lisa-harper-fabric-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
  596. package/plugins/lisa-harper-fabric-devin/skills/harper-auth/SKILL.md +368 -0
  597. package/plugins/lisa-harper-fabric-devin/skills/harper-build-and-deploy/SKILL.md +198 -0
  598. package/plugins/lisa-harper-fabric-devin/skills/harper-caching/SKILL.md +265 -0
  599. package/plugins/lisa-harper-fabric-devin/skills/harper-component-model/SKILL.md +87 -0
  600. package/plugins/lisa-harper-fabric-devin/skills/harper-config-yaml/SKILL.md +283 -0
  601. package/plugins/lisa-harper-fabric-devin/skills/harper-operations/SKILL.md +239 -0
  602. package/plugins/lisa-harper-fabric-devin/skills/harper-realtime/SKILL.md +187 -0
  603. package/plugins/lisa-harper-fabric-devin/skills/harper-resources/SKILL.md +142 -0
  604. package/plugins/lisa-harper-fabric-devin/skills/harper-rest-queries/SKILL.md +403 -0
  605. package/plugins/lisa-harper-fabric-devin/skills/harper-schema-graphql/SKILL.md +123 -0
  606. package/plugins/lisa-harper-fabric-devin/skills/harper-testing/SKILL.md +254 -0
  607. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  608. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  609. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  610. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  611. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  612. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +11 -0
  613. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +8 -0
  614. package/plugins/lisa-nestjs-devin/hooks/block-migration-edits.sh +125 -0
  615. package/plugins/lisa-nestjs-devin/hooks/devin-tool-name-shim.sh +30 -0
  616. package/plugins/lisa-nestjs-devin/hooks/lisa-edit-gate.sh +152 -0
  617. package/plugins/lisa-nestjs-devin/hooks.json +15 -0
  618. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/SKILL.md +176 -0
  619. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/advanced-features.md +527 -0
  620. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/project-patterns.md +531 -0
  621. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/quick-start.md +257 -0
  622. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/resolvers-mutations.md +413 -0
  623. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/types-scalars.md +513 -0
  624. package/plugins/lisa-nestjs-devin/skills/nestjs-rules/SKILL.md +560 -0
  625. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/SKILL.md +275 -0
  626. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/configuration-patterns.md +487 -0
  627. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/entity-patterns.md +450 -0
  628. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/observability-patterns.md +536 -0
  629. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  630. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  631. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  632. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  633. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  634. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +8 -0
  635. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +8 -0
  636. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/SKILL.md +163 -0
  637. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/references/repo-topic-config.md +185 -0
  638. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/SKILL.md +175 -0
  639. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/platform-routing.md +123 -0
  640. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/prompts.md +78 -0
  641. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-setup/SKILL.md +141 -0
  642. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  643. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  644. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  645. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  646. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  647. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +11 -0
  648. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +8 -0
  649. package/plugins/lisa-phaser-devin/agents/accessibility-advocate.md +55 -0
  650. package/plugins/lisa-phaser-devin/agents/art-director.md +90 -0
  651. package/plugins/lisa-phaser-devin/agents/audio-director.md +65 -0
  652. package/plugins/lisa-phaser-devin/agents/combat-designer.md +61 -0
  653. package/plugins/lisa-phaser-devin/agents/economy-designer.md +59 -0
  654. package/plugins/lisa-phaser-devin/agents/game-designer.md +62 -0
  655. package/plugins/lisa-phaser-devin/agents/game-feel-specialist.md +56 -0
  656. package/plugins/lisa-phaser-devin/agents/level-designer.md +59 -0
  657. package/plugins/lisa-phaser-devin/agents/localization-manager.md +61 -0
  658. package/plugins/lisa-phaser-devin/agents/marketing-strategist.md +59 -0
  659. package/plugins/lisa-phaser-devin/agents/monetization-designer.md +59 -0
  660. package/plugins/lisa-phaser-devin/agents/narrative-designer.md +56 -0
  661. package/plugins/lisa-phaser-devin/agents/onboarding-advocate.md +59 -0
  662. package/plugins/lisa-phaser-devin/agents/player-advocate.md +57 -0
  663. package/plugins/lisa-phaser-devin/agents/producer.md +61 -0
  664. package/plugins/lisa-phaser-devin/agents/product-analyst.md +62 -0
  665. package/plugins/lisa-phaser-devin/agents/publisher.md +59 -0
  666. package/plugins/lisa-phaser-devin/agents/qa-playtester.md +57 -0
  667. package/plugins/lisa-phaser-devin/agents/target-player.md +52 -0
  668. package/plugins/lisa-phaser-devin/agents/ux-ui-designer.md +60 -0
  669. package/plugins/lisa-phaser-devin/hooks/devin-tool-name-shim.sh +30 -0
  670. package/plugins/lisa-phaser-devin/hooks/inject-rules.sh +27 -0
  671. package/plugins/lisa-phaser-devin/hooks.json +14 -0
  672. package/plugins/lisa-phaser-devin/rules/eager/phaser.md +44 -0
  673. package/plugins/lisa-phaser-devin/rules/reference/phaser.md +184 -0
  674. package/plugins/lisa-phaser-devin/skills/phaser-accessibility/SKILL.md +135 -0
  675. package/plugins/lisa-phaser-devin/skills/phaser-asset-pipeline/SKILL.md +222 -0
  676. package/plugins/lisa-phaser-devin/skills/phaser-asset-sourcing/SKILL.md +151 -0
  677. package/plugins/lisa-phaser-devin/skills/phaser-build-deploy/SKILL.md +153 -0
  678. package/plugins/lisa-phaser-devin/skills/phaser-i18n/SKILL.md +122 -0
  679. package/plugins/lisa-phaser-devin/skills/phaser-project-structure/SKILL.md +89 -0
  680. package/plugins/lisa-phaser-devin/skills/phaser-services/SKILL.md +208 -0
  681. package/plugins/lisa-phaser-devin/skills/phaser-testing/SKILL.md +192 -0
  682. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  683. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  684. package/plugins/lisa-rails-agy/plugin.json +1 -1
  685. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  686. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  687. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +11 -0
  688. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +8 -0
  689. package/plugins/lisa-rails-devin/agents/ops-specialist.md +226 -0
  690. package/plugins/lisa-rails-devin/hooks/devin-tool-name-shim.sh +30 -0
  691. package/plugins/lisa-rails-devin/hooks/inject-rules.sh +40 -0
  692. package/plugins/lisa-rails-devin/hooks/lisa-edit-gate.sh +152 -0
  693. package/plugins/lisa-rails-devin/hooks/rubocop-on-edit.sh +101 -0
  694. package/plugins/lisa-rails-devin/hooks/sg-scan-on-edit.sh +97 -0
  695. package/plugins/lisa-rails-devin/hooks.json +29 -0
  696. package/plugins/lisa-rails-devin/rules/eager/rails-conventions.md +23 -0
  697. package/plugins/lisa-rails-devin/rules/reference/rails-conventions.md +176 -0
  698. package/plugins/lisa-rails-devin/skills/action-controller-best-practices/SKILL.md +374 -0
  699. package/plugins/lisa-rails-devin/skills/action-view-best-practices/SKILL.md +335 -0
  700. package/plugins/lisa-rails-devin/skills/active-record-model-best-practices/SKILL.md +166 -0
  701. package/plugins/lisa-rails-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
  702. package/plugins/lisa-rails-devin/skills/fix-linter-error/SKILL.md +45 -0
  703. package/plugins/lisa-rails-devin/skills/improve-code-complexity/SKILL.md +51 -0
  704. package/plugins/lisa-rails-devin/skills/improve-max-lines/SKILL.md +46 -0
  705. package/plugins/lisa-rails-devin/skills/improve-max-lines-per-function/SKILL.md +46 -0
  706. package/plugins/lisa-rails-devin/skills/improve-test-coverage/SKILL.md +48 -0
  707. package/plugins/lisa-rails-devin/skills/jira-add-journey/SKILL.md +79 -0
  708. package/plugins/lisa-rails-devin/skills/jira-create/SKILL.md +100 -0
  709. package/plugins/lisa-rails-devin/skills/jira-evidence/SKILL.md +76 -0
  710. package/plugins/lisa-rails-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
  711. package/plugins/lisa-rails-devin/skills/jira-journey/SKILL.md +65 -0
  712. package/plugins/lisa-rails-devin/skills/jira-verify/SKILL.md +26 -0
  713. package/plugins/lisa-rails-devin/skills/ops-check-logs/SKILL.md +191 -0
  714. package/plugins/lisa-rails-devin/skills/ops-deploy/SKILL.md +153 -0
  715. package/plugins/lisa-rails-devin/skills/ops-run-local/SKILL.md +169 -0
  716. package/plugins/lisa-rails-devin/skills/ops-verify-jobs/SKILL.md +157 -0
  717. package/plugins/lisa-rails-devin/skills/ops-verify-telemetry/SKILL.md +226 -0
  718. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  719. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  720. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  721. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  722. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  723. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +11 -0
  724. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +8 -0
  725. package/plugins/lisa-typescript-devin/hooks/block-suppress-directives.sh +141 -0
  726. package/plugins/lisa-typescript-devin/hooks/devin-tool-name-shim.sh +30 -0
  727. package/plugins/lisa-typescript-devin/hooks/format-on-edit.sh +102 -0
  728. package/plugins/lisa-typescript-devin/hooks/lint-on-edit.sh +117 -0
  729. package/plugins/lisa-typescript-devin/hooks/lisa-edit-gate.sh +152 -0
  730. package/plugins/lisa-typescript-devin/hooks/sg-scan-on-edit.sh +94 -0
  731. package/plugins/lisa-typescript-devin/hooks.json +34 -0
  732. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  733. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  734. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  735. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  736. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  737. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +8 -0
  738. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +8 -0
  739. package/plugins/lisa-wiki-devin/ci/lisa-wiki-validate.yml +33 -0
  740. package/plugins/lisa-wiki-devin/schema/lisa-wiki-config.schema.json +162 -0
  741. package/plugins/lisa-wiki-devin/schema/wiki-structure.schema.json +51 -0
  742. package/plugins/lisa-wiki-devin/scripts/_wiki-lib.mjs +204 -0
  743. package/plugins/lisa-wiki-devin/scripts/diff-guard.mjs +117 -0
  744. package/plugins/lisa-wiki-devin/scripts/ensure-gitignore.mjs +131 -0
  745. package/plugins/lisa-wiki-devin/scripts/ensure-wiki.mjs +268 -0
  746. package/plugins/lisa-wiki-devin/scripts/ingest-git.mjs +198 -0
  747. package/plugins/lisa-wiki-devin/scripts/ingest-memory.mjs +135 -0
  748. package/plugins/lisa-wiki-devin/scripts/ingest-roles.mjs +95 -0
  749. package/plugins/lisa-wiki-devin/scripts/ingest_slack_channel.py +361 -0
  750. package/plugins/lisa-wiki-devin/scripts/lib/bounded-child.mjs +159 -0
  751. package/plugins/lisa-wiki-devin/scripts/lint-wiki.mjs +513 -0
  752. package/plugins/lisa-wiki-devin/scripts/mcp-doctor.mjs +72 -0
  753. package/plugins/lisa-wiki-devin/scripts/render-contract.mjs +107 -0
  754. package/plugins/lisa-wiki-devin/scripts/rewrite-refs.mjs +144 -0
  755. package/plugins/lisa-wiki-devin/scripts/slack_oauth_user.py +179 -0
  756. package/plugins/lisa-wiki-devin/scripts/validate-config.mjs +321 -0
  757. package/plugins/lisa-wiki-devin/scripts/verify-migration.mjs +203 -0
  758. package/plugins/lisa-wiki-devin/scripts/verify-wiki-safety.mjs +126 -0
  759. package/plugins/lisa-wiki-devin/scripts/wiki-safety.mjs +378 -0
  760. package/plugins/lisa-wiki-devin/scripts/wiki-status.mjs +345 -0
  761. package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  762. package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-role/SKILL.md +35 -0
  763. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  764. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  765. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  766. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  767. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  768. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  769. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  770. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  771. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  772. package/plugins/lisa-wiki-devin/skills/lisa-wiki-doctor/SKILL.md +47 -0
  773. package/plugins/lisa-wiki-devin/skills/lisa-wiki-ingest/SKILL.md +106 -0
  774. package/plugins/lisa-wiki-devin/skills/lisa-wiki-lint/SKILL.md +32 -0
  775. package/plugins/lisa-wiki-devin/skills/lisa-wiki-migrate/SKILL.md +43 -0
  776. package/plugins/lisa-wiki-devin/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  777. package/plugins/lisa-wiki-devin/skills/lisa-wiki-query/SKILL.md +40 -0
  778. package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup/SKILL.md +75 -0
  779. package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup-automations/SKILL.md +83 -0
  780. package/plugins/lisa-wiki-devin/skills/lisa-wiki-status/SKILL.md +47 -0
  781. package/plugins/lisa-wiki-devin/skills/lisa-wiki-tear-down-automations/SKILL.md +34 -0
  782. package/plugins/lisa-wiki-devin/skills/lisa-wiki-usage/SKILL.md +50 -0
  783. package/plugins/lisa-wiki-devin/templates/agents/role-agent.claude.md +16 -0
  784. package/plugins/lisa-wiki-devin/templates/agents/role-agent.codex.toml +15 -0
  785. package/plugins/lisa-wiki-devin/templates/index.md +17 -0
  786. package/plugins/lisa-wiki-devin/templates/llm-wiki-contract.md +72 -0
  787. package/plugins/lisa-wiki-devin/templates/log.md +8 -0
  788. package/plugins/lisa-wiki-devin/templates/page-types/architecture.md +18 -0
  789. package/plugins/lisa-wiki-devin/templates/page-types/concept.md +18 -0
  790. package/plugins/lisa-wiki-devin/templates/page-types/decision.md +18 -0
  791. package/plugins/lisa-wiki-devin/templates/page-types/entity.md +19 -0
  792. package/plugins/lisa-wiki-devin/templates/page-types/open-question.md +18 -0
  793. package/plugins/lisa-wiki-devin/templates/page-types/playbook.md +18 -0
  794. package/plugins/lisa-wiki-devin/templates/page-types/project.md +19 -0
  795. package/plugins/lisa-wiki-devin/templates/page-types/requirement.md +19 -0
  796. package/plugins/lisa-wiki-devin/templates/page-types/staff.md +26 -0
  797. package/plugins/lisa-wiki-devin/templates/start-here.md +24 -0
  798. package/plugins/lisa-wiki-devin/templates/state-readme.md +20 -0
  799. package/plugins/lisa-wiki-devin/templates/wrapper-gitignore.txt +19 -0
  800. package/scripts/build-plugins.sh +2 -2
  801. package/scripts/deployed-guard-advice.mjs +1 -0
  802. package/scripts/generate-devin-plugin-artifacts.mjs +263 -0
  803. package/scripts/lib/hook-registration-audit.mjs +3 -1
  804. package/scripts/lib/per-agent-hook-filter.mjs +174 -5
  805. package/scripts/lib/plugin-rule-citations.mjs +2 -2
@@ -0,0 +1,2288 @@
1
+ #!/usr/bin/env bash
2
+ # This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ # Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ # PreToolUse hook for Bash: blocks commands that bypass git's verification hooks.
6
+ # Bypassing pre-commit/pre-push hooks (which exist for a reason) is blocked in
7
+ # all of its forms; the fix is to address the underlying issue, not silence the
8
+ # check. See feedback_never_no_verify in user memory.
9
+ #
10
+ # Blocked bypass vectors:
11
+ # 1. --no-verify, and any prefix abbreviation git would accept for it;
12
+ # 2. HUSKY=0 / HUSKY_SKIP_HOOKS=... — disables husky-managed git hooks;
13
+ # 3. core.hooksPath pointed anywhere but an allowlisted in-repo hooks dir;
14
+ # 4. --config-env=core.hooksPath=VAR, in both the one- and two-token spellings;
15
+ # 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
16
+ # 6. GIT_CONFIG_PARAMETERS carrying core.hooksPath command-scope config;
17
+ # 7. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
18
+ # into a short-option cluster such as `-nm "msg"`;
19
+ # 8. a nested shell carrying any of the above as its `-c` command string,
20
+ # including behind long shell options such as `bash --norc -c`;
21
+ # 9. `env --split-string` in EVERY abbreviation GNU accepts (`--s` upward,
22
+ # with or without an attached `=value`);
23
+ # 10. those forms behind a wrapper that does not change what runs —
24
+ # `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`, and
25
+ # wrappers with an operand of their own (`nice -n 5`, `timeout 5`, `sudo`);
26
+ # 11. any of the above inside a script the command EXECUTES — `bash nv.sh`,
27
+ # `source`, `.`, `bash < nv.sh`, and an interpreter a `find -exec` or
28
+ # `xargs` puts at a command position.
29
+ #
30
+ # NOT vector 11: `bash -n nv.sh`. `-n` is noexec — the shell reads and parses
31
+ # the file, then exits without running a line — so a syntax check is the one
32
+ # shape that puts a path at a command position while executing nothing. It used
33
+ # to be refused, which made `bash -n` unusable on this file, on every husky
34
+ # hook, and on most of this repository (#3781). The distinction is noexec, not
35
+ # the program: `bash nv.sh` still executes and is still refused.
36
+ #
37
+ # The line below is what lets `lisa apply` tell a downstream copy of this guard
38
+ # that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
39
+ # "differs from mine", and guessing "behind" is how a fork's stronger guard gets
40
+ # silently replaced by a weaker upstream one. Refresh compares the two declared
41
+ # sets, and refuses to overwrite a copy declaring anything this one does not.
42
+ #
43
+ # Add a name here in the same commit that closes a vector. A hardening that
44
+ # forgets to is invisible to refresh, and shows up as an unexplained diff at
45
+ # review time instead of a named capability.
46
+ # lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, static-script-directory-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
47
+ #
48
+ # Shell-token matching avoids false positives from issue bodies, heredocs, and
49
+ # commit-message prose while still catching quoted real argv values such as
50
+ # `git -c "core.hooksPath=/dev/null"`.
51
+ #
52
+ # ## The long flag is scoped to a git argv, and that is a WEAKENING
53
+ #
54
+ # `--no-verify` used to match as a bare token anywhere on the line. Measured
55
+ # against that matcher: `echo --no-verify` BLOCK, `grep -rl -- "--no-verify"
56
+ # scripts/` BLOCK. Both are correct to ALLOW — neither runs git — and both are
57
+ # now allowed. They are listed here rather than left to be rediscovered, because
58
+ # giving up a refusal is exactly the change that deserves to be written down.
59
+ #
60
+ # It was given up because it had to be. Vector 11 above reads FILE CONTENTS, and
61
+ # this file, every husky hook, and much of this repository carry the literal
62
+ # token in prose and in refusal text. An unscoped matcher plus reach refuses
63
+ # `bash` on this guard's own source and on the repository's own operations, on
64
+ # the first command. Narrowing was the prerequisite, not a cleanup.
65
+ #
66
+ # Scoped to a git INVOCATION, not to `git commit`: `git push --no-verify` skips
67
+ # pre-push and `git am` / `git merge` skip their own hooks. Only the short `-n`
68
+ # needs the commit scope. See `git_argv_disables_verification`.
69
+ #
70
+ # `HUSKY=0` and `core.hooksPath=` stay unscoped, deliberately — they are
71
+ # assignments that configure whatever git runs next, so they legitimately sit
72
+ # before the invocation or on an earlier line. See `token_bypass`.
73
+ #
74
+ # The short `-n` form used to be excluded here, and the stated reason was that
75
+ # "grep cannot distinguish a real -n option from -n in commit-message prose or
76
+ # an unrelated piped command". That was true of the matcher it was written for.
77
+ # It is not true of this one: the command is TOKENIZED, not grepped, so `-m "fix
78
+ # the -n flag"` is one token holding the message and `grep -n` is a separate
79
+ # command — the two cases the comment called inseparable are separated by the
80
+ # same machinery `--config-env` and `GIT_CONFIG_KEY_<n>` already depend on.
81
+ #
82
+ # A rationale that outlives the implementation it describes is worse than none:
83
+ # it reads as a considered decision and stops the next reader from re-examining
84
+ # it. Measured against real git, `git commit -n`, `-nm msg`, and `-anm msg` all
85
+ # skip pre-commit exactly as completely as `--no-verify`, and `-nm` is the more
86
+ # likely spelling in practice because it reads as an ordinary message flag.
87
+ #
88
+ # What the old comment got right is the part about `-n` being common, and that
89
+ # concern is answered by SCOPE rather than by giving up: `-n` is `--dry-run` for
90
+ # `push`, `--no-stat` for `merge`, and a line count for head/tail/sort/grep, so
91
+ # the match is confined to the argv of a `git commit` invocation and nothing
92
+ # else. See `git_skips_verification` below.
93
+ set -euo pipefail
94
+
95
+ input="$(cat)"
96
+
97
+ # Evaluate once per tool call when this guard is registered on both channels.
98
+ #
99
+ # Lisa reaches an agent through the repository dispatcher AND the plugin
100
+ # manifest, and where both are live the harness runs this guard twice for one
101
+ # tool call. `guard-dedupe.bash` short-circuits the second run ONLY when a
102
+ # byte-identical copy already ALLOWED this exact payload on this exact tool
103
+ # call; a differing vintage, a refusal, and a host with one channel all
104
+ # evaluate exactly as before. Nothing is de-registered by it
105
+ # (CodySwannGT/lisa#3814).
106
+ #
107
+ # Absent library means no dedupe, which is the pre-existing behaviour, so an
108
+ # older channel copy that predates it is unaffected.
109
+ lisa_guard_hook_dir="${BASH_SOURCE[0]%/*}"
110
+ lisa_guard_dedupe_lib="$lisa_guard_hook_dir/guard-dedupe.bash"
111
+ if [ -r "$lisa_guard_dedupe_lib" ]; then
112
+ # shellcheck source=guard-dedupe.bash
113
+ . "$lisa_guard_dedupe_lib"
114
+ trap 'lisa_guard_dedupe_record $?' EXIT
115
+ lisa_guard_dedupe block-no-verify "$input"
116
+ fi
117
+
118
+ # Both interpreters must be probed BEFORE they are used, and a missing one must
119
+ # be announced rather than swallowed.
120
+ #
121
+ # `jq` used to be called unguarded here while `python3` two lines below was
122
+ # guarded. Under `set -e` an absent jq aborted the script with 127, and Claude
123
+ # Code treats any non-2 exit as a NON-BLOCKING hook error — so the hook that
124
+ # enforces "never --no-verify" silently permitted the very thing it exists to
125
+ # stop. It was not hypothetical: agent containers routinely ship no jq (that is
126
+ # why jq is now a pinned toolchain entry). The Codex variant of this script has
127
+ # always had the guard, so this was a parity gap rather than a design choice.
128
+ #
129
+ # Degrading to "allow" is still the right behaviour — a hook that cannot parse
130
+ # its input cannot tell a bypass from an ordinary command, and failing closed
131
+ # would block every Bash call on a machine missing an interpreter. What is NOT
132
+ # right is doing it quietly, so the operator gets one line on stderr saying the
133
+ # protection is off. A guard that is silently absent reads exactly like a guard
134
+ # that is passing.
135
+ for required in jq python3; do
136
+ if ! command -v "$required" >/dev/null 2>&1; then
137
+ printf 'block-no-verify: %s not found; --no-verify protection is NOT active\n' \
138
+ "$required" >&2
139
+ exit 0
140
+ fi
141
+ done
142
+
143
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
144
+ if [ "$tool_name" != "Bash" ]; then
145
+ exit 0
146
+ fi
147
+
148
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
149
+ if [ -z "$command_str" ]; then
150
+ exit 0
151
+ fi
152
+
153
+ if ! BLOCK_NO_VERIFY_COMMAND="$command_str" python3 - <<'PY'
154
+ import os
155
+ import io
156
+ import re
157
+ import shlex
158
+ import sys
159
+
160
+ command = os.environ.get("BLOCK_NO_VERIFY_COMMAND", "")
161
+
162
+
163
+ def strip_heredocs(text: str) -> str:
164
+ lines = text.splitlines()
165
+ output = []
166
+ pending = []
167
+ marker_pattern = re.compile(
168
+ r'''(?<!<)<<-?(?!<)\s*((?:'[^']*'|"(?:[^"\\]|\\.)*"|\\.|[^\s;&|<>()])+)'''
169
+ )
170
+
171
+ def marker_word(raw: str):
172
+ try:
173
+ words = shlex.split(raw, posix=True)
174
+ except ValueError:
175
+ return None
176
+ # An explicitly quoted empty word is a valid heredoc delimiter. Bash
177
+ # terminates that heredoc on the next empty line, so preserve "" as a
178
+ # marker instead of treating it as a parse failure.
179
+ return words[0] if len(words) == 1 else None
180
+
181
+ index = 0
182
+ while index < len(lines):
183
+ line = lines[index]
184
+ output.append(line)
185
+ pending.extend(
186
+ marker
187
+ for match in marker_pattern.finditer(line)
188
+ if (marker := marker_word(match.group(1))) is not None
189
+ )
190
+ index += 1
191
+ while pending and index < len(lines):
192
+ if lines[index].strip() == pending[0]:
193
+ output.append(lines[index])
194
+ pending.pop(0)
195
+ index += 1
196
+ break
197
+ index += 1
198
+ return "\n".join(output)
199
+
200
+
201
+ # The only relocations that keep a repo's own hooks in play. `.husky` is the
202
+ # husky convention this fleet runs; `.githooks` is the common hand-rolled one.
203
+ # Anything else — including "" and /dev/null, which are simply the two most
204
+ # obvious members of the blocked set rather than special cases — is refused.
205
+ PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
206
+
207
+
208
+ def is_permitted_hooks_path(value):
209
+ """Whether a core.hooksPath value relocates hooks rather than disabling them.
210
+
211
+ Args:
212
+ value: The raw core.hooksPath value as it appeared on the command line.
213
+
214
+ Returns:
215
+ True if the path is an established in-repo hooks directory.
216
+ """
217
+ cleaned = value.strip().strip("'\"")
218
+ if cleaned.startswith("./"):
219
+ cleaned = cleaned[2:]
220
+ return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
221
+
222
+
223
+ # Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-verify`
224
+ # is only the longest of the spellings that disable verification: `git commit
225
+ # --no-veri` skips hooks exactly as completely. An equality check therefore
226
+ # enforced the guard against the one spelling nobody in a hurry types.
227
+ #
228
+ # Matched as "a prefix of --no-verify" rather than by listing abbreviations,
229
+ # because the set of accepted abbreviations is a property of git's parser and
230
+ # changes with the surrounding options. `--no-verbose` is NOT caught, and must
231
+ # not be: it diverges from `--no-verify` at the character after `--no-ver`, so
232
+ # it fails the prefix test.
233
+ #
234
+ # The floor is `--no-v`. Shorter is refused anyway — bare `--no-` is not a flag
235
+ # — and blocking an abbreviation git would reject as ambiguous costs nothing,
236
+ # while missing one git accepts costs the whole guard.
237
+ NO_VERIFY = "--no-verify"
238
+ NO_VERIFY_MIN_PREFIX = len("--no-v")
239
+
240
+
241
+ def disables_verification(token):
242
+ """Whether a token is `--no-verify` or an abbreviation git would accept.
243
+
244
+ Args:
245
+ token: A single shell token from the command line.
246
+
247
+ Returns:
248
+ True if git would read this token as --no-verify.
249
+ """
250
+ return (
251
+ len(token) >= NO_VERIFY_MIN_PREFIX
252
+ and len(token) <= len(NO_VERIFY)
253
+ and NO_VERIFY.startswith(token)
254
+ )
255
+
256
+
257
+ # `git commit -n` is the short spelling of --no-verify and skips pre-commit and
258
+ # commit-msg identically. Matching it needs one thing the `--no-verify` scan
259
+ # above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
260
+ # merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
261
+ # would refuse ordinary commands all day. Only `git commit` reads it as a hook
262
+ # bypass, so only that invocation's argv is scanned.
263
+ #
264
+ # The tokenizer below is a SECOND pass, deliberately not shared with the one the
265
+ # checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
266
+ # which those checks paper over by stripping the punctuation off each token —
267
+ # fine when the question is "does this token look like a bypass anywhere in the
268
+ # line", fatal when the question is "where does this command end", because
269
+ # `git commit -m x && grep -n foo` would then read as one long git invocation
270
+ # and refuse the grep. `punctuation_chars=True` emits the operators as their own
271
+ # tokens, so the invocation's boundary is a token the scan can stop at.
272
+ COMMAND_SEPARATORS = {
273
+ ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
274
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
275
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
276
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
277
+ # whose arguments happened to include git. The case terminators end a
278
+ # command the same way.
279
+ "|&", ";;", ";&", ";;&",
280
+ }
281
+ # shlex also GLUES adjacent punctuation into one token across distinct
282
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
283
+ # test can recognise. An operator-only token is therefore re-split into the
284
+ # operators it is made of, longest first. Redirections are kept whole and are
285
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
286
+ # commit's argv scope.
287
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
288
+ SHELL_OPERATOR = re.compile("|".join(
289
+ re.escape(operator) for operator in sorted(
290
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
291
+ key=lambda operator: (-len(operator), operator),
292
+ )
293
+ ))
294
+
295
+
296
+ def split_operators(token):
297
+ """Split a glued operator-only token into the shell operators it holds.
298
+
299
+ Args:
300
+ token: One shlex token that was not read inside a quote or escape.
301
+
302
+ Returns:
303
+ The operators, longest first, or the token itself when it is not
304
+ made only of shell punctuation.
305
+ """
306
+ if OPERATOR_ONLY.match(token):
307
+ return SHELL_OPERATOR.findall(token)
308
+ return [token]
309
+
310
+ SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
311
+ MAX_SHELL_NESTING = 8
312
+
313
+ # GNU env's long options, listed in FULL so an abbreviation can be resolved
314
+ # against them. getopt_long accepts any prefix that names exactly one option,
315
+ # and no other env option begins with `s` — so `env --s`, `env --sp`, and
316
+ # `env --split` are `env --split-string` just as completely as the full
317
+ # spelling is. Matching only the full spelling is bypassed by deleting
318
+ # characters, which is why the set below exists instead of a literal list of
319
+ # the two spellings the guard used to know.
320
+ ENV_LONG_OPTIONS = {
321
+ "--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
322
+ "--help", "--ignore-environment", "--ignore-signal",
323
+ "--list-signal-handling", "--null", "--split-string", "--unset",
324
+ "--version",
325
+ }
326
+ # Of those, the ones whose value is a SEPARATE token, so the operand is never
327
+ # mistaken for the command (`env -u bash -c ...` consumes `bash` as the
328
+ # variable name). The signal options take an OPTIONAL value, which GNU reads
329
+ # only when it is attached with `=`, so they never consume a following token.
330
+ ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
331
+
332
+ # env's single-letter options, by whether the rest of a cluster is more
333
+ # options or the option's value.
334
+ #
335
+ # BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
336
+ # in neither set falls through to `ambiguous`, which this guard treats as
337
+ # suspicious. That default is correct and stays — an unknown option genuinely
338
+ # could take a value and swallow the command name. What is wrong is a
339
+ # well-known option being unknown, and on a macOS fleet `env -P` is exactly
340
+ # that: BSD's "search altpath for the utility", refused on every ordinary
341
+ # command it prefixes.
342
+ #
343
+ # Measured, one option at a time, against the guard before this change. BSD's
344
+ # complete short set is `0 i v u C P S`, from the synopsis in env(1):
345
+ #
346
+ # allowed -0 -i -v -u NAME -C DIR already covered
347
+ # BLOCKED -P altpath the defect, and the ONLY one
348
+ # BLOCKED -S string correct: split-string reparses
349
+ # an opaque payload, so what it
350
+ # runs cannot be proven here
351
+ # BLOCKED -Z -Q correct: unknown stays suspicious
352
+ #
353
+ # So the population is one letter, not a class. The two platforms' short
354
+ # options coincide everywhere else, which is why "detect the platform and
355
+ # choose a table" would be machinery for a single character — and would fail
356
+ # on a machine running GNU coreutils under a BSD userland, or the reverse.
357
+ # Recognising the union is both smaller and more robust.
358
+ #
359
+ # Adding a letter here is a WEAKENING, so it needs the same evidence as any
360
+ # other: ten bypass shapes routed through `-P` — long flag, attached value,
361
+ # short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
362
+ # a nested `-c` shell, an executed script, and `-P` swallowing the command
363
+ # word — were all refused before this change and are all still refused after.
364
+ ENV_SHORT_NO_VALUE = frozenset("i0v")
365
+ ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
366
+
367
+ # Builtins and wrappers that run whatever FOLLOWS them without changing what
368
+ # it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
369
+ # and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
370
+ # the command is how a payload hides from a guard that inspects only the first
371
+ # word of an invocation.
372
+ COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
373
+ # The only option of those that consumes a separate following token
374
+ # (`exec -a NAME env -S ...`).
375
+ COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
376
+
377
+ # Shell options whose following token is an operand, not another option. The
378
+ # generic recursive scanner must step over these before looking for `-c`.
379
+ SHELL_OPTIONS_SEPARATE_VALUE = {
380
+ "-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
381
+ }
382
+ SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
383
+
384
+ # Boolean long options of the recognized shells. Listed only for PRECISION:
385
+ # a long option is never the command-string flag either way, but naming the
386
+ # ordinary ones keeps `bash --norc script.sh` from being treated as a possible
387
+ # value-taking option whose operand must be scanned past. zsh and ksh accept a
388
+ # long option per shell setting, so this set is deliberately not exhaustive —
389
+ # the scanner stays correct on the ones it does not name.
390
+ SHELL_LONG_OPTIONS_NO_VALUE = {
391
+ "--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
392
+ "--globalrcs", "--help", "--interactive", "--login", "--monitor",
393
+ "--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
394
+ "--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
395
+ "--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
396
+ "--xtrace",
397
+ }
398
+
399
+ # git's own options that take a SEPARATE value token, which therefore must be
400
+ # skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
401
+ # `git -C /repo commit` both reach `commit`.
402
+ GIT_GLOBAL_SEPARATE_VALUE = {
403
+ "-c", "-C", "--config-env", "--git-dir", "--work-tree",
404
+ "--namespace", "--exec-path", "--super-prefix",
405
+ }
406
+
407
+ # `git commit` long options whose value can be a separate token. Listed so the
408
+ # VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
409
+ # must stay allowed. Only genuinely value-taking options belong here: adding a
410
+ # boolean one by mistake would swallow the token after it, and `git commit
411
+ # --amend -n` would go unnoticed.
412
+ COMMIT_LONG_SEPARATE_VALUE = {
413
+ "--message", "--file", "--author", "--date", "--template", "--cleanup",
414
+ "--reuse-message", "--reedit-message", "--fixup", "--squash",
415
+ "--pathspec-from-file", "--trailer",
416
+ }
417
+
418
+ # Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
419
+ # In a cluster the value is whatever follows them in the same token, or the next
420
+ # token when nothing does — which is why `-mn` is the message "n" and not a
421
+ # bypass, and `-nm msg` IS one.
422
+ COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
423
+
424
+ # Short options taking an OPTIONAL value, which git only ever reads attached:
425
+ # `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
426
+ # ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
427
+ COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
428
+
429
+
430
+ def line_boundaries_as_separators(text):
431
+ """Turn newlines into command separators, keeping continuations intact.
432
+
433
+ A newline ends a command exactly as `;` does, but shlex treats it as plain
434
+ whitespace — so `git commit -m x` on one line and `grep -n foo` on the next
435
+ read as ONE invocation and the grep gets refused. That is the same
436
+ false-positive class the short-form match exists to avoid, arriving through
437
+ the boundary rather than through the token.
438
+
439
+ Backslash-newline is joined first, because there the newline is NOT a
440
+ boundary: `git commit \\` + newline + `-nm x` is one command, and turning
441
+ that newline into a separator would hide a real bypass.
442
+
443
+ Args:
444
+ text: The command line, heredoc payloads already stripped.
445
+
446
+ Returns:
447
+ The same command with line breaks spelled as separators.
448
+ """
449
+ joined = text.replace("\r\n", "\n").replace("\\\n", " ")
450
+ return joined.replace("\n", " ; ")
451
+
452
+
453
+ def shell_tokens(text):
454
+ """Tokenize a command with shell operators kept as their own tokens.
455
+
456
+ Args:
457
+ text: The command line, heredoc payloads already stripped.
458
+
459
+ Returns:
460
+ The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
461
+ """
462
+ stream = OperatorStream(line_boundaries_as_separators(text))
463
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
464
+ lexer.whitespace_split = True
465
+ # shlex treats `#` as a comment introducer by default and would silently
466
+ # truncate the rest of the line; `shlex.split` disables it, and so must this.
467
+ lexer.commenters = ""
468
+ stream.lexer = lexer
469
+ tokens = []
470
+ while True:
471
+ stream.literal_operator = False
472
+ token = lexer.get_token()
473
+ if token is None:
474
+ return tokens
475
+ # Quoted punctuation is an argument, never a boundary: it stays whole,
476
+ # and a quoted separator is marked so no scan stops at it (a path or
477
+ # branch can be named `|&`). Only real operator runs are split.
478
+ if stream.literal_operator:
479
+ tokens.append(
480
+ chr(0) + token if token in COMMAND_SEPARATORS else token
481
+ )
482
+ else:
483
+ tokens.extend(split_operators(token))
484
+
485
+
486
+ def cluster_skips_verification(cluster):
487
+ """Read a short-option cluster the way git's own parser reads it.
488
+
489
+ Args:
490
+ cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
491
+
492
+ Returns:
493
+ A pair (bypasses, consumes_next_token). `bypasses` is True when a real
494
+ `-n` option is present; `consumes_next_token` is True when the cluster
495
+ ends in a value-taking option whose value is the following token.
496
+ """
497
+ body = cluster[1:]
498
+ for offset, letter in enumerate(body):
499
+ if letter == "n":
500
+ return (True, False)
501
+ if letter in COMMIT_SHORT_REQUIRED_VALUE:
502
+ # Everything after this letter is the value. It is a separate token
503
+ # only when nothing is attached.
504
+ return (False, offset == len(body) - 1)
505
+ if letter in COMMIT_SHORT_OPTIONAL_VALUE:
506
+ return (False, False)
507
+ return (False, False)
508
+
509
+
510
+ def commit_bypasses_verification(argv):
511
+ """Whether a `git commit` invocation's argv carries a real short `-n`.
512
+
513
+ Args:
514
+ argv: Tokens following the `commit` subcommand, to the end of the line.
515
+
516
+ Returns:
517
+ True if git would read one of them as --no-verify.
518
+ """
519
+ index = 0
520
+ while index < len(argv):
521
+ token = argv[index]
522
+ index += 1
523
+ # `--` ends the options; everything after it is a pathspec, and a file
524
+ # legitimately named `-n` is not a bypass.
525
+ if token in COMMAND_SEPARATORS or token == "--":
526
+ return False
527
+ if token.startswith("--"):
528
+ if token in COMMIT_LONG_SEPARATE_VALUE:
529
+ index += 1
530
+ elif disables_verification(token):
531
+ return True
532
+ continue
533
+ # A bare `-` is git's stdin placeholder, not an option cluster.
534
+ if not token.startswith("-") or token == "-":
535
+ continue
536
+ bypasses, consumes_next = cluster_skips_verification(token)
537
+ if bypasses:
538
+ return True
539
+ if consumes_next:
540
+ index += 1
541
+ return False
542
+
543
+
544
+ def subcommand_after_git(tokens, start):
545
+ """Find the subcommand a `git` token introduces, and its argv.
546
+
547
+ Args:
548
+ tokens: The full operator-aware token list.
549
+ start: Index just past the `git` token.
550
+
551
+ Returns:
552
+ A pair (subcommand, argv), or None when the invocation names none.
553
+ """
554
+ index = start
555
+ while index < len(tokens):
556
+ token = tokens[index]
557
+ if token in COMMAND_SEPARATORS:
558
+ return None
559
+ if not token.startswith("-"):
560
+ return (token, tokens[index + 1:])
561
+ index += 2 if token in GIT_GLOBAL_SEPARATE_VALUE else 1
562
+ return None
563
+
564
+
565
+ def resolve_env_long_option(name):
566
+ """Resolve one GNU env long option, abbreviations included.
567
+
568
+ Args:
569
+ name: A token beginning with `--`, with any `=value` already removed.
570
+
571
+ Returns:
572
+ The full option spelling; "" when the abbreviation names more than one
573
+ option, which real env refuses; None when nothing matches at all.
574
+ """
575
+ if not name.startswith("--") or name == "--":
576
+ return None
577
+ if name in ENV_LONG_OPTIONS:
578
+ return name
579
+ matches = [option for option in ENV_LONG_OPTIONS if option.startswith(name)]
580
+ if len(matches) == 1:
581
+ return matches[0]
582
+ return "" if matches else None
583
+
584
+
585
+ def env_option_kind(token):
586
+ """Classify one token sitting in an `env` option position.
587
+
588
+ Returns:
589
+ "split-string" when env will reparse an argv value as shell words,
590
+ "separate-value" when the NEXT token is this option's operand,
591
+ "no-value" when the token stands alone, "ambiguous" when env accepts
592
+ the token but this parser cannot say which option it is, or None when
593
+ the token is not an option.
594
+ """
595
+ if not token.startswith("-") or token in {"-", "--"}:
596
+ return None
597
+ if token.startswith("--"):
598
+ name = token.split("=", 1)[0]
599
+ resolved = resolve_env_long_option(name)
600
+ if not resolved:
601
+ # Either unknown to this parser or an abbreviation naming several
602
+ # options. Neither can be reasoned about, so say so and let the
603
+ # caller fail closed rather than read past it.
604
+ return "ambiguous"
605
+ if resolved == "--split-string":
606
+ return "split-string"
607
+ if "=" in token:
608
+ return "no-value"
609
+ return (
610
+ "separate-value" if resolved in ENV_LONG_SEPARATE_VALUE else "no-value"
611
+ )
612
+ for position, letter in enumerate(token[1:], start=2):
613
+ if letter == "S":
614
+ return "split-string"
615
+ if letter in ENV_SHORT_TAKES_VALUE:
616
+ # The rest of this cluster is the option's value when there is
617
+ # one; otherwise the value is the following token.
618
+ return "no-value" if position <= len(token) - 1 else "separate-value"
619
+ if letter not in ENV_SHORT_NO_VALUE:
620
+ return "ambiguous"
621
+ return "no-value"
622
+
623
+
624
+ def strip_command_prefix(prefix):
625
+ """Drop the tokens that precede a command without changing what it is.
626
+
627
+ Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
628
+ `env` wrappers all leave the following word in command position. Reading
629
+ any of them as the command itself is what let `command env -S ...` and
630
+ `env -- env -S ...` slip past the split-string check: the prefix failed
631
+ the "assignments only" test, so the wrapped `env` was never classified.
632
+
633
+ Args:
634
+ prefix: The tokens between the last command separator and the
635
+ candidate program.
636
+
637
+ Returns:
638
+ A pair (status, remainder). "ok" carries the tokens still standing
639
+ between the separator and the candidate — empty means the candidate is
640
+ in command position. "operand" means the candidate is a wrapper
641
+ option's value rather than a command. "ambiguous" means a wrapper
642
+ option could not be classified and the caller must fail closed.
643
+ """
644
+ cursor = 0
645
+ while cursor < len(prefix):
646
+ token = prefix[cursor]
647
+ if "=" in token and not token.startswith(("=", "-")):
648
+ cursor += 1
649
+ continue
650
+ program = token.rsplit("/", 1)[-1]
651
+ if program in COMMAND_PREFIX_WRAPPERS:
652
+ cursor += 1
653
+ while cursor < len(prefix) and prefix[cursor].startswith("-"):
654
+ if prefix[cursor] == "--":
655
+ cursor += 1
656
+ break
657
+ if prefix[cursor] in COMMAND_PREFIX_SEPARATE_VALUE:
658
+ if cursor + 1 >= len(prefix):
659
+ return ("operand", [])
660
+ cursor += 2
661
+ continue
662
+ cursor += 1
663
+ continue
664
+ if program == "env":
665
+ cursor += 1
666
+ while cursor < len(prefix):
667
+ option = prefix[cursor]
668
+ if option == "--":
669
+ cursor += 1
670
+ break
671
+ kind = env_option_kind(option)
672
+ if kind is None:
673
+ break
674
+ if kind == "ambiguous":
675
+ return ("ambiguous", [])
676
+ if kind == "split-string":
677
+ # Everything after `-S` is env's own opaque payload, so the
678
+ # candidate is not a command at all.
679
+ return ("operand", [])
680
+ if kind == "separate-value":
681
+ if cursor + 1 >= len(prefix):
682
+ return ("operand", [])
683
+ cursor += 2
684
+ continue
685
+ cursor += 1
686
+ continue
687
+ break
688
+ return ("ok", prefix[cursor:])
689
+
690
+
691
+ def shell_starts_command(tokens, index):
692
+ """Whether a recognized shell token occupies command position."""
693
+ start = index - 1
694
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
695
+ start -= 1
696
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
697
+ if status == "ambiguous":
698
+ # A prefix option this parser cannot classify may still be launching
699
+ # the shell. Fail closed rather than trusting the wrapper.
700
+ return True
701
+ return status == "ok" and not remainder
702
+
703
+
704
+ def printed_argument(tokens, index):
705
+ """Whether a token is data passed to an ordinary echo or printf command."""
706
+ start = index - 1
707
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
708
+ start -= 1
709
+ program, _, opaque = command_word(tokens[start + 1:index])
710
+ return not opaque and program in {"echo", "printf"}
711
+
712
+
713
+ def env_uses_split_string(tokens, index):
714
+ """Whether a command-position env invocation uses split-string parsing.
715
+
716
+ `env -S` reparses one opaque argv value as shell-like words. Inspecting the
717
+ outer command cannot prove what executable or nested shell that second
718
+ parse will produce, so the verification guard refuses the ambiguous form.
719
+
720
+ Args:
721
+ tokens: The full operator-aware token list.
722
+ index: Index of the candidate env executable.
723
+
724
+ Returns:
725
+ True when env will reparse a split-string payload.
726
+ """
727
+ start = index - 1
728
+ while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
729
+ start -= 1
730
+ status, remainder = strip_command_prefix(tokens[start + 1:index])
731
+ if status != "ok" or remainder:
732
+ return False
733
+
734
+ cursor = index + 1
735
+ while cursor < len(tokens):
736
+ token = tokens[cursor]
737
+ if token in COMMAND_SEPARATORS or token == "--":
738
+ return False
739
+ kind = env_option_kind(token)
740
+ if kind == "split-string":
741
+ return True
742
+ if kind == "ambiguous":
743
+ # An option env accepts but this parser cannot name makes the rest
744
+ # of the invocation unreadable; refuse instead of reading past it.
745
+ return True
746
+ if kind is None:
747
+ if "=" in token and not token.startswith(("=", "-")):
748
+ cursor += 1
749
+ continue
750
+ return False
751
+ cursor += 2 if kind == "separate-value" else 1
752
+ return False
753
+
754
+
755
+ def nested_shell_payload(tokens, index):
756
+ """Return a shell `-c` payload, or True when option parsing is ambiguous."""
757
+ cursor = index + 1
758
+ opaque_option_seen = False
759
+ while cursor < len(tokens):
760
+ option = tokens[cursor]
761
+ if option in COMMAND_SEPARATORS or option == "--":
762
+ return None
763
+ if not option.startswith(("-", "+")):
764
+ # Ordinarily the script operand, which ends option parsing. After
765
+ # a long option this parser cannot name it may instead be that
766
+ # option's value (`zsh --emulate ksh -c ...`), so keep scanning
767
+ # rather than concluding there is no command string.
768
+ if not opaque_option_seen:
769
+ return None
770
+ cursor += 1
771
+ continue
772
+ if option in SHELL_OPTIONS_SEPARATE_VALUE:
773
+ if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
774
+ return True
775
+ cursor += 2
776
+ continue
777
+ if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
778
+ cursor += 1
779
+ continue
780
+ # A LONG option is never the command-string flag, and testing it for a
781
+ # bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
782
+ # so the real `-c` that followed was consumed as their payload and the
783
+ # nested `git commit --no-verify` was never classified at all. The
784
+ # command string lives only in a single-dash cluster (`-c`, `-lc`).
785
+ if option.startswith("--"):
786
+ if option not in SHELL_LONG_OPTIONS_NO_VALUE:
787
+ opaque_option_seen = True
788
+ cursor += 1
789
+ continue
790
+ if "c" in option[1:]:
791
+ if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
792
+ return True
793
+ return tokens[cursor + 1]
794
+ cursor += 1
795
+ return None
796
+
797
+
798
+ # ── Reach: the contents of a script this command EXECUTES ──────────────────
799
+ #
800
+ # The guard used to inspect argv and nothing else, so `bash nv.sh` showed it two
801
+ # tokens, `bash` and a path, while the script it was about to run carried
802
+ # `git commit --no-verify` and skipped pre-commit exactly as completely as the
803
+ # inline spelling. Measured before this change: inline BLOCK, the same line one
804
+ # file away ALLOW — on bash, sh, zsh, `source`, `.`, an absolute interpreter
805
+ # path, and behind every wrapper.
806
+ #
807
+ # THE RULE IS TAKEN FROM parity-safety-net.sh RATHER THAN INVENTED HERE: a path
808
+ # is followed ONLY when the command EXECUTES it. `grep -n x nv.sh`,
809
+ # `git commit -F msg.txt` and `gh pr create --body-file body.md` name a file as
810
+ # DATA, and following those is the known-wrong fix that guard names by number —
811
+ # it refuses ordinary reads and attributes a file's capability to a command that
812
+ # merely mentions it. So COMMAND POSITION decides, never the presence of a path.
813
+ #
814
+ # Non-shell interpreters (`python3 x.py`, `node x.mjs`) are deliberately NOT
815
+ # followed. These matchers are git and shell syntax; running them over a Python
816
+ # or JS file buys mis-attribution, not coverage.
817
+ #
818
+ # Reach was not safe to add until the bare-token match was narrowed to a git
819
+ # argv — see `git_argv_disables_verification`. With the old unscoped matcher
820
+ # this guard would have refused `bash` on its own source, on every husky hook,
821
+ # and on much of this repository, all of which carry the literal token in prose.
822
+ FOLLOW_MAX_BYTES = 262144
823
+ FOLLOW_MAX_FILES = 8
824
+ FOLLOW_MAX_DEPTH = 3
825
+
826
+ # The `.` builtin and its `source` alias run the named file in the CURRENT
827
+ # shell, which is execution by any definition.
828
+ SOURCE_BUILTINS = {"source", "."}
829
+
830
+ # Wrappers that run what FOLLOWS them without changing what it is, mapped to
831
+ # (options whose value is a SEPARATE token, positional operands consumed).
832
+ #
833
+ # The second field is why this table exists rather than a bare set. `timeout 5
834
+ # bash x` puts a POSITIONAL between the wrapper and the interpreter, so a walk
835
+ # that steps over `-flags` only stops at `5` and never reaches `bash` — the
836
+ # shape that walked past three guards in this family before it was named.
837
+ FOLLOW_WRAPPERS = {
838
+ "builtin": (frozenset(), 0),
839
+ "command": (frozenset(), 0),
840
+ "exec": (frozenset({"-a"}), 0),
841
+ "nice": (frozenset({"-n", "--adjustment"}), 0),
842
+ "nohup": (frozenset(), 0),
843
+ "setsid": (frozenset(), 0),
844
+ "stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
845
+ "sudo": (
846
+ frozenset(
847
+ {
848
+ "-C", "--close-from", "-g", "--group", "-h", "--host",
849
+ "-p", "--prompt", "-r", "--role", "-t", "--type",
850
+ "-U", "--other-user", "-u", "--user",
851
+ }
852
+ ),
853
+ 0,
854
+ ),
855
+ "time": (frozenset(), 0),
856
+ "timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
857
+ }
858
+
859
+ # Dispatchers that BUILD a command out of their own arguments, putting an
860
+ # interpreter at a command position the text does not spell as one. Scoped to
861
+ # exactly one token — the word after `find -exec`/`-execdir`, and `xargs`'s
862
+ # first operand — because latching "an interpreter name anywhere" refuses
863
+ # `find . -name bash`, where `bash` is a filename PATTERN and nothing runs.
864
+ FIND_EXEC_FLAGS = {"-exec", "-execdir"}
865
+
866
+ # A value the SHELL computes. This guard sees the command before expansion, so
867
+ # it cannot prove which file such a token will name.
868
+ COMPUTED_VALUE = re.compile(r"[$`]")
869
+
870
+
871
+ def strip_shell_comments(text):
872
+ """Remove `#` comments from a file's contents, quote-aware.
873
+
874
+ Applied to FILE CONTENTS and never to a typed command, because the two are
875
+ genuinely different problems. On a typed command `#` handling is a
876
+ bash-versus-shlex divergence a guard can be attacked through, which is why
877
+ `shell_tokens` sets `commenters = ""`. Inside a file the question is not
878
+ adversarial and the answer is unambiguous: a comment cannot execute.
879
+
880
+ It is also what makes reach usable at all. This repository's guards, its
881
+ husky hooks and its own refusal messages discuss `git commit -n` and
882
+ `--no-verify` in prose, and a comment line reading "`git commit -n` skips
883
+ pre-commit" tokenizes into exactly the argv of a real bypass. Following a
884
+ file without dropping its comments refuses this guard's own source.
885
+
886
+ Args:
887
+ text: A file's contents.
888
+
889
+ Returns:
890
+ The same text with unquoted comments removed.
891
+ """
892
+ output = []
893
+ for line in text.splitlines():
894
+ quote = ""
895
+ escaped = False
896
+ cut = None
897
+ for position, character in enumerate(line):
898
+ if escaped:
899
+ escaped = False
900
+ continue
901
+ if character == "\\" and quote != "'":
902
+ escaped = True
903
+ continue
904
+ if quote:
905
+ if character == quote:
906
+ quote = ""
907
+ continue
908
+ if character in "'\"":
909
+ quote = character
910
+ continue
911
+ # A `#` only opens a comment at the start of a word, so `x#y` and
912
+ # `${x#y}` keep their hashes.
913
+ if character == "#" and (position == 0 or line[position - 1].isspace()):
914
+ cut = position
915
+ break
916
+ output.append(line if cut is None else line[:cut])
917
+ return "\n".join(output)
918
+
919
+
920
+ def statement_slices(tokens):
921
+ """Split an operator-aware token list into statements.
922
+
923
+ Args:
924
+ tokens: The full operator-aware token list.
925
+
926
+ Returns:
927
+ Pairs of (separator that PRECEDED the statement, its tokens). The
928
+ separator is carried because `<` is both a statement boundary and the
929
+ link between `bash` and the script it reads — `bash < nv.sh` runs that
930
+ file as surely as `bash nv.sh` does.
931
+ """
932
+ statements = []
933
+ current = []
934
+ separator = ""
935
+ for token in tokens:
936
+ if token in COMMAND_SEPARATORS:
937
+ statements.append((separator, current))
938
+ current = []
939
+ separator = token
940
+ continue
941
+ current.append(token)
942
+ statements.append((separator, current))
943
+ return statements
944
+
945
+
946
+ def command_word(statement):
947
+ """The program a statement runs, and the tokens after it.
948
+
949
+ Steps over leading variable assignments and over wrappers that do not
950
+ change what runs. Reading a wrapper as the command is how a payload hides
951
+ from a guard that inspects only a statement's first word.
952
+
953
+ Args:
954
+ statement: One statement's tokens.
955
+
956
+ Returns:
957
+ A triple (program, args, opaque). `opaque` is True when a wrapper's
958
+ option grammar could not be read, which the caller must treat as an
959
+ execution it cannot follow rather than as an ordinary command.
960
+ """
961
+ index = 0
962
+ while index < len(statement):
963
+ token = statement[index]
964
+ if "=" in token and not token.startswith(("=", "-")):
965
+ index += 1
966
+ continue
967
+ program = token.rsplit("/", 1)[-1]
968
+ if program == "env":
969
+ index += 1
970
+ while index < len(statement):
971
+ option = statement[index]
972
+ if option == "--":
973
+ index += 1
974
+ break
975
+ kind = env_option_kind(option)
976
+ if kind is None:
977
+ if "=" in option and not option.startswith(("=", "-")):
978
+ index += 1
979
+ continue
980
+ break
981
+ if kind in {"ambiguous", "split-string"}:
982
+ # `env -S` reparses an opaque payload as shell words, and an
983
+ # unnameable option makes the rest unreadable. Neither can be
984
+ # resolved to a program.
985
+ return (None, [], True)
986
+ index += 2 if kind == "separate-value" else 1
987
+ continue
988
+ if program in FOLLOW_WRAPPERS:
989
+ separate, positional = FOLLOW_WRAPPERS[program]
990
+ index += 1
991
+ while index < len(statement) and statement[index].startswith("-"):
992
+ option = statement[index]
993
+ if option == "--":
994
+ index += 1
995
+ break
996
+ index += 2 if option in separate else 1
997
+ index += positional
998
+ continue
999
+ return (program, statement[index + 1 :], False)
1000
+ return (None, [], False)
1001
+
1002
+
1003
+ def shell_script_operand(args):
1004
+ """The file a shell invocation runs, when it runs one.
1005
+
1006
+ Args:
1007
+ args: The tokens following the shell's own name.
1008
+
1009
+ Returns:
1010
+ The operand token, or None when the invocation runs no script file —
1011
+ an interactive shell, or a `-c` command string, which
1012
+ `nested_shell_payload` already owns.
1013
+ """
1014
+ index = 0
1015
+ opaque_option_seen = False
1016
+ while index < len(args):
1017
+ token = args[index]
1018
+ if token == "--":
1019
+ index += 1
1020
+ break
1021
+ if not token.startswith(("-", "+")):
1022
+ if not opaque_option_seen:
1023
+ break
1024
+ index += 1
1025
+ opaque_option_seen = False
1026
+ continue
1027
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
1028
+ index += 2
1029
+ continue
1030
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
1031
+ index += 1
1032
+ continue
1033
+ if token.startswith("--"):
1034
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1035
+ opaque_option_seen = True
1036
+ index += 1
1037
+ continue
1038
+ if "c" in token[1:]:
1039
+ return None
1040
+ index += 1
1041
+ return args[index] if index < len(args) else None
1042
+
1043
+
1044
+ def dispatched_commands(program, args):
1045
+ """Commands a dispatcher builds out of its own arguments.
1046
+
1047
+ `find … -exec bash {} \\;` and `find … | xargs bash` put an interpreter at
1048
+ a command position the text does not spell as one. The exception is scoped
1049
+ to a `find`/`xargs` statement and to exactly ONE token, because latching an
1050
+ interpreter NAME anywhere refuses `find . -name bash` and
1051
+ `xargs grep bash`, where the word is a pattern and nothing is executed.
1052
+
1053
+ Args:
1054
+ program: The dispatcher's name.
1055
+ args: Its arguments.
1056
+
1057
+ Returns:
1058
+ Pairs of (program, args) the dispatcher will run.
1059
+ """
1060
+ built = []
1061
+ if program == "find":
1062
+ for index, token in enumerate(args):
1063
+ if token in FIND_EXEC_FLAGS and index + 1 < len(args):
1064
+ built.append((args[index + 1].rsplit("/", 1)[-1], args[index + 2 :]))
1065
+ return built
1066
+ if program != "xargs":
1067
+ return built
1068
+ index = 0
1069
+ while index < len(args):
1070
+ token = args[index]
1071
+ if token == "--":
1072
+ index += 1
1073
+ break
1074
+ if not token.startswith("-"):
1075
+ break
1076
+ # `-I`, `-n`, `-P`, `-s`, `-a`, `-d`, `-E`, `-L` take a value; the rest
1077
+ # do not. Over-consuming here only loses the dispatcher, which degrades
1078
+ # to the fail-open this file's header already accepts.
1079
+ index += 2 if token in {"-I", "-n", "-P", "-s", "-a", "-d", "-E", "-L"} else 1
1080
+ if index < len(args):
1081
+ built.append((args[index].rsplit("/", 1)[-1], args[index + 1 :]))
1082
+ return built
1083
+
1084
+
1085
+ def resolve_script(token):
1086
+ """Resolve a token naming a script to a readable file.
1087
+
1088
+ Args:
1089
+ token: The operand an interpreter will run.
1090
+
1091
+ Returns:
1092
+ A pair (path, reason). Exactly one is set. `reason` names why the
1093
+ execution could not be followed, which the caller fails CLOSED on:
1094
+ silence about a command that is definitely running something the guard
1095
+ cannot read is the fail-open this change exists to close.
1096
+ """
1097
+ if COMPUTED_VALUE.search(token):
1098
+ return (None, "a computed path the guard cannot resolve before the shell does")
1099
+ # A quoted separator carries a NUL marker from shell_tokens; the path the
1100
+ # shell passes is the argument without it.
1101
+ if token.startswith(chr(0)):
1102
+ token = token[1:]
1103
+ text = os.path.expanduser(token.strip().strip("'\""))
1104
+ # `bash -` and a bare `-` read the script from stdin; there is no file.
1105
+ if not text or text == "-":
1106
+ return (None, None)
1107
+ candidates = [text]
1108
+ if not os.path.isabs(text):
1109
+ project = os.environ.get("CLAUDE_PROJECT_DIR", "")
1110
+ candidates.append(os.path.join(os.getcwd(), text))
1111
+ if project:
1112
+ candidates.append(os.path.join(project, text))
1113
+ for candidate in candidates:
1114
+ try:
1115
+ if not os.path.isfile(candidate):
1116
+ continue
1117
+ if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
1118
+ return (None, "a script larger than the inspection cap")
1119
+ except OSError:
1120
+ continue
1121
+ return (candidate, None)
1122
+ return (None, "a script that does not exist or cannot be read")
1123
+
1124
+
1125
+ SHELL_COMMAND_STRING = object()
1126
+ SHELL_STDIN = object()
1127
+ # `-n` / `--noexec`: the shell READS and parses its operand, then exits without
1128
+ # executing a line of it. Distinct from the two above because nothing runs at
1129
+ # all — there is no payload to scan and no stdin script to wait for.
1130
+ SHELL_NOEXEC = object()
1131
+
1132
+
1133
+ def shell_execution_operand(args):
1134
+ """The file a shell runs, or a sentinel for `-c` and stdin modes.
1135
+
1136
+ `shell_script_operand` intentionally collapses both cases to None for the
1137
+ nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
1138
+ its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
1139
+ stdin and must remain linked to a following `< file` separator.
1140
+ """
1141
+ index = 0
1142
+ opaque_option_seen = False
1143
+ while index < len(args):
1144
+ token = args[index]
1145
+ if token == "--":
1146
+ index += 1
1147
+ break
1148
+ if not token.startswith(("-", "+")):
1149
+ if not opaque_option_seen:
1150
+ break
1151
+ index += 1
1152
+ opaque_option_seen = False
1153
+ continue
1154
+ if token in SHELL_OPTIONS_SEPARATE_VALUE:
1155
+ index += 2
1156
+ continue
1157
+ if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
1158
+ index += 1
1159
+ continue
1160
+ if token.startswith("--"):
1161
+ if token == "--noexec":
1162
+ return SHELL_NOEXEC
1163
+ if token not in SHELL_LONG_OPTIONS_NO_VALUE:
1164
+ opaque_option_seen = True
1165
+ index += 1
1166
+ continue
1167
+ if "c" in token[1:]:
1168
+ return SHELL_COMMAND_STRING
1169
+ # `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
1170
+ # a command position while provably executing nothing, so it is the
1171
+ # exact seam between reading and running — and reach got it wrong in
1172
+ # BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
1173
+ # of any file merely DISCUSSING the bypass was refused, which is most of
1174
+ # this repository's own guards and every husky hook.
1175
+ #
1176
+ # `bash <file>` still executes and is still refused. Noexec is the
1177
+ # distinction, not the program: this is not a read-only exemption.
1178
+ if "n" in token[1:]:
1179
+ return SHELL_NOEXEC
1180
+ index += 1
1181
+ return args[index] if index < len(args) else SHELL_STDIN
1182
+
1183
+
1184
+ def executed_scripts(tokens):
1185
+ """The files a command line will RUN, and the executions it cannot follow.
1186
+
1187
+ Args:
1188
+ tokens: The full operator-aware token list.
1189
+
1190
+ Returns:
1191
+ A pair (paths, reasons). `reasons` is non-empty when a command position
1192
+ clearly runs a script the guard cannot read.
1193
+ """
1194
+ paths = []
1195
+ reasons = []
1196
+ previous_shell_awaiting_stdin = False
1197
+ for separator, statement in statement_slices(tokens):
1198
+ if separator == "<" and previous_shell_awaiting_stdin and statement:
1199
+ # `bash < nv.sh`: the redirect target is the script.
1200
+ path, reason = resolve_script(statement[0])
1201
+ if reason:
1202
+ reasons.append((reason, statement[0]))
1203
+ elif path:
1204
+ paths.append((path, "stdin"))
1205
+ previous_shell_awaiting_stdin = False
1206
+ if not statement:
1207
+ continue
1208
+ program, args, opaque = command_word(statement)
1209
+ if opaque:
1210
+ reasons.append(("an invocation whose wrapper options cannot be read", ""))
1211
+ continue
1212
+ if program is None:
1213
+ continue
1214
+ for name, arguments in [(program, args)] + dispatched_commands(program, args):
1215
+ if name in SOURCE_BUILTINS:
1216
+ operand = arguments[0] if arguments else None
1217
+ elif name in SHELL_PROGRAMS:
1218
+ operand = shell_execution_operand(arguments)
1219
+ if operand is SHELL_STDIN:
1220
+ previous_shell_awaiting_stdin = True
1221
+ continue
1222
+ if operand is SHELL_COMMAND_STRING:
1223
+ continue
1224
+ if operand is SHELL_NOEXEC:
1225
+ # Nothing runs, so there is nothing to follow and nothing
1226
+ # to fail closed about.
1227
+ continue
1228
+ else:
1229
+ continue
1230
+ if operand is None:
1231
+ continue
1232
+ path, reason = resolve_script(operand)
1233
+ if reason:
1234
+ reasons.append((reason, operand))
1235
+ elif path:
1236
+ paths.append((path, "source" if name in SOURCE_BUILTINS else "execute"))
1237
+ return paths, reasons
1238
+
1239
+
1240
+ def read_script(path):
1241
+ """The text of a script the command runs, comments removed.
1242
+
1243
+ Args:
1244
+ path: A path from `resolve_script`.
1245
+
1246
+ Returns:
1247
+ The contents, or an empty string when unreadable.
1248
+ """
1249
+ try:
1250
+ with open(path, encoding="utf-8", errors="replace") as handle:
1251
+ return strip_shell_comments(strip_heredocs(handle.read(FOLLOW_MAX_BYTES)))
1252
+ except OSError:
1253
+ return ""
1254
+
1255
+
1256
+ def raw_reach_statements(text):
1257
+ """Keep quotes and complete substitutions for ordered file reach.
1258
+
1259
+ shlex intentionally removes quotes, and splits the nested quotes in the
1260
+ canonical dirname idiom. Those tokens cannot prove an assignment or tell
1261
+ a literal '$DIR' from a variable. This lexer only preserves raw words; it
1262
+ never evaluates substitutions. The existing command classifier still owns
1263
+ interpreter options, wrappers and dispatchers.
1264
+ """
1265
+ text = text.replace("\\\n", "")
1266
+
1267
+ def quoted_end(start, quote, depth=0):
1268
+ if depth > 32:
1269
+ raise ValueError("shell nesting exceeds inspection budget")
1270
+ index = start + 1
1271
+ while index < len(text):
1272
+ char = text[index]
1273
+ if char == "\\" and quote != "'":
1274
+ index += 2
1275
+ continue
1276
+ if quote == '"' and text.startswith("$(", index):
1277
+ index = substitution_end(index + 1, depth + 1)
1278
+ continue
1279
+ if char == quote:
1280
+ return index + 1
1281
+ index += 1
1282
+ raise ValueError("unclosed shell quote")
1283
+
1284
+ def substitution_end(start, depth=0):
1285
+ if depth > 32:
1286
+ raise ValueError("shell nesting exceeds inspection budget")
1287
+ index = start + 1
1288
+ nesting = 1
1289
+ while index < len(text):
1290
+ char = text[index]
1291
+ if char == "\\":
1292
+ index += 2
1293
+ continue
1294
+ if char in "'\"`":
1295
+ index = quoted_end(index, char, depth + 1)
1296
+ continue
1297
+ if char == "(":
1298
+ nesting += 1
1299
+ elif char == ")":
1300
+ nesting -= 1
1301
+ if not nesting:
1302
+ return index + 1
1303
+ index += 1
1304
+ raise ValueError("unclosed command substitution")
1305
+
1306
+ statements = []
1307
+ words = []
1308
+ separator = ""
1309
+ index = 0
1310
+ while index < len(text):
1311
+ char = text[index]
1312
+ if char in " \t\r":
1313
+ index += 1
1314
+ continue
1315
+ if char in "\n;|&()<>":
1316
+ end = index + 1
1317
+ if char in "|&<>" and end < len(text) and text[end] == char:
1318
+ end += 1
1319
+ statements.append((separator, words))
1320
+ words = []
1321
+ separator = ";" if char == "\n" else text[index:end]
1322
+ index = end
1323
+ continue
1324
+ start = index
1325
+ while index < len(text) and text[index] not in " \t\r\n;|&()<>":
1326
+ if text[index] == "\\":
1327
+ index += 2
1328
+ elif text[index] in "'\"`":
1329
+ index = quoted_end(index, text[index])
1330
+ elif text.startswith("$(", index):
1331
+ index = substitution_end(index + 1)
1332
+ else:
1333
+ index += 1
1334
+ words.append(text[start:index])
1335
+ statements.append((separator, words))
1336
+ return statements
1337
+
1338
+
1339
+ DIRECTORY_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)(?:\[[^\]]*\])?(\+?=)(.*)$", re.S)
1340
+ DIRECTORY_OPERAND = re.compile(
1341
+ r'^\$(?:\{([A-Za-z_][A-Za-z_0-9]*)\}|([A-Za-z_][A-Za-z_0-9]*))([^$`\'"\\]*)$'
1342
+ )
1343
+ DIRECTORY_MUTATORS = {
1344
+ "eval", "read", "readarray", "mapfile", "unset", "let", "declare",
1345
+ "typeset", "local", "export", "readonly", "trap", "alias", "unalias",
1346
+ "enable", "getopts",
1347
+ }
1348
+ PARENT_PARAMETER_WRITE = re.compile(
1349
+ r"\$\{[A-Za-z_][A-Za-z_0-9]*(?:\[[^\]]*\])?:?="
1350
+ )
1351
+ PARENT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[")
1352
+ CONSTANT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]")
1353
+ CONSTANT_ARRAY_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]\+?=")
1354
+ SPECIAL_DIRECTORY_VARIABLES = {
1355
+ "_",
1356
+ "RANDOM", "SRANDOM", "SECONDS", "LINENO", "EPOCHSECONDS", "EPOCHREALTIME",
1357
+ "PWD", "OLDPWD", "DIRSTACK", "FUNCNAME", "GROUPS", "PIPESTATUS",
1358
+ "UID", "EUID", "PPID", "BASHPID", "SHLVL", "OPTIND", "OPTERR",
1359
+ }
1360
+
1361
+
1362
+ def bound_directory_operand(raw, context):
1363
+ """Expand one proven binding, retaining raw quote provenance."""
1364
+ if context is None:
1365
+ return None
1366
+ quoted = raw.startswith('"') and raw.endswith('"')
1367
+ candidate = raw[1:-1] if quoted else raw
1368
+ match = DIRECTORY_OPERAND.fullmatch(candidate)
1369
+ if match:
1370
+ variable = match[1] or match[2]
1371
+ value = context["bindings"].get(variable)
1372
+ if value is not None:
1373
+ result = value + match[3]
1374
+ if (quoted or (context["ifs_default"] and not re.search(r"[\s*?\[{}]", result))) and (os.path.isabs(value) or context["cwd_known"]):
1375
+ return result
1376
+ return None
1377
+
1378
+
1379
+ def directory_assignment_value(raw, context):
1380
+ """Prove only literal directory idioms in the followed file's context."""
1381
+ if context is None:
1382
+ return None
1383
+ if context["assignment_types_unknown"]:
1384
+ return None
1385
+ derived = bound_directory_operand(raw, context)
1386
+ if derived is not None:
1387
+ return derived
1388
+ if raw == '"${BASH_SOURCE[0]%/*}"':
1389
+ pathname = context["file"]
1390
+ if pathname is None:
1391
+ return None
1392
+ return pathname.rsplit("/", 1)[0] if "/" in pathname else pathname
1393
+ if context["shadowed"]:
1394
+ return None
1395
+ # $0 belongs to the outer executed script when this file was sourced.
1396
+ source = '"${BASH_SOURCE[0]}"'
1397
+ zero = '"$0"'
1398
+ candidates = [(source, context["file"]), (zero, context["zero"])]
1399
+ for reference, pathname in candidates:
1400
+ if pathname is None or "\n" in pathname:
1401
+ continue
1402
+ directory = os.path.dirname(pathname) or "."
1403
+ if raw == '"$(dirname ' + reference + ')"':
1404
+ return directory
1405
+ if raw == '"$(cd "$(dirname ' + reference + ')" && pwd)"':
1406
+ if os.path.isabs(directory):
1407
+ return os.path.normpath(directory)
1408
+ if context["cwd_known"] and not context["cdpath"]:
1409
+ return os.path.abspath(directory)
1410
+ return None
1411
+
1412
+
1413
+ def parent_expansion_writes(raw):
1414
+ """Detect possible parent-shell writes without reading quoted data as code."""
1415
+ index = 0
1416
+ double_quoted = False
1417
+ while index < len(raw):
1418
+ if raw[index] == "\\":
1419
+ index += 2
1420
+ continue
1421
+ if raw[index] == '"':
1422
+ double_quoted = not double_quoted
1423
+ index += 1
1424
+ continue
1425
+ if raw[index] == "'" and not double_quoted:
1426
+ end = raw.find("'", index + 1)
1427
+ if end < 0:
1428
+ return True
1429
+ index = end + 1
1430
+ continue
1431
+ if raw.startswith("$((", index) or raw.startswith("$[", index):
1432
+ return True
1433
+ if raw.startswith("${", index):
1434
+ if PARENT_PARAMETER_WRITE.match(raw, index):
1435
+ return True
1436
+ if PARENT_ARRAY_READ.match(raw, index) and not CONSTANT_ARRAY_READ.match(raw, index):
1437
+ return True
1438
+ index += 1
1439
+ return False
1440
+
1441
+
1442
+ def directory_variable_write(context, name):
1443
+ """Invalidate shell facts as well as bindings when a variable can change."""
1444
+ if context is None:
1445
+ return
1446
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*)(?:\[[0-9]+\])?(?:=.*)?", name, re.S)
1447
+ variable = match[1] if match else None
1448
+ if variable is not None:
1449
+ context["bindings"].pop(variable, None)
1450
+ if variable is None or variable == "PATH":
1451
+ context["shadowed"] = True
1452
+ if variable is None or variable == "CDPATH":
1453
+ context["cdpath"] = True
1454
+ if variable is None or variable == "IFS":
1455
+ context["ifs_default"] = False
1456
+ if variable is None or variable in {"BASH_ENV", "ENV"}:
1457
+ context["startup_unknown"] = True
1458
+ if variable is None or variable == "BASH_SOURCE":
1459
+ context["file"] = None
1460
+ if variable is None or variable == "BASH_ARGV0":
1461
+ context["zero"] = None
1462
+ if variable is None:
1463
+ context["assignment_types_unknown"] = True
1464
+
1465
+
1466
+ def directory_command(words):
1467
+ """Strip command-position control prefixes while counting their scopes."""
1468
+ index = 0
1469
+ openings = 0
1470
+ braces = 0
1471
+ while index < len(words) and words[index] in {"if", "elif", "while", "until", "then", "else", "do", "{", "!"}:
1472
+ if words[index] in {"if", "while", "until"}:
1473
+ openings += 1
1474
+ if words[index] == "{":
1475
+ braces += 1
1476
+ index += 1
1477
+ return words[index:], openings, braces
1478
+
1479
+
1480
+ def directory_reach(text, context, inspect, inspect_payload):
1481
+ """Follow commands in order, retaining only proven parent-shell facts.
1482
+
1483
+ Sourcing shares bindings; a new interpreter has its own bindings and $0.
1484
+ Assignments inside control flow, functions or subshells never authorize a
1485
+ later source. Possible writes invalidate facts, including writes in a
1486
+ library inspected immediately before its caller continues.
1487
+ """
1488
+ try:
1489
+ statements = raw_reach_statements(text)
1490
+ except ValueError:
1491
+ paths, reasons = executed_scripts(shell_tokens(text))
1492
+ if context is not None:
1493
+ context["bindings"].clear()
1494
+ return paths, reasons
1495
+ bindings = context["bindings"] if context is not None else {}
1496
+ definition_headers = set()
1497
+ definition_bodies = {}
1498
+ if context is not None:
1499
+ discovered_functions = set()
1500
+ for index, (_, raw_header) in enumerate(statements):
1501
+ words, _, _ = directory_command(raw_header)
1502
+ header_offset = len(raw_header) - len(words)
1503
+ name = None
1504
+ if len(words) >= 2 and words[0] == "function":
1505
+ name = words[1]
1506
+ elif len(words) == 1 and index + 2 < len(statements):
1507
+ closing, body = statements[index + 2]
1508
+ if statements[index + 1] == ("(", []) and closing == ")" and body and body[0] == "{":
1509
+ name = words[0]
1510
+ if name is not None:
1511
+ try:
1512
+ decoded_name = shlex.split(name, posix=True)
1513
+ except ValueError:
1514
+ decoded_name = []
1515
+ if len(decoded_name) == 1 and not COMPUTED_VALUE.search(decoded_name[0]):
1516
+ name = decoded_name[0]
1517
+ else:
1518
+ context["shadowed"] = True
1519
+ context["assignment_types_unknown"] = True
1520
+ definition_headers.add(index)
1521
+ if "{" in words:
1522
+ definition_bodies[index] = header_offset + words.index("{")
1523
+ elif index + 2 < len(statements) and "{" in statements[index + 2][1]:
1524
+ definition_bodies[index + 2] = statements[index + 2][1].index("{")
1525
+ discovered_functions.add(name)
1526
+ if name in {"dirname", "cd", "pwd"}:
1527
+ context["shadowed"] = True
1528
+ context["functions"] = context["functions"] | discovered_functions
1529
+ control_scope = 0
1530
+ paren_scope = 0
1531
+ brace_scope = 0
1532
+ function_frames = []
1533
+ previous_stdin = False
1534
+ for statement_index, (separator, raw_words) in enumerate(statements):
1535
+ if separator == "(" and statement_index and statements[statement_index - 1] == ("(", []):
1536
+ # Arithmetic evaluates variable contents as expressions; even a
1537
+ # seemingly unrelated counter can conceal a write through them.
1538
+ bindings.clear()
1539
+ if context is not None:
1540
+ context["cdpath"] = True
1541
+ context["shadowed"] = True
1542
+ if separator == "(":
1543
+ paren_scope += 1
1544
+ elif separator == ")":
1545
+ paren_scope = max(0, paren_scope - 1)
1546
+ if not raw_words:
1547
+ continue
1548
+ head = raw_words[0]
1549
+ enclosing_control = control_scope
1550
+ enclosing_parens = paren_scope
1551
+ normalized, openings, brace_openings = directory_command(raw_words)
1552
+ control_scope += openings
1553
+ if normalized and normalized[0] in {"for", "select", "case"}:
1554
+ control_scope += 1
1555
+ elif head in {"fi", "done", "esac"}:
1556
+ control_scope = max(0, control_scope - 1)
1557
+ brace_scope += brace_openings
1558
+ if normalized and normalized[0] == "function" and "{" in normalized:
1559
+ brace_scope += 1
1560
+ if head == "}":
1561
+ brace_scope = max(0, brace_scope - 1)
1562
+ if function_frames and brace_scope < function_frames[-1][0]:
1563
+ _, context, control_scope, paren_scope = function_frames.pop()
1564
+ bindings = context["bindings"] if context is not None else {}
1565
+ raw_words = raw_words[1:]
1566
+ if not raw_words:
1567
+ continue
1568
+ head = raw_words[0]
1569
+ if statement_index in definition_bodies and context is not None:
1570
+ if len(function_frames) >= FOLLOW_MAX_DEPTH:
1571
+ return [], [("an unproved function body beyond the inspection cap", head)]
1572
+ function_frames.append((brace_scope, context, enclosing_control, enclosing_parens))
1573
+ # A definition does not execute its body. Inspect potential file
1574
+ # executions without borrowing directory facts from the defining
1575
+ # moment: the body may be called after the caller changes them.
1576
+ # Its speculative writes must not erase the caller's facts either.
1577
+ context = {
1578
+ **context, "bindings": {},
1579
+ "cwd_known": False, "assignment_types_unknown": True,
1580
+ "startup_unknown": True,
1581
+ }
1582
+ bindings = context["bindings"]
1583
+ raw_words = raw_words[definition_bodies[statement_index] + 1:]
1584
+ if not raw_words:
1585
+ continue
1586
+ head = raw_words[0]
1587
+ scope = control_scope + paren_scope + brace_scope
1588
+ outgoing = statements[statement_index + 1][0] if statement_index + 1 < len(statements) else ""
1589
+ parent_context = context
1590
+ isolated = separator == "|" or outgoing in {"|", "&"}
1591
+ if context is not None and isolated:
1592
+ context = {**context, "bindings": bindings.copy()}
1593
+ bindings = context["bindings"]
1594
+ raw_command, _, _ = directory_command(raw_words)
1595
+ if raw_command and raw_command[0] in {"for", "select"} and len(raw_command) > 1:
1596
+ directory_variable_write(context, raw_command[1])
1597
+ if any(parent_expansion_writes(raw) for raw in raw_words):
1598
+ bindings.clear()
1599
+ if context is not None:
1600
+ context["shadowed"] = True
1601
+ context["cdpath"] = True
1602
+ context["ifs_default"] = False
1603
+ # Function definitions may shadow the commands used by dirname. Do
1604
+ # not treat a definition as an executed directory assignment.
1605
+ for raw_assignment in raw_command:
1606
+ assignment = DIRECTORY_ASSIGNMENT.fullmatch(raw_assignment)
1607
+ if assignment is None:
1608
+ break
1609
+ variable, operator, value = assignment.groups()
1610
+ proven = directory_assignment_value(value, context)
1611
+ # Bash's dynamic, readonly and array variables do not have the
1612
+ # ordinary scalar assignment/expansion semantics this proof uses.
1613
+ if variable in SPECIAL_DIRECTORY_VARIABLES or variable.startswith("BASH_"):
1614
+ proven = None
1615
+ if context is not None and variable == "BASH_SOURCE":
1616
+ context["file"] = None
1617
+ if context is not None and variable == "BASH_ARGV0":
1618
+ context["zero"] = None
1619
+ if raw_assignment.startswith(variable + "[") and not CONSTANT_ARRAY_ASSIGNMENT.match(raw_assignment):
1620
+ bindings.clear()
1621
+ if context is not None:
1622
+ context["shadowed"] = True
1623
+ context["ifs_default"] = False
1624
+ context["cdpath"] = True
1625
+ bindings.pop(variable, None)
1626
+ if context is not None and variable == "CDPATH":
1627
+ context["cdpath"] = True
1628
+ if context is not None and variable == "PATH":
1629
+ context["shadowed"] = True
1630
+ if context is not None and variable == "IFS":
1631
+ context["ifs_default"] = False
1632
+ if context is not None and variable in {"BASH_ENV", "ENV"}:
1633
+ context["startup_unknown"] = value not in {"", "''", '\"\"'}
1634
+ if len(raw_words) == 1 and not scope and not isolated and separator not in {"&&", "||", "|", "&"} and operator == "=" and head.startswith(variable + "="):
1635
+ if proven is not None:
1636
+ bindings[variable] = proven
1637
+ words = []
1638
+ for raw in raw_words:
1639
+ try:
1640
+ decoded = shlex.split(raw, posix=True)
1641
+ word = decoded[0] if len(decoded) == 1 else raw
1642
+ except ValueError:
1643
+ word = raw
1644
+ # Expansion is confined to one proven variable plus a literal
1645
+ # suffix. Single quotes and escaped dollars never get substituted.
1646
+ expanded = bound_directory_operand(raw, context)
1647
+ if expanded is not None:
1648
+ word = expanded
1649
+ if word in COMMAND_SEPARATORS:
1650
+ word = "\x00" + word
1651
+ words.append(word)
1652
+ # Prefix identity comes from raw words, before quote removal. A quoted
1653
+ # exclamation mark is an executable name, not the negation operator.
1654
+ command_words = words[len(raw_words) - len(raw_command):]
1655
+ program, args, _ = command_word(command_words)
1656
+ eval_context = {**context, "bindings": bindings.copy()} if context is not None and program == "eval" else context
1657
+ actual_head = next((word for word in command_words if not DIRECTORY_ASSIGNMENT.fullmatch(word)), None)
1658
+ if context is not None:
1659
+ called_function = (program in context["functions"] or actual_head in context["functions"]) and statement_index not in definition_headers
1660
+ arithmetic_test = program in {"test", "[", "[["} and any(
1661
+ arg in {"-v", "-R"} and index + 1 < len(args)
1662
+ and "[" in args[index + 1]
1663
+ and not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]", args[index + 1])
1664
+ for index, arg in enumerate(args)
1665
+ )
1666
+ if program == "[[":
1667
+ # [[ arithmetic comparisons evaluate variable contents as
1668
+ # expressions. Literal numbers and numeric special parameters
1669
+ # cannot conceal writes; test/[ instead parse integer strings.
1670
+ for index, arg in enumerate(args):
1671
+ if arg in {"-eq", "-ne", "-lt", "-le", "-gt", "-ge"}:
1672
+ operands = args[max(0, index - 1):index] + args[index + 1:index + 2]
1673
+ if len(operands) != 2 or any(not re.fullmatch(r"[+-]?[0-9]+|\$[?#$]", value) for value in operands):
1674
+ arithmetic_test = True
1675
+ if program in DIRECTORY_MUTATORS or called_function or arithmetic_test or (program == "printf" and "-v" in args) or (separator == "(" and head.startswith("(")):
1676
+ bindings.clear()
1677
+ context["cdpath"] = True
1678
+ context["ifs_default"] = False
1679
+ if program == "eval" or called_function or arithmetic_test:
1680
+ context["cwd_known"] = False
1681
+ context["shadowed"] = True
1682
+ context["assignment_types_unknown"] = True
1683
+ if program == "eval" or called_function:
1684
+ context["startup_unknown"] = True
1685
+ if program == "printf" and "-v" in args:
1686
+ target_index = args.index("-v") + 1
1687
+ directory_variable_write(context, args[target_index] if target_index < len(args) else "")
1688
+ elif program == "getopts":
1689
+ directory_variable_write(context, args[1] if len(args) > 1 else "")
1690
+ elif program in {"read", "readarray", "mapfile", "unset", "declare", "typeset", "local", "export", "readonly"}:
1691
+ for arg in args:
1692
+ if not arg.startswith("-"):
1693
+ directory_variable_write(context, arg)
1694
+ elif program == "let":
1695
+ directory_variable_write(context, "")
1696
+ invocation_context = context
1697
+ if context is not None and program in SHELL_PROGRAMS:
1698
+ operand = shell_execution_operand(args)
1699
+ if isinstance(operand, str) or operand is SHELL_COMMAND_STRING:
1700
+ prefix = command_words[:len(command_words) - len(args)]
1701
+ invocation_context = context.copy()
1702
+ if any(word.startswith("CDPATH=") for word in prefix):
1703
+ invocation_context["cdpath"] = True
1704
+ if any(word.startswith("PATH=") for word in prefix):
1705
+ invocation_context["shadowed"] = True
1706
+ if any(word.startswith(("BASH_ENV=", "ENV=")) and word.split("=", 1)[1] for word in prefix):
1707
+ invocation_context["startup_unknown"] = True
1708
+ startup_args = args[:args.index(operand)] if isinstance(operand, str) else args
1709
+ if shell_startup_unknown(startup_args):
1710
+ invocation_context["startup_unknown"] = True
1711
+ if context is not None:
1712
+ if program in {"alias", "unalias", "enable"}:
1713
+ context["shadowed"] = True
1714
+ if program in {"declare", "typeset", "local"} and any(arg.startswith("-") and any(flag in arg[1:] for flag in "nirlu") for arg in args):
1715
+ context["assignment_types_unknown"] = True
1716
+ trap_args = args[1:] if args and args[0] == "--" else args
1717
+ exit_only_trap = len(trap_args) >= 2 and all(signal in {"EXIT", "0"} for signal in trap_args[1:])
1718
+ if program == "readonly" or (program == "trap" and not exit_only_trap):
1719
+ context["assignment_types_unknown"] = True
1720
+ if program in {"cd", "pushd", "popd"}:
1721
+ context["cwd_known"] = False
1722
+ for variable, value in list(bindings.items()):
1723
+ if not os.path.isabs(value):
1724
+ del bindings[variable]
1725
+ # A conditional assignment is still a possible overwrite. Ignore
1726
+ # assignment-looking data belonging to an ordinary command.
1727
+ if head in {"then", "else", "do"}:
1728
+ for raw in raw_words[1:]:
1729
+ mutation = DIRECTORY_ASSIGNMENT.fullmatch(raw)
1730
+ if mutation:
1731
+ bindings.pop(mutation[1], None)
1732
+ tokens = (["bash", "<"] if separator == "<" and previous_stdin else []) + command_words
1733
+ paths, reasons = executed_scripts(tokens)
1734
+ if reasons:
1735
+ return [], reasons
1736
+ if program in SHELL_PROGRAMS:
1737
+ payload = nested_shell_payload([program, *args], 0)
1738
+ if isinstance(payload, str) and inspect_payload(payload, "execute", invocation_context):
1739
+ return [], [("a nested shell command bypasses git's verification hooks", program)]
1740
+ elif program == "eval":
1741
+ for payload in eval_payloads(command_words):
1742
+ if inspect_payload(payload, "source", eval_context):
1743
+ return [], [("an evaluated command bypasses git's verification hooks", program)]
1744
+ previous_stdin = program in SHELL_PROGRAMS and shell_execution_operand(args) is SHELL_STDIN
1745
+ for path, mode in paths:
1746
+ if context is not None and not context["cwd_known"] and not os.path.isabs(path):
1747
+ return [], [("a computed path after an unproved working-directory change", path)]
1748
+ before = bindings.copy()
1749
+ if inspect(path, mode, invocation_context):
1750
+ return [], [("a followed script bypasses git's verification hooks", path)]
1751
+ if mode == "source" and (scope or separator in {"&&", "||", "|", "&"}):
1752
+ for variable, value in list(bindings.items()):
1753
+ if variable not in before or before[variable] != value:
1754
+ del bindings[variable]
1755
+ if isolated:
1756
+ if separator == "|" and parent_context is not None:
1757
+ # The last pipeline command can run in the parent (`lastpipe`
1758
+ # or another shell). Retain only facts unchanged in both
1759
+ # execution models; never promote a child-only assignment.
1760
+ for variable, value in list(parent_context["bindings"].items()):
1761
+ if bindings.get(variable) != value:
1762
+ del parent_context["bindings"][variable]
1763
+ parent_context["cwd_known"] &= context["cwd_known"]
1764
+ parent_context["ifs_default"] &= context["ifs_default"]
1765
+ for fact in ("cdpath", "shadowed", "assignment_types_unknown", "startup_unknown"):
1766
+ parent_context[fact] |= context[fact]
1767
+ if parent_context["functions"] is not context["functions"]:
1768
+ parent_context["functions"] |= context["functions"]
1769
+ context = parent_context
1770
+ bindings = context["bindings"] if context is not None else {}
1771
+ return [], []
1772
+
1773
+
1774
+ def git_argv_disables_verification(argv):
1775
+ """Whether a git invocation's argv carries `--no-verify` or an abbreviation.
1776
+
1777
+ SCOPED to a git invocation, which is the change that made file reach usable.
1778
+ The token match used to fire anywhere on the line, so `echo --no-verify` and
1779
+ `grep -rl -- "--no-verify" scripts/` were both refused — ordinary commands
1780
+ that run no git at all. That was survivable while the guard read only argv.
1781
+ It is not once the guard reads FILE CONTENTS, because this file, every husky
1782
+ hook, and much of this repository carry the literal token in prose and in
1783
+ refusal text; an unscoped matcher plus reach refuses the repository's own
1784
+ operations on the first command.
1785
+
1786
+ Deliberately NOT narrowed to `commit`. `git push --no-verify` skips pre-push
1787
+ and `git am` / `git merge` skip their own hooks, so every subcommand's argv
1788
+ is scanned. Only the SHORT `-n` needs the commit scope, because `-n` is
1789
+ --dry-run to push and --no-stat to merge — see
1790
+ `commit_bypasses_verification`.
1791
+
1792
+ Args:
1793
+ argv: Tokens following the `git` token, to the end of the line.
1794
+
1795
+ Returns:
1796
+ True if git would read one of them as --no-verify.
1797
+ """
1798
+ for token in argv:
1799
+ # `--` ends the options; a pathspec literally named `--no-verify` is a
1800
+ # file, not a bypass.
1801
+ if token in COMMAND_SEPARATORS or token == "--":
1802
+ return False
1803
+ if disables_verification(token):
1804
+ return True
1805
+ return False
1806
+
1807
+
1808
+ def git_skips_verification(text, depth=0):
1809
+ """Whether the command runs a git invocation that skips verification.
1810
+
1811
+ Args:
1812
+ text: The command line, heredoc payloads already stripped.
1813
+ depth: Number of recognized shell payloads already traversed.
1814
+
1815
+ Returns:
1816
+ True if any `git commit` invocation on the line skips verification.
1817
+ """
1818
+ try:
1819
+ scoped_tokens = shell_tokens(text)
1820
+ except ValueError:
1821
+ return False
1822
+ for index, token in enumerate(scoped_tokens):
1823
+ if token.rsplit("/", 1)[-1] == "env" and env_uses_split_string(
1824
+ scoped_tokens, index
1825
+ ):
1826
+ return True
1827
+ for index, token in enumerate(scoped_tokens):
1828
+ # `/usr/bin/git` and a bare `git` are the same program; an env prefix
1829
+ # (`HUSKY=1 git commit -n`) simply sits in an earlier token.
1830
+ if token != "git" and not token.endswith("/git"):
1831
+ continue
1832
+ if printed_argument(scoped_tokens, index):
1833
+ continue
1834
+ # The LONG flag, scoped to this invocation's argv rather than matched
1835
+ # anywhere on the line. This replaces the unscoped token match that used
1836
+ # to live in the flat loop below.
1837
+ if git_argv_disables_verification(scoped_tokens[index + 1 :]):
1838
+ return True
1839
+ found = subcommand_after_git(scoped_tokens, index + 1)
1840
+ if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
1841
+ return True
1842
+ if depth >= MAX_SHELL_NESTING:
1843
+ return True
1844
+ for index, token in enumerate(scoped_tokens):
1845
+ program = token.rsplit("/", 1)[-1]
1846
+ if program not in SHELL_PROGRAMS or not shell_starts_command(
1847
+ scoped_tokens, index
1848
+ ):
1849
+ continue
1850
+ payload = nested_shell_payload(scoped_tokens, index)
1851
+ if payload is True:
1852
+ return True
1853
+ if isinstance(payload, str) and git_skips_verification(payload, depth + 1):
1854
+ return True
1855
+ return False
1856
+
1857
+
1858
+ def git_config_reads_key(tokens, index):
1859
+ """Whether this key is a read operand of this exact Git config invocation.
1860
+
1861
+ Keep the command boundary: a query cannot authorize the next command or
1862
+ turn a later setting into a read. Unknown options remain conservative.
1863
+ """
1864
+ start = index
1865
+ while start > 0 and tokens[start - 1] not in COMMAND_SEPARATORS:
1866
+ start -= 1
1867
+ end = index + 1
1868
+ while end < len(tokens) and tokens[end] not in COMMAND_SEPARATORS:
1869
+ end += 1
1870
+ program, args, opaque = command_word(tokens[start:end])
1871
+ if opaque or program != "git":
1872
+ return False
1873
+ found = subcommand_after_git(args, 0)
1874
+ if not found or found[0] != "config":
1875
+ return False
1876
+ argv = found[1]
1877
+ key_offset = index - (end - len(argv))
1878
+ read = bool(argv and argv[0] == "get")
1879
+ cursor = 1 if read else 0
1880
+ operands = []
1881
+ read_actions = {"--get", "--get-all", "--get-regexp", "--get-urlmatch"}
1882
+ modifiers = {
1883
+ "--local", "--global", "--system", "--worktree", "--includes",
1884
+ "--no-includes", "--show-origin", "--show-scope", "--null", "-z",
1885
+ "--name-only", "--all", "--regexp",
1886
+ }
1887
+ value_options = {"--file", "-f", "--blob", "--type", "--default", "--value"}
1888
+ while cursor < len(argv):
1889
+ token = argv[cursor]
1890
+ if token in read_actions:
1891
+ if read:
1892
+ return False
1893
+ read = True
1894
+ elif token in value_options:
1895
+ if cursor + 1 >= len(argv):
1896
+ return False
1897
+ cursor += 1
1898
+ elif token in modifiers:
1899
+ pass
1900
+ elif token.startswith("-"):
1901
+ if not any(token.startswith(option + "=") for option in value_options):
1902
+ return False
1903
+ else:
1904
+ operands.append(cursor)
1905
+ cursor += 1
1906
+ return bool(operands and operands[0] == key_offset and (read or len(operands) == 1))
1907
+
1908
+
1909
+ def token_bypass(tokens):
1910
+ """Whether any token disables hooks by environment or by git config.
1911
+
1912
+ Printed echo/printf arguments are filtered before this scan. Assignments
1913
+ otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
1914
+ where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
1915
+ configure whatever git runs next, so they legitimately sit before the
1916
+ invocation and often on a different line of a script. `--no-verify` is an
1917
+ option, and an option belongs to an argv.
1918
+
1919
+ Args:
1920
+ tokens: Operator-aware tokens of one command or script.
1921
+
1922
+ Returns:
1923
+ True when a token disables verification.
1924
+ """
1925
+ for i, token in enumerate(tokens):
1926
+ if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
1927
+ return True
1928
+ # Allowlist the destinations, do not denylist the disabling ones.
1929
+ #
1930
+ # This used to block only "" and /dev/null. But hooks are disabled just
1931
+ # as completely by pointing hooksPath at any directory that happens to
1932
+ # contain none — `-c core.hooksPath=/tmp/empty` bypassed every hook while
1933
+ # reading as an ordinary path — so a denylist of "obviously disabling"
1934
+ # values can always be stepped around by naming a third thing. The set of
1935
+ # paths that DISABLE hooks is unbounded; the set that legitimately
1936
+ # relocates them is tiny and known, so the allowlist is the only side
1937
+ # that can be enumerated.
1938
+ #
1939
+ # Matched case-insensitively because git config variable names are:
1940
+ # `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to
1941
+ # git, so a case-sensitive check is bypassed by holding down shift.
1942
+ lowered = token.lower()
1943
+ if lowered.startswith("core.hookspath="):
1944
+ if not is_permitted_hooks_path(token.split("=", 1)[1]):
1945
+ return True
1946
+ if (lowered == "core.hookspath" and i + 1 < len(tokens)
1947
+ and tokens[i + 1] not in COMMAND_SEPARATORS
1948
+ and not git_config_reads_key(tokens, i)):
1949
+ if not is_permitted_hooks_path(tokens[i + 1]):
1950
+ return True
1951
+ # `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
1952
+ # the value out of the named environment variable. The path therefore is
1953
+ # not in the command at all, so there is nothing to allowlist against —
1954
+ # SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
1955
+ # routed through --config-env is refused outright.
1956
+ if lowered.startswith("--config-env="):
1957
+ spec = token.split("=", 1)[1]
1958
+ if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
1959
+ return True
1960
+ # git accepts `--config-env <name>=<envvar>` as TWO tokens as well as
1961
+ # one, and guarding only the `=` spelling was worse than missing the
1962
+ # separate form outright: the trailing `core.hooksPath=.husky` then fell
1963
+ # through to the allowlist above, which reads `.husky` as a PATH and
1964
+ # permits it. But here it is an ENVIRONMENT VARIABLE NAME, and
1965
+ # `env '.husky=/dev/null' git --config-env core.hooksPath=.husky` really
1966
+ # does resolve hooksPath to /dev/null. The allowlist was being used as
1967
+ # the bypass.
1968
+ #
1969
+ # Checked at the `--config-env` token, which the loop reaches first, so
1970
+ # the refusal happens before the value token can be mistaken for a path.
1971
+ if lowered == "--config-env" and i + 1 < len(tokens):
1972
+ spec = tokens[i + 1]
1973
+ if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
1974
+ return True
1975
+ # `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
1976
+ # GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
1977
+ # same way `-c core.hooksPath=...` does — env-var-style assignments ahead
1978
+ # of the invocation instead of a flag — so it disables every hook just as
1979
+ # completely while matching none of the token shapes above. Upstream
1980
+ # missed this until a downstream fork hardened its own copy against it,
1981
+ # which is the one direction a guard must never be caught in.
1982
+ #
1983
+ # The index is matched as `\d+` rather than pinned to 0: git accepts any
1984
+ # index below GIT_CONFIG_COUNT, so a single-index check is evaded by
1985
+ # typing a 1. Refused outright, like --config-env=, because the path
1986
+ # lives in a separate GIT_CONFIG_VALUE_<n> token that can be exported
1987
+ # earlier, reordered, or left out entirely — there is nothing here to
1988
+ # allowlist against.
1989
+ key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
1990
+ if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
1991
+ return True
1992
+ # `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
1993
+ # Parsing the value as Git's shell-quoted parameter list distinguishes
1994
+ # the key from an unrelated value that merely contains the same text.
1995
+ if re.match(r"git_config_parameters\+?=", lowered):
1996
+ parameters = token.split("=", 1)[1]
1997
+ # The guard sees the command before the shell expands assignments. A
1998
+ # variable or command substitution can therefore hide hooksPath from
1999
+ # this parser and reveal it only to Git. Refuse unresolved values;
2000
+ # the guard cannot safely prove what configuration they will become.
2001
+ if "$" in parameters or "`" in parameters:
2002
+ return True
2003
+ try:
2004
+ configured = shlex.split(parameters, posix=True)
2005
+ except ValueError:
2006
+ configured = []
2007
+ if any(
2008
+ parameter.split("=", 1)[0].strip().lower() == "core.hookspath"
2009
+ for parameter in configured
2010
+ ):
2011
+ return True
2012
+ return False
2013
+
2014
+
2015
+ class OperatorStream(io.StringIO):
2016
+ """Remember punctuation read while shlex is inside a quote or escape."""
2017
+
2018
+ lexer = None
2019
+ literal_operator = False
2020
+
2021
+ def read(self, size=-1):
2022
+ value = super().read(size)
2023
+ if (self.lexer is not None and self.lexer.state in {"'", '"', "\\"}
2024
+ and any(char in "();|&<>" for char in value)):
2025
+ self.literal_operator = True
2026
+ return value
2027
+
2028
+
2029
+ def flat_tokens(text):
2030
+ """Operator-aware tokens without printed data, or None on a lex failure.
2031
+
2032
+ Args:
2033
+ text: A command line or a script's contents.
2034
+
2035
+ Returns:
2036
+ The token list, or None.
2037
+ """
2038
+ try:
2039
+ stream = OperatorStream(line_boundaries_as_separators(text))
2040
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
2041
+ lexer.whitespace_split = True
2042
+ lexer.commenters = ""
2043
+ stream.lexer = lexer
2044
+ tokens = []
2045
+ while True:
2046
+ stream.literal_operator = False
2047
+ token = lexer.get_token()
2048
+ if token is None:
2049
+ break
2050
+ # shlex otherwise makes quoted ";" indistinguishable from a real
2051
+ # command boundary. Keep literal destinations as data, so a setter
2052
+ # cannot masquerade as a one-operand configuration read.
2053
+ if stream.literal_operator:
2054
+ if token in COMMAND_SEPARATORS:
2055
+ token = "\x00" + token
2056
+ tokens.append(token)
2057
+ else:
2058
+ tokens.extend(split_operators(token))
2059
+ return [
2060
+ token
2061
+ for index, token in enumerate(tokens)
2062
+ if not printed_argument(tokens, index)
2063
+ ]
2064
+ except ValueError:
2065
+ return None
2066
+
2067
+
2068
+ def eval_payloads(scoped_tokens):
2069
+ """Command strings an `eval` in command position would execute.
2070
+
2071
+ `eval` takes no options: it concatenates its arguments and runs the result,
2072
+ so the payload is every token up to the next separator, joined by a space.
2073
+ Quoting is already gone by the time `shell_tokens` hands them over, which is
2074
+ exactly why the payload is invisible to every other scan here — the argv
2075
+ scan sees one opaque token and the git scan sees no `git` token at all.
2076
+
2077
+ Args:
2078
+ scoped_tokens: Tokens of one command line.
2079
+
2080
+ Returns:
2081
+ A list of payload strings, one per `eval` found in command position.
2082
+ """
2083
+ payloads = []
2084
+ for index, token in enumerate(scoped_tokens):
2085
+ if token.rsplit("/", 1)[-1] != "eval":
2086
+ continue
2087
+ if not shell_starts_command(scoped_tokens, index):
2088
+ continue
2089
+ collected = []
2090
+ for follow in scoped_tokens[index + 1 :]:
2091
+ if follow in COMMAND_SEPARATORS:
2092
+ break
2093
+ collected.append(follow)
2094
+ if collected:
2095
+ payloads.append(" ".join(collected))
2096
+ return payloads
2097
+
2098
+
2099
+ def shell_startup_unknown(args):
2100
+ """Whether invocation options permit uninspected interactive/login startup."""
2101
+ options = []
2102
+ index = 0
2103
+ while index < len(args):
2104
+ option = args[index]
2105
+ if option == "--" or not option.startswith(("-", "+")):
2106
+ break
2107
+ options.append(option)
2108
+ if option.startswith("-") and not option.startswith("--") and "c" in option[1:]:
2109
+ break
2110
+ index += 2 if option in SHELL_OPTIONS_SEPARATE_VALUE else 1
2111
+ interactive = any(option == "--interactive" or (option.startswith("-") and not option.startswith("--") and "i" in option[1:]) for option in options)
2112
+ login = any(option == "--login" or (option.startswith("-") and not option.startswith("--") and "l" in option[1:]) for option in options)
2113
+ return (interactive and "--norc" not in options) or (login and "--noprofile" not in options)
2114
+
2115
+
2116
+ def initial_directory_context(startup_unknown=False):
2117
+ startup_unknown |= bool(os.environ.get("BASH_ENV") or os.environ.get("ENV"))
2118
+ return {
2119
+ "file": None, "zero": None, "bindings": {}, "cwd_known": True,
2120
+ "cdpath": bool(os.environ.get("CDPATH")), "shadowed": False,
2121
+ "functions": frozenset(), "ifs_default": True,
2122
+ "assignment_types_unknown": startup_unknown,
2123
+ "startup_unknown": startup_unknown,
2124
+ }
2125
+
2126
+
2127
+ def verdict(text, depth=0, followed=None, context=None, follow_scripts=True):
2128
+ """Whether this text, or a script it runs, bypasses verification.
2129
+
2130
+ Args:
2131
+ text: A command line or a followed script's contents.
2132
+ depth: Number of scripts already followed.
2133
+ followed: Shared inspection count, including repeats and nested payloads.
2134
+
2135
+ Returns:
2136
+ True when the command must be refused.
2137
+ """
2138
+ if followed is None:
2139
+ followed = []
2140
+ if git_skips_verification(text):
2141
+ return True
2142
+ # RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
2143
+ # a command string, and it was absent from both recursion sets: it is not a
2144
+ # SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
2145
+ # whose argv carries through. So every vector this guard blocks was restored
2146
+ # by writing it inside `eval "..."` — measured on origin/main, nine of them,
2147
+ # including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
2148
+ # separator and one behind a `command` wrapper.
2149
+ #
2150
+ # Enumerating spelling twelve is how spelling thirteen appears, and this
2151
+ # repository has twice declined an arms race of that shape. Recursing through
2152
+ # `verdict` instead gives the payload the IDENTICAL analysis, so anything
2153
+ # covered outside is covered inside — including checks the reporter's own
2154
+ # suggested patch would have missed, because it recursed into the git half
2155
+ # only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
2156
+ #
2157
+ # Bounded by the same budget as a followed script: unbounded recursion on
2158
+ # adversarial input is itself a denial of the guard, and a Python traceback
2159
+ # exits non-zero, which the wrapper turns into a spurious block.
2160
+ if depth < FOLLOW_MAX_DEPTH:
2161
+ try:
2162
+ eval_scoped = shell_tokens(text)
2163
+ except ValueError:
2164
+ eval_scoped = []
2165
+ for payload in eval_payloads(eval_scoped):
2166
+ if verdict(payload, depth + 1, followed, follow_scripts=False):
2167
+ return True
2168
+ # AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
2169
+ # into a `bash -c` payload, but only into ITSELF — so it carries the git
2170
+ # half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
2171
+ # `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
2172
+ # exactly that reason, and it was found by running the adversarial round
2173
+ # rather than by reasoning about it. Recursing the payload through
2174
+ # `verdict` gives every level the whole analysis.
2175
+ for index, token in enumerate(eval_scoped):
2176
+ if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
2177
+ continue
2178
+ if not shell_starts_command(eval_scoped, index):
2179
+ continue
2180
+ nested = nested_shell_payload(eval_scoped, index)
2181
+ if isinstance(nested, str) and verdict(nested, depth + 1, followed, follow_scripts=False):
2182
+ return True
2183
+
2184
+ tokens = flat_tokens(text)
2185
+ if tokens is not None and token_bypass(tokens):
2186
+ return True
2187
+ if not follow_scripts:
2188
+ return False
2189
+ try:
2190
+ scoped_tokens = shell_tokens(text)
2191
+ except ValueError:
2192
+ # Text this parser cannot lex is left to the checks above, which is the
2193
+ # same accepted fail-open the header records for an unlexable command.
2194
+ return False
2195
+
2196
+ def inspect(path, mode, parent_context):
2197
+ key = os.path.realpath(path)
2198
+ sourced = mode == "source"
2199
+ if len(followed) >= FOLLOW_MAX_FILES or depth >= FOLLOW_MAX_DEPTH:
2200
+ print(
2201
+ "block-no-verify: refusing at the script-following cap rather "
2202
+ f"than skipping past it ({path})", file=sys.stderr,
2203
+ )
2204
+ return True
2205
+ # Count inspections rather than unique names: a repeated source must
2206
+ # be read under its current bindings, and still consumes the budget.
2207
+ followed.append(key)
2208
+ inherited = sourced and parent_context is not None
2209
+ child = {
2210
+ "file": None if mode == "stdin" else path,
2211
+ "zero": parent_context["zero"] if inherited else (path if mode == "execute" else None),
2212
+ "bindings": parent_context["bindings"] if inherited else {},
2213
+ "cwd_known": parent_context["cwd_known"] if parent_context else True,
2214
+ "cdpath": parent_context["cdpath"] if parent_context else bool(os.environ.get("CDPATH")),
2215
+ "shadowed": parent_context["shadowed"] if parent_context else False,
2216
+ # Exported functions can survive a new interpreter. Retain the
2217
+ # caller's known candidates conservatively; an unused definition
2218
+ # does not invalidate a binding, while a possible call does.
2219
+ "functions": parent_context["functions"],
2220
+ "ifs_default": parent_context["ifs_default"] if inherited else True,
2221
+ "assignment_types_unknown": parent_context["assignment_types_unknown"] if inherited else parent_context["startup_unknown"],
2222
+ "startup_unknown": parent_context["startup_unknown"],
2223
+ }
2224
+ refused = verdict(read_script(path), depth + 1, followed, child)
2225
+ if inherited:
2226
+ for fact in ("cwd_known", "cdpath", "shadowed", "ifs_default", "assignment_types_unknown", "startup_unknown", "functions", "file", "zero"):
2227
+ if fact == "file":
2228
+ if child[fact] is None:
2229
+ parent_context[fact] = None
2230
+ continue
2231
+ parent_context[fact] = child[fact]
2232
+ if refused:
2233
+ print(
2234
+ f"block-no-verify: {path} — a script this command runs — "
2235
+ "bypasses git's verification hooks.", file=sys.stderr,
2236
+ )
2237
+ return refused
2238
+
2239
+ if context is None:
2240
+ context = initial_directory_context()
2241
+
2242
+ def inspect_payload(payload, mode, parent_context):
2243
+ if depth >= FOLLOW_MAX_DEPTH:
2244
+ print("block-no-verify: refusing at the script-following cap for a nested payload", file=sys.stderr)
2245
+ return True
2246
+ child = parent_context
2247
+ if mode == "execute":
2248
+ child = {
2249
+ **parent_context, "file": None, "zero": None, "bindings": {},
2250
+ "ifs_default": True,
2251
+ "assignment_types_unknown": parent_context["startup_unknown"],
2252
+ }
2253
+ return verdict(payload, depth + 1, followed, child)
2254
+
2255
+ paths, reasons = directory_reach(text, context, inspect, inspect_payload)
2256
+ if reasons:
2257
+ reason, operand = reasons[0]
2258
+ print(
2259
+ f"block-no-verify: refusing an execution it cannot inspect — {reason}"
2260
+ + (f" ({operand})" if operand else ""),
2261
+ file=sys.stderr,
2262
+ )
2263
+ return True
2264
+ for path, mode in paths:
2265
+ if inspect(path, mode, context):
2266
+ return True
2267
+ return False
2268
+
2269
+
2270
+ if verdict(strip_heredocs(command)):
2271
+ sys.exit(1)
2272
+
2273
+ sys.exit(0)
2274
+ PY
2275
+ then
2276
+ cat >&2 <<'EOF'
2277
+ Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify or its
2278
+ short form -n, HUSKY=0, or core.hooksPath disabling). `git commit -n` and a
2279
+ cluster such as `-nm "msg"` skip pre-commit exactly as completely as the long
2280
+ flag. Fix the underlying issue (security audit, lint,
2281
+ typecheck, tests, formatting) instead. If a fix is genuinely impossible, ask the
2282
+ user to make the risk-acceptance decision and add a specific documented ignore;
2283
+ never bypass the hook.
2284
+ EOF
2285
+ exit 2
2286
+ fi
2287
+
2288
+ exit 0