@codyswann/lisa 4.74.8 → 4.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (809) hide show
  1. package/.claude-plugin/marketplace.json +60 -0
  2. package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +4 -2
  3. package/all/copy-overwrite/scripts/lib/npm-update-checkpoint-codec.mjs +141 -0
  4. package/all/copy-overwrite/scripts/lib/npm-update-checkpoint.mjs +162 -116
  5. package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +1 -0
  6. package/all/copy-overwrite/scripts/lib/npm-update-recovery.mjs +9 -60
  7. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +18 -6
  8. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.js +20 -0
  10. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  11. package/dist/core/lisa-skill-sources.d.ts +3 -3
  12. package/dist/core/lisa-skill-sources.d.ts.map +1 -1
  13. package/dist/core/lisa-skill-sources.js +3 -2
  14. package/dist/core/lisa-skill-sources.js.map +1 -1
  15. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  16. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  17. package/dist/core/upstream-evidence-manifest.js +739 -11
  18. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  19. package/package.json +4 -4
  20. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +11 -0
  29. package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +8 -0
  30. package/plugins/lisa-cdk-devin/skills/setup-aws-accounts/SKILL.md +292 -0
  31. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-devin/.claude-plugin/plugin.json +8 -0
  34. package/plugins/lisa-devin/.devin-plugin/plugin.json +8 -0
  35. package/plugins/lisa-devin/.mcp.json +8 -0
  36. package/plugins/lisa-devin/agents/architecture-specialist.md +32 -0
  37. package/plugins/lisa-devin/agents/bug-fixer.md +43 -0
  38. package/plugins/lisa-devin/agents/builder.md +44 -0
  39. package/plugins/lisa-devin/agents/confluence-prd-intake.md +65 -0
  40. package/plugins/lisa-devin/agents/debug-specialist.md +34 -0
  41. package/plugins/lisa-devin/agents/eval-specialist.md +33 -0
  42. package/plugins/lisa-devin/agents/git-history-analyzer.md +183 -0
  43. package/plugins/lisa-devin/agents/github-agent.md +155 -0
  44. package/plugins/lisa-devin/agents/github-build-intake.md +64 -0
  45. package/plugins/lisa-devin/agents/github-prd-intake.md +66 -0
  46. package/plugins/lisa-devin/agents/jira-agent.md +135 -0
  47. package/plugins/lisa-devin/agents/jira-build-intake.md +64 -0
  48. package/plugins/lisa-devin/agents/learner.md +85 -0
  49. package/plugins/lisa-devin/agents/learning-judge.md +119 -0
  50. package/plugins/lisa-devin/agents/learnings-synthesizer.md +155 -0
  51. package/plugins/lisa-devin/agents/linear-agent.md +138 -0
  52. package/plugins/lisa-devin/agents/linear-build-intake.md +64 -0
  53. package/plugins/lisa-devin/agents/linear-prd-intake.md +66 -0
  54. package/plugins/lisa-devin/agents/notion-prd-intake.md +63 -0
  55. package/plugins/lisa-devin/agents/performance-specialist.md +26 -0
  56. package/plugins/lisa-devin/agents/pr-mining-specialist.md +85 -0
  57. package/plugins/lisa-devin/agents/product-specialist.md +30 -0
  58. package/plugins/lisa-devin/agents/quality-specialist.md +30 -0
  59. package/plugins/lisa-devin/agents/security-specialist.md +43 -0
  60. package/plugins/lisa-devin/agents/skill-evaluator.md +205 -0
  61. package/plugins/lisa-devin/agents/spec-conformance-specialist.md +56 -0
  62. package/plugins/lisa-devin/agents/test-specialist.md +32 -0
  63. package/plugins/lisa-devin/agents/tracker-mining-specialist.md +85 -0
  64. package/plugins/lisa-devin/agents/verification-specialist.md +145 -0
  65. package/plugins/lisa-devin/hooks/auto-update.mjs +824 -0
  66. package/plugins/lisa-devin/hooks/auto-update.sh +31 -0
  67. package/plugins/lisa-devin/hooks/block-blind-automerge.sh +1242 -0
  68. package/plugins/lisa-devin/hooks/block-direct-issue-create.sh +2603 -0
  69. package/plugins/lisa-devin/hooks/block-host-name-leak.mjs +412 -0
  70. package/plugins/lisa-devin/hooks/block-host-name-leak.sh +61 -0
  71. package/plugins/lisa-devin/hooks/block-instruction-file-edits.sh +207 -0
  72. package/plugins/lisa-devin/hooks/block-managed-file-edits.sh +1175 -0
  73. package/plugins/lisa-devin/hooks/block-no-verify.sh +2288 -0
  74. package/plugins/lisa-devin/hooks/block-shell-json-parsing.sh +263 -0
  75. package/plugins/lisa-devin/hooks/cleanup-stale-worktrees.sh +122 -0
  76. package/plugins/lisa-devin/hooks/devin-tool-name-shim.sh +30 -0
  77. package/plugins/lisa-devin/hooks/discharge-work-item-gates.sh +90 -0
  78. package/plugins/lisa-devin/hooks/enforce-verification-gate.sh +506 -0
  79. package/plugins/lisa-devin/hooks/enforcement-vintage-npm.mjs +320 -0
  80. package/plugins/lisa-devin/hooks/enforcement-vintage.mjs +472 -0
  81. package/plugins/lisa-devin/hooks/enforcement-vintage.sh +48 -0
  82. package/plugins/lisa-devin/hooks/failure-signature-index.mjs +717 -0
  83. package/plugins/lisa-devin/hooks/failure-signature-index.sh +26 -0
  84. package/plugins/lisa-devin/hooks/guard-dedupe.bash +374 -0
  85. package/plugins/lisa-devin/hooks/inject-resolved-config.mjs +903 -0
  86. package/plugins/lisa-devin/hooks/inject-resolved-config.sh +107 -0
  87. package/plugins/lisa-devin/hooks/inject-rules.sh +49 -0
  88. package/plugins/lisa-devin/hooks/install-pkgs.sh +84 -0
  89. package/plugins/lisa-devin/hooks/operational-hazards.mjs +736 -0
  90. package/plugins/lisa-devin/hooks/operational-hazards.sh +28 -0
  91. package/plugins/lisa-devin/hooks/parity-safety-net-heredoc.py +1004 -0
  92. package/plugins/lisa-devin/hooks/parity-safety-net.sh +3275 -0
  93. package/plugins/lisa-devin/hooks/secrets-preflight.sh +72 -0
  94. package/plugins/lisa-devin/hooks/setup-jira-cli.sh +205 -0
  95. package/plugins/lisa-devin/hooks/shell-write-nudge.sh +86 -0
  96. package/plugins/lisa-devin/hooks/sonar-secrets.sh +205 -0
  97. package/plugins/lisa-devin/hooks/threshold-ratchet-compare.mjs +518 -0
  98. package/plugins/lisa-devin/hooks/threshold-ratchet-families.mjs +543 -0
  99. package/plugins/lisa-devin/hooks/threshold-ratchet.mjs +568 -0
  100. package/plugins/lisa-devin/hooks/threshold-ratchet.sh +22 -0
  101. package/plugins/lisa-devin/hooks/withdrawn-rulings.mjs +600 -0
  102. package/plugins/lisa-devin/hooks/withdrawn-rulings.sh +24 -0
  103. package/plugins/lisa-devin/hooks/worktree-binding-guard.mjs +1383 -0
  104. package/plugins/lisa-devin/hooks/worktree-binding-guard.sh +79 -0
  105. package/plugins/lisa-devin/hooks.json +247 -0
  106. package/plugins/lisa-devin/rules/eager/00-rule-index.md +82 -0
  107. package/plugins/lisa-devin/rules/eager/base-rules.md +85 -0
  108. package/plugins/lisa-devin/rules/eager/coding-philosophy.md +27 -0
  109. package/plugins/lisa-devin/rules/eager/config-resolution.md +37 -0
  110. package/plugins/lisa-devin/rules/eager/do-it-now.md +11 -0
  111. package/plugins/lisa-devin/rules/eager/empirical-inquiry.md +24 -0
  112. package/plugins/lisa-devin/rules/eager/falsifiable-checks.md +48 -0
  113. package/plugins/lisa-devin/rules/eager/history-audit.md +27 -0
  114. package/plugins/lisa-devin/rules/eager/integration-access-layer.md +19 -0
  115. package/plugins/lisa-devin/rules/eager/intent-routing.md +18 -0
  116. package/plugins/lisa-devin/rules/eager/learnings-ladder.md +18 -0
  117. package/plugins/lisa-devin/rules/eager/local-ci-first.md +15 -0
  118. package/plugins/lisa-devin/rules/eager/measurement-integrity.md +18 -0
  119. package/plugins/lisa-devin/rules/eager/never-name-a-downstream-project.md +9 -0
  120. package/plugins/lisa-devin/rules/eager/not-blocked-just-waiting.md +13 -0
  121. package/plugins/lisa-devin/rules/eager/operational-hazards.md +50 -0
  122. package/plugins/lisa-devin/rules/eager/report-actionability.md +26 -0
  123. package/plugins/lisa-devin/rules/eager/security-audit-handling.md +40 -0
  124. package/plugins/lisa-devin/rules/eager/session-status-updates.md +20 -0
  125. package/plugins/lisa-devin/rules/eager/settled-decisions.md +75 -0
  126. package/plugins/lisa-devin/rules/eager/stale-state-claims.md +26 -0
  127. package/plugins/lisa-devin/rules/eager/tool-access-gate.md +23 -0
  128. package/plugins/lisa-devin/rules/eager/tracked-work.md +9 -0
  129. package/plugins/lisa-devin/rules/eager/upstream-to-lisa.md +14 -0
  130. package/plugins/lisa-devin/rules/eager/verification.md +33 -0
  131. package/plugins/lisa-devin/rules/eager/wiki-knowledge-source.md +13 -0
  132. package/plugins/lisa-devin/rules/eager/withdrawn-rulings.md +102 -0
  133. package/plugins/lisa-devin/rules/reference/automation-runbook-contract.md +355 -0
  134. package/plugins/lisa-devin/rules/reference/base-rules.md +136 -0
  135. package/plugins/lisa-devin/rules/reference/bdd-e2e-coverage.md +301 -0
  136. package/plugins/lisa-devin/rules/reference/blocker-containment.md +165 -0
  137. package/plugins/lisa-devin/rules/reference/claim-archaeology.md +184 -0
  138. package/plugins/lisa-devin/rules/reference/claim-evidence-mapping.md +362 -0
  139. package/plugins/lisa-devin/rules/reference/claim-time-guards.md +135 -0
  140. package/plugins/lisa-devin/rules/reference/coding-philosophy.md +428 -0
  141. package/plugins/lisa-devin/rules/reference/config-resolution.md +1225 -0
  142. package/plugins/lisa-devin/rules/reference/control-reachability.md +166 -0
  143. package/plugins/lisa-devin/rules/reference/convergent-review.md +145 -0
  144. package/plugins/lisa-devin/rules/reference/credential-substrate-precedence.md +249 -0
  145. package/plugins/lisa-devin/rules/reference/dependency-decision-records.md +177 -0
  146. package/plugins/lisa-devin/rules/reference/dependency-internalization-kit.md +244 -0
  147. package/plugins/lisa-devin/rules/reference/dependency-trust-classes.md +291 -0
  148. package/plugins/lisa-devin/rules/reference/deployed-state-readback.md +142 -0
  149. package/plugins/lisa-devin/rules/reference/derived-branch-plan.md +214 -0
  150. package/plugins/lisa-devin/rules/reference/design-source-of-truth.md +260 -0
  151. package/plugins/lisa-devin/rules/reference/design-value-binding.md +311 -0
  152. package/plugins/lisa-devin/rules/reference/do-it-now.md +19 -0
  153. package/plugins/lisa-devin/rules/reference/documentation-source-paths.md +31 -0
  154. package/plugins/lisa-devin/rules/reference/empirical-inquiry.md +28 -0
  155. package/plugins/lisa-devin/rules/reference/factory-model.md +111 -0
  156. package/plugins/lisa-devin/rules/reference/falsifiable-checks.md +275 -0
  157. package/plugins/lisa-devin/rules/reference/history-audit.md +42 -0
  158. package/plugins/lisa-devin/rules/reference/integration-access-layer.md +89 -0
  159. package/plugins/lisa-devin/rules/reference/intent-routing.md +428 -0
  160. package/plugins/lisa-devin/rules/reference/leaf-only-lifecycle.md +227 -0
  161. package/plugins/lisa-devin/rules/reference/learnings-ladder.md +41 -0
  162. package/plugins/lisa-devin/rules/reference/local-ci-first.md +34 -0
  163. package/plugins/lisa-devin/rules/reference/measurement-integrity.md +119 -0
  164. package/plugins/lisa-devin/rules/reference/never-name-a-downstream-project.md +37 -0
  165. package/plugins/lisa-devin/rules/reference/not-blocked-just-waiting.md +41 -0
  166. package/plugins/lisa-devin/rules/reference/observability-audit.md +146 -0
  167. package/plugins/lisa-devin/rules/reference/operational-hazards.md +115 -0
  168. package/plugins/lisa-devin/rules/reference/prd-definition-of-ready.md +118 -0
  169. package/plugins/lisa-devin/rules/reference/prd-lifecycle-rollup.md +192 -0
  170. package/plugins/lisa-devin/rules/reference/pre-flight-autofill.md +250 -0
  171. package/plugins/lisa-devin/rules/reference/project-learnings.md +159 -0
  172. package/plugins/lisa-devin/rules/reference/promotion-contract.md +148 -0
  173. package/plugins/lisa-devin/rules/reference/readiness-rubric.md +242 -0
  174. package/plugins/lisa-devin/rules/reference/ready-role-filing.md +212 -0
  175. package/plugins/lisa-devin/rules/reference/rejection-detection.md +308 -0
  176. package/plugins/lisa-devin/rules/reference/repo-scope-split.md +119 -0
  177. package/plugins/lisa-devin/rules/reference/report-actionability.md +61 -0
  178. package/plugins/lisa-devin/rules/reference/reset-seed-coverage.md +286 -0
  179. package/plugins/lisa-devin/rules/reference/security-audit-handling.md +55 -0
  180. package/plugins/lisa-devin/rules/reference/session-status-updates.md +111 -0
  181. package/plugins/lisa-devin/rules/reference/settled-decisions.md +39 -0
  182. package/plugins/lisa-devin/rules/reference/stale-state-claims.md +88 -0
  183. package/plugins/lisa-devin/rules/reference/tool-access-gate.md +114 -0
  184. package/plugins/lisa-devin/rules/reference/tracked-work.md +49 -0
  185. package/plugins/lisa-devin/rules/reference/upstream-to-lisa.md +26 -0
  186. package/plugins/lisa-devin/rules/reference/usage-accounting.md +261 -0
  187. package/plugins/lisa-devin/rules/reference/verification.md +278 -0
  188. package/plugins/lisa-devin/rules/reference/wiki-knowledge-source.md +37 -0
  189. package/plugins/lisa-devin/rules/reference/withdrawn-rulings.md +148 -0
  190. package/plugins/lisa-devin/rules/reference/work-item-definition-of-ready.md +177 -0
  191. package/plugins/lisa-devin/rules/reference/work-item-trailer-definition.md +129 -0
  192. package/plugins/lisa-devin/scripts/automation-run-record.mjs +678 -0
  193. package/plugins/lisa-devin/scripts/automation-status-claude-adapter.mjs +804 -0
  194. package/plugins/lisa-devin/scripts/automation-status-codex-adapter.mjs +1081 -0
  195. package/plugins/lisa-devin/scripts/automation-status-contract-drift.mjs +392 -0
  196. package/plugins/lisa-devin/scripts/automation-status-expected-fleet.mjs +508 -0
  197. package/plugins/lisa-devin/scripts/automation-status-report.mjs +200 -0
  198. package/plugins/lisa-devin/scripts/automation-status-run-history.mjs +216 -0
  199. package/plugins/lisa-devin/scripts/automation-status-unrecorded-runs.mjs +127 -0
  200. package/plugins/lisa-devin/scripts/blocker-edge-resolution.mjs +150 -0
  201. package/plugins/lisa-devin/scripts/cross-pollinate.mjs +763 -0
  202. package/plugins/lisa-devin/scripts/design-bindings-probe.mjs +713 -0
  203. package/plugins/lisa-devin/scripts/design-intake-gate.mjs +470 -0
  204. package/plugins/lisa-devin/scripts/design-source-gate.mjs +610 -0
  205. package/plugins/lisa-devin/scripts/design-variable-ids.mjs +567 -0
  206. package/plugins/lisa-devin/scripts/doctor-report.mjs +635 -0
  207. package/plugins/lisa-devin/scripts/install-remote-agent-aws.mjs +443 -0
  208. package/plugins/lisa-devin/scripts/intake-blocker-reprobe.mjs +1065 -0
  209. package/plugins/lisa-devin/scripts/intake-prework-denominator.mjs +249 -0
  210. package/plugins/lisa-devin/scripts/lifecycle-label-trust.mjs +593 -0
  211. package/plugins/lisa-devin/scripts/linear-state-write-target.mjs +362 -0
  212. package/plugins/lisa-devin/scripts/plugin-sync-explain.mjs +654 -0
  213. package/plugins/lisa-devin/scripts/pr-arming-sweep.mjs +561 -0
  214. package/plugins/lisa-devin/scripts/project-ideation-idempotency-harness.mjs +338 -0
  215. package/plugins/lisa-devin/scripts/qa-signal-lifecycle.mjs +523 -0
  216. package/plugins/lisa-devin/scripts/queue-contract-resolution.mjs +583 -0
  217. package/plugins/lisa-devin/scripts/queue-health-classification.mjs +157 -0
  218. package/plugins/lisa-devin/scripts/queue-status-build-readers.mjs +561 -0
  219. package/plugins/lisa-devin/scripts/queue-status-prd-readers.mjs +452 -0
  220. package/plugins/lisa-devin/scripts/remote-agent-aws-setup.sh +677 -0
  221. package/plugins/lisa-devin/scripts/repo-scope-vocabulary-audit.mjs +458 -0
  222. package/plugins/lisa-devin/scripts/resolve-lifecycle-role.mjs +347 -0
  223. package/plugins/lisa-devin/scripts/rollup-blocker-classification.mjs +616 -0
  224. package/plugins/lisa-devin/scripts/scoping-label-audit.mjs +474 -0
  225. package/plugins/lisa-devin/skills/lisa-acceptance-criteria/SKILL.md +73 -0
  226. package/plugins/lisa-devin/skills/lisa-agent-design-best-practices/SKILL.md +242 -0
  227. package/plugins/lisa-devin/skills/lisa-agent-ready/SKILL.md +271 -0
  228. package/plugins/lisa-devin/skills/lisa-analyze-claude-remote/SKILL.md +434 -0
  229. package/plugins/lisa-devin/skills/lisa-atlassian-access/SKILL.md +469 -0
  230. package/plugins/lisa-devin/skills/lisa-atlassian-access/scripts/markdown-to-adf.mjs +236 -0
  231. package/plugins/lisa-devin/skills/lisa-attribute-failure/SKILL.md +142 -0
  232. package/plugins/lisa-devin/skills/lisa-automation-status/SKILL.md +149 -0
  233. package/plugins/lisa-devin/skills/lisa-bug-triage/SKILL.md +23 -0
  234. package/plugins/lisa-devin/skills/lisa-capability-drift/SKILL.md +71 -0
  235. package/plugins/lisa-devin/skills/lisa-codebase-research/SKILL.md +87 -0
  236. package/plugins/lisa-devin/skills/lisa-codify-verification/SKILL.md +213 -0
  237. package/plugins/lisa-devin/skills/lisa-confluence-prd-intake/SKILL.md +417 -0
  238. package/plugins/lisa-devin/skills/lisa-confluence-to-tracker/SKILL.md +469 -0
  239. package/plugins/lisa-devin/skills/lisa-confluence-write-prd/SKILL.md +111 -0
  240. package/plugins/lisa-devin/skills/lisa-cross-pollinate/SKILL.md +175 -0
  241. package/plugins/lisa-devin/skills/lisa-debrief/SKILL.md +96 -0
  242. package/plugins/lisa-devin/skills/lisa-debrief-apply/SKILL.md +106 -0
  243. package/plugins/lisa-devin/skills/lisa-delivery-effectiveness/SKILL.md +62 -0
  244. package/plugins/lisa-devin/skills/lisa-design-intake/SKILL.md +198 -0
  245. package/plugins/lisa-devin/skills/lisa-detect-tooling/SKILL.md +110 -0
  246. package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
  247. package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +658 -0
  248. package/plugins/lisa-devin/skills/lisa-doctor/SKILL.md +743 -0
  249. package/plugins/lisa-devin/skills/lisa-drive-pr-to-merge/SKILL.md +1587 -0
  250. package/plugins/lisa-devin/skills/lisa-epic-triage/SKILL.md +28 -0
  251. package/plugins/lisa-devin/skills/lisa-evaluation-suite/SKILL.md +63 -0
  252. package/plugins/lisa-devin/skills/lisa-exploratory-qa/SKILL.md +185 -0
  253. package/plugins/lisa-devin/skills/lisa-fix-linter-error/SKILL.md +45 -0
  254. package/plugins/lisa-devin/skills/lisa-generate-claude-remote-build-script/SKILL.md +235 -0
  255. package/plugins/lisa-devin/skills/lisa-git-commit/SKILL.md +53 -0
  256. package/plugins/lisa-devin/skills/lisa-git-prune/SKILL.md +35 -0
  257. package/plugins/lisa-devin/skills/lisa-git-submit-pr/SKILL.md +170 -0
  258. package/plugins/lisa-devin/skills/lisa-github-add-journey/SKILL.md +118 -0
  259. package/plugins/lisa-devin/skills/lisa-github-build-intake/SKILL.md +745 -0
  260. package/plugins/lisa-devin/skills/lisa-github-claim/SKILL.md +27 -0
  261. package/plugins/lisa-devin/skills/lisa-github-create/SKILL.md +105 -0
  262. package/plugins/lisa-devin/skills/lisa-github-evidence/SKILL.md +120 -0
  263. package/plugins/lisa-devin/skills/lisa-github-journey/SKILL.md +134 -0
  264. package/plugins/lisa-devin/skills/lisa-github-prd-intake/SKILL.md +424 -0
  265. package/plugins/lisa-devin/skills/lisa-github-project-v2/SKILL.md +227 -0
  266. package/plugins/lisa-devin/skills/lisa-github-read-issue/SKILL.md +305 -0
  267. package/plugins/lisa-devin/skills/lisa-github-sync/SKILL.md +201 -0
  268. package/plugins/lisa-devin/skills/lisa-github-to-tracker/SKILL.md +456 -0
  269. package/plugins/lisa-devin/skills/lisa-github-validate-issue/SKILL.md +598 -0
  270. package/plugins/lisa-devin/skills/lisa-github-verify/SKILL.md +60 -0
  271. package/plugins/lisa-devin/skills/lisa-github-write-issue/SKILL.md +531 -0
  272. package/plugins/lisa-devin/skills/lisa-github-write-prd/SKILL.md +156 -0
  273. package/plugins/lisa-devin/skills/lisa-health/SKILL.md +142 -0
  274. package/plugins/lisa-devin/skills/lisa-health-drift-cron/SKILL.md +80 -0
  275. package/plugins/lisa-devin/skills/lisa-implement/SKILL.md +372 -0
  276. package/plugins/lisa-devin/skills/lisa-improve-code-complexity/SKILL.md +44 -0
  277. package/plugins/lisa-devin/skills/lisa-improve-harness/SKILL.md +487 -0
  278. package/plugins/lisa-devin/skills/lisa-improve-max-lines/SKILL.md +45 -0
  279. package/plugins/lisa-devin/skills/lisa-improve-max-lines-per-function/SKILL.md +46 -0
  280. package/plugins/lisa-devin/skills/lisa-improve-test-coverage/SKILL.md +47 -0
  281. package/plugins/lisa-devin/skills/lisa-improve-tests/SKILL.md +50 -0
  282. package/plugins/lisa-devin/skills/lisa-intake/SKILL.md +208 -0
  283. package/plugins/lisa-devin/skills/lisa-intake-explain/SKILL.md +279 -0
  284. package/plugins/lisa-devin/skills/lisa-jam-access/SKILL.md +128 -0
  285. package/plugins/lisa-devin/skills/lisa-jira-add-journey/SKILL.md +139 -0
  286. package/plugins/lisa-devin/skills/lisa-jira-build-intake/SKILL.md +556 -0
  287. package/plugins/lisa-devin/skills/lisa-jira-claim/SKILL.md +27 -0
  288. package/plugins/lisa-devin/skills/lisa-jira-create/SKILL.md +157 -0
  289. package/plugins/lisa-devin/skills/lisa-jira-evidence/SKILL.md +106 -0
  290. package/plugins/lisa-devin/skills/lisa-jira-evidence/scripts/post-evidence.sh +262 -0
  291. package/plugins/lisa-devin/skills/lisa-jira-journey/SKILL.md +168 -0
  292. package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/generate-templates.py +239 -0
  293. package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/parse-plan.py +570 -0
  294. package/plugins/lisa-devin/skills/lisa-jira-read-ticket/SKILL.md +199 -0
  295. package/plugins/lisa-devin/skills/lisa-jira-read-ticket/scripts/download-attachment.sh +211 -0
  296. package/plugins/lisa-devin/skills/lisa-jira-sync/SKILL.md +117 -0
  297. package/plugins/lisa-devin/skills/lisa-jira-validate-ticket/SKILL.md +525 -0
  298. package/plugins/lisa-devin/skills/lisa-jira-verify/SKILL.md +61 -0
  299. package/plugins/lisa-devin/skills/lisa-jira-write-ticket/SKILL.md +440 -0
  300. package/plugins/lisa-devin/skills/lisa-jsdoc-best-practices/SKILL.md +491 -0
  301. package/plugins/lisa-devin/skills/lisa-kane-browser/SKILL.md +80 -0
  302. package/plugins/lisa-devin/skills/lisa-learnings-audit/SKILL.md +490 -0
  303. package/plugins/lisa-devin/skills/lisa-linear-access/SKILL.md +483 -0
  304. package/plugins/lisa-devin/skills/lisa-linear-add-journey/SKILL.md +109 -0
  305. package/plugins/lisa-devin/skills/lisa-linear-build-intake/SKILL.md +548 -0
  306. package/plugins/lisa-devin/skills/lisa-linear-claim/SKILL.md +28 -0
  307. package/plugins/lisa-devin/skills/lisa-linear-create/SKILL.md +150 -0
  308. package/plugins/lisa-devin/skills/lisa-linear-evidence/SKILL.md +124 -0
  309. package/plugins/lisa-devin/skills/lisa-linear-journey/SKILL.md +147 -0
  310. package/plugins/lisa-devin/skills/lisa-linear-prd-intake/SKILL.md +411 -0
  311. package/plugins/lisa-devin/skills/lisa-linear-read-issue/SKILL.md +201 -0
  312. package/plugins/lisa-devin/skills/lisa-linear-sync/SKILL.md +164 -0
  313. package/plugins/lisa-devin/skills/lisa-linear-to-tracker/SKILL.md +454 -0
  314. package/plugins/lisa-devin/skills/lisa-linear-validate-issue/SKILL.md +504 -0
  315. package/plugins/lisa-devin/skills/lisa-linear-verify/SKILL.md +65 -0
  316. package/plugins/lisa-devin/skills/lisa-linear-write-issue/SKILL.md +448 -0
  317. package/plugins/lisa-devin/skills/lisa-linear-write-prd/SKILL.md +98 -0
  318. package/plugins/lisa-devin/skills/lisa-monitor/SKILL.md +224 -0
  319. package/plugins/lisa-devin/skills/lisa-nightly-add-test-coverage/SKILL.md +40 -0
  320. package/plugins/lisa-devin/skills/lisa-nightly-improve-tests/SKILL.md +29 -0
  321. package/plugins/lisa-devin/skills/lisa-nightly-lower-code-complexity/SKILL.md +42 -0
  322. package/plugins/lisa-devin/skills/lisa-notion-access/SKILL.md +301 -0
  323. package/plugins/lisa-devin/skills/lisa-notion-prd-intake/SKILL.md +360 -0
  324. package/plugins/lisa-devin/skills/lisa-notion-to-tracker/SKILL.md +467 -0
  325. package/plugins/lisa-devin/skills/lisa-notion-write-prd/SKILL.md +111 -0
  326. package/plugins/lisa-devin/skills/lisa-parity-code-review/SKILL.md +83 -0
  327. package/plugins/lisa-devin/skills/lisa-parity-code-simplifier/SKILL.md +76 -0
  328. package/plugins/lisa-devin/skills/lisa-parity-coderabbit/SKILL.md +87 -0
  329. package/plugins/lisa-devin/skills/lisa-parity-sentry-sdk-setup/SKILL.md +375 -0
  330. package/plugins/lisa-devin/skills/lisa-parity-sentry-seer/SKILL.md +168 -0
  331. package/plugins/lisa-devin/skills/lisa-parity-skill-creator/SKILL.md +155 -0
  332. package/plugins/lisa-devin/skills/lisa-performance-review/SKILL.md +94 -0
  333. package/plugins/lisa-devin/skills/lisa-persist-learning/SKILL.md +247 -0
  334. package/plugins/lisa-devin/skills/lisa-plan/SKILL.md +64 -0
  335. package/plugins/lisa-devin/skills/lisa-plugin-sync-explain/SKILL.md +53 -0
  336. package/plugins/lisa-devin/skills/lisa-posthog-access/SKILL.md +140 -0
  337. package/plugins/lisa-devin/skills/lisa-prd-backlink/SKILL.md +274 -0
  338. package/plugins/lisa-devin/skills/lisa-prd-source-write/SKILL.md +104 -0
  339. package/plugins/lisa-devin/skills/lisa-prd-ticket-coverage/SKILL.md +187 -0
  340. package/plugins/lisa-devin/skills/lisa-product-walkthrough/SKILL.md +106 -0
  341. package/plugins/lisa-devin/skills/lisa-project-ideation/SKILL.md +417 -0
  342. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/evidence-card-format.md +21 -0
  343. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/host-project-only.md +22 -0
  344. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/idempotency-verification-harness.md +57 -0
  345. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/public-external-inspiration.md +22 -0
  346. package/plugins/lisa-devin/skills/lisa-project-ideation/examples/unavailable-data-rejection.md +22 -0
  347. package/plugins/lisa-devin/skills/lisa-pull-request-review/SKILL.md +132 -0
  348. package/plugins/lisa-devin/skills/lisa-quality-review/SKILL.md +63 -0
  349. package/plugins/lisa-devin/skills/lisa-queue-status/SKILL.md +215 -0
  350. package/plugins/lisa-devin/skills/lisa-remote-dispatch/SKILL.md +109 -0
  351. package/plugins/lisa-devin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +535 -0
  352. package/plugins/lisa-devin/skills/lisa-repair-intake/SKILL.md +1621 -0
  353. package/plugins/lisa-devin/skills/lisa-reproduce-bug/SKILL.md +75 -0
  354. package/plugins/lisa-devin/skills/lisa-research/SKILL.md +92 -0
  355. package/plugins/lisa-devin/skills/lisa-review-implementation/SKILL.md +209 -0
  356. package/plugins/lisa-devin/skills/lisa-review-local/SKILL.md +141 -0
  357. package/plugins/lisa-devin/skills/lisa-rework-triage/SKILL.md +206 -0
  358. package/plugins/lisa-devin/skills/lisa-root-cause-analysis/SKILL.md +135 -0
  359. package/plugins/lisa-devin/skills/lisa-secrets-access/SKILL.md +430 -0
  360. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +433 -0
  361. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +288 -0
  362. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +273 -0
  363. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/envfile.mjs +80 -0
  364. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/inspect-vault.mjs +142 -0
  365. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +1085 -0
  366. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
  367. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
  368. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +342 -0
  369. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
  370. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/providers.mjs +805 -0
  371. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +680 -0
  372. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/read-secret-note.mjs +105 -0
  373. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
  374. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +285 -0
  375. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/rotate-secret.mjs +297 -0
  376. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  377. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/surfaces.mjs +480 -0
  378. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +510 -0
  379. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +63 -0
  380. package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/validate-config.mjs +550 -0
  381. package/plugins/lisa-devin/skills/lisa-security-review/SKILL.md +124 -0
  382. package/plugins/lisa-devin/skills/lisa-security-zap-scan/SKILL.md +53 -0
  383. package/plugins/lisa-devin/skills/lisa-sentry-access/SKILL.md +140 -0
  384. package/plugins/lisa-devin/skills/lisa-setup-atlassian/SKILL.md +407 -0
  385. package/plugins/lisa-devin/skills/lisa-setup-automations/SKILL.md +430 -0
  386. package/plugins/lisa-devin/skills/lisa-setup-automations/scripts/generate-workflow.mjs +288 -0
  387. package/plugins/lisa-devin/skills/lisa-setup-confluence/SKILL.md +254 -0
  388. package/plugins/lisa-devin/skills/lisa-setup-github/SKILL.md +319 -0
  389. package/plugins/lisa-devin/skills/lisa-setup-github-repo/SKILL.md +128 -0
  390. package/plugins/lisa-devin/skills/lisa-setup-jira/SKILL.md +198 -0
  391. package/plugins/lisa-devin/skills/lisa-setup-kane/SKILL.md +96 -0
  392. package/plugins/lisa-devin/skills/lisa-setup-linear/SKILL.md +375 -0
  393. package/plugins/lisa-devin/skills/lisa-setup-local-env/SKILL.md +95 -0
  394. package/plugins/lisa-devin/skills/lisa-setup-local-env/scripts/local-env.mjs +196 -0
  395. package/plugins/lisa-devin/skills/lisa-setup-notion/SKILL.md +376 -0
  396. package/plugins/lisa-devin/skills/lisa-setup-remote-aws/SKILL.md +149 -0
  397. package/plugins/lisa-devin/skills/lisa-setup-remote-env/SKILL.md +382 -0
  398. package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/materialized-env-authority.mjs +262 -0
  399. package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/session-start.sh +87 -0
  400. package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/setup.sh +216 -0
  401. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
  402. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +378 -0
  403. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +1632 -0
  404. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  405. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +448 -0
  406. package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +250 -0
  407. package/plugins/lisa-devin/skills/lisa-setup-sonar/SKILL.md +153 -0
  408. package/plugins/lisa-devin/skills/lisa-setup-workstation/SKILL.md +143 -0
  409. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/bounded-child.mjs +168 -0
  410. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/catalogue.mjs +293 -0
  411. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/cli.mjs +333 -0
  412. package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/workstation.mjs +511 -0
  413. package/plugins/lisa-devin/skills/lisa-sonarcloud-access/SKILL.md +202 -0
  414. package/plugins/lisa-devin/skills/lisa-spec-conformance/SKILL.md +205 -0
  415. package/plugins/lisa-devin/skills/lisa-starter-sync/SKILL.md +35 -0
  416. package/plugins/lisa-devin/skills/lisa-sync-down/SKILL.md +209 -0
  417. package/plugins/lisa-devin/skills/lisa-task-decomposition/SKILL.md +195 -0
  418. package/plugins/lisa-devin/skills/lisa-task-triage/SKILL.md +23 -0
  419. package/plugins/lisa-devin/skills/lisa-tdd-implementation/SKILL.md +91 -0
  420. package/plugins/lisa-devin/skills/lisa-tear-down-automations/SKILL.md +85 -0
  421. package/plugins/lisa-devin/skills/lisa-test-strategy/SKILL.md +66 -0
  422. package/plugins/lisa-devin/skills/lisa-ticket-triage/SKILL.md +214 -0
  423. package/plugins/lisa-devin/skills/lisa-track/SKILL.md +129 -0
  424. package/plugins/lisa-devin/skills/lisa-tracker-add-journey/SKILL.md +28 -0
  425. package/plugins/lisa-devin/skills/lisa-tracker-build-intake/SKILL.md +134 -0
  426. package/plugins/lisa-devin/skills/lisa-tracker-claim/SKILL.md +35 -0
  427. package/plugins/lisa-devin/skills/lisa-tracker-create/SKILL.md +28 -0
  428. package/plugins/lisa-devin/skills/lisa-tracker-evidence/SKILL.md +63 -0
  429. package/plugins/lisa-devin/skills/lisa-tracker-journey/SKILL.md +27 -0
  430. package/plugins/lisa-devin/skills/lisa-tracker-read/SKILL.md +28 -0
  431. package/plugins/lisa-devin/skills/lisa-tracker-source-artifacts/SKILL.md +111 -0
  432. package/plugins/lisa-devin/skills/lisa-tracker-sync/SKILL.md +150 -0
  433. package/plugins/lisa-devin/skills/lisa-tracker-validate/SKILL.md +37 -0
  434. package/plugins/lisa-devin/skills/lisa-tracker-verify/SKILL.md +29 -0
  435. package/plugins/lisa-devin/skills/lisa-tracker-write/SKILL.md +55 -0
  436. package/plugins/lisa-devin/skills/lisa-usage-accounting/SKILL.md +205 -0
  437. package/plugins/lisa-devin/skills/lisa-usage-accounting/references/effectiveness.md +63 -0
  438. package/plugins/lisa-devin/skills/lisa-use-the-product/SKILL.md +86 -0
  439. package/plugins/lisa-devin/skills/lisa-validate-tracker-mapping/SKILL.md +467 -0
  440. package/plugins/lisa-devin/skills/lisa-verification-lifecycle/SKILL.md +384 -0
  441. package/plugins/lisa-devin/skills/lisa-verify/SKILL.md +55 -0
  442. package/plugins/lisa-devin/skills/lisa-verify-prd/SKILL.md +394 -0
  443. package/plugins/lisa-devin/skills/lisa-verify-workflow-change/SKILL.md +154 -0
  444. package/plugins/lisa-devin/skills/lisa-wiki-install/SKILL.md +101 -0
  445. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  446. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  447. package/plugins/lisa-expo-agy/plugin.json +1 -1
  448. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  449. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  450. package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +11 -0
  451. package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +8 -0
  452. package/plugins/lisa-expo-devin/.mcp.json +8 -0
  453. package/plugins/lisa-expo-devin/THIRD-PARTY-NOTICES.md +84 -0
  454. package/plugins/lisa-expo-devin/agents/ops-specialist.md +124 -0
  455. package/plugins/lisa-expo-devin/hooks/devin-tool-name-shim.sh +30 -0
  456. package/plugins/lisa-expo-devin/hooks/register-maestro-mcp.sh +84 -0
  457. package/plugins/lisa-expo-devin/hooks.json +14 -0
  458. package/plugins/lisa-expo-devin/skills/add-app-clip/SKILL.md +280 -0
  459. package/plugins/lisa-expo-devin/skills/add-app-clip/references/native-module.md +96 -0
  460. package/plugins/lisa-expo-devin/skills/apollo-client/SKILL.md +238 -0
  461. package/plugins/lisa-expo-devin/skills/apollo-client/references/mutation-patterns.md +360 -0
  462. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/SKILL.md +369 -0
  463. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/atomic-levels.md +417 -0
  464. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/folder-structure.md +257 -0
  465. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/gluestack-mapping.md +233 -0
  466. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/test_validate_atomic_structure.py +240 -0
  467. package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/validate_atomic_structure.py +438 -0
  468. package/plugins/lisa-expo-devin/skills/building-native-ui/SKILL.md +321 -0
  469. package/plugins/lisa-expo-devin/skills/building-native-ui/references/animations.md +220 -0
  470. package/plugins/lisa-expo-devin/skills/building-native-ui/references/controls.md +272 -0
  471. package/plugins/lisa-expo-devin/skills/building-native-ui/references/form-sheet.md +253 -0
  472. package/plugins/lisa-expo-devin/skills/building-native-ui/references/gradients.md +106 -0
  473. package/plugins/lisa-expo-devin/skills/building-native-ui/references/icons.md +213 -0
  474. package/plugins/lisa-expo-devin/skills/building-native-ui/references/media.md +198 -0
  475. package/plugins/lisa-expo-devin/skills/building-native-ui/references/route-structure.md +229 -0
  476. package/plugins/lisa-expo-devin/skills/building-native-ui/references/search.md +248 -0
  477. package/plugins/lisa-expo-devin/skills/building-native-ui/references/storage.md +121 -0
  478. package/plugins/lisa-expo-devin/skills/building-native-ui/references/tabs.md +433 -0
  479. package/plugins/lisa-expo-devin/skills/building-native-ui/references/toolbar-and-headers.md +284 -0
  480. package/plugins/lisa-expo-devin/skills/building-native-ui/references/visual-effects.md +197 -0
  481. package/plugins/lisa-expo-devin/skills/building-native-ui/references/webgpu-three.md +605 -0
  482. package/plugins/lisa-expo-devin/skills/building-native-ui/references/zoom-transitions.md +158 -0
  483. package/plugins/lisa-expo-devin/skills/container-view-pattern/SKILL.md +355 -0
  484. package/plugins/lisa-expo-devin/skills/container-view-pattern/references/examples.md +749 -0
  485. package/plugins/lisa-expo-devin/skills/container-view-pattern/references/patterns.md +318 -0
  486. package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/create_component.py +200 -0
  487. package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/validate_component.py +332 -0
  488. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/SKILL.md +268 -0
  489. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/common-issues.md +619 -0
  490. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/file-extensions.md +340 -0
  491. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/platform-api.md +276 -0
  492. package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/scripts/validate_cross_platform.py +416 -0
  493. package/plugins/lisa-expo-devin/skills/directory-structure/SKILL.md +214 -0
  494. package/plugins/lisa-expo-devin/skills/directory-structure/scripts/validate_structure.py +454 -0
  495. package/plugins/lisa-expo-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
  496. package/plugins/lisa-expo-devin/skills/eas-update-insights/SKILL.md +228 -0
  497. package/plugins/lisa-expo-devin/skills/eas-update-insights/references/channel-insights-schema.md +47 -0
  498. package/plugins/lisa-expo-devin/skills/eas-update-insights/references/update-insights-schema.md +69 -0
  499. package/plugins/lisa-expo-devin/skills/expo-api-routes/SKILL.md +369 -0
  500. package/plugins/lisa-expo-devin/skills/expo-brownfield/SKILL.md +54 -0
  501. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-integrated.md +526 -0
  502. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-isolated.md +402 -0
  503. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/comparison.md +63 -0
  504. package/plugins/lisa-expo-devin/skills/expo-brownfield/references/troubleshooting.md +88 -0
  505. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/SKILL.md +92 -0
  506. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/fetch.js +113 -0
  507. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/package.json +11 -0
  508. package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/validate.js +85 -0
  509. package/plugins/lisa-expo-devin/skills/expo-deployment/SKILL.md +193 -0
  510. package/plugins/lisa-expo-devin/skills/expo-deployment/references/app-store-metadata.md +479 -0
  511. package/plugins/lisa-expo-devin/skills/expo-deployment/references/ios-app-store.md +355 -0
  512. package/plugins/lisa-expo-devin/skills/expo-deployment/references/play-store.md +264 -0
  513. package/plugins/lisa-expo-devin/skills/expo-deployment/references/testflight.md +58 -0
  514. package/plugins/lisa-expo-devin/skills/expo-deployment/references/workflows.md +200 -0
  515. package/plugins/lisa-expo-devin/skills/expo-dev-client/SKILL.md +164 -0
  516. package/plugins/lisa-expo-devin/skills/expo-env-config/SKILL.md +309 -0
  517. package/plugins/lisa-expo-devin/skills/expo-env-config/references/validation-patterns.md +417 -0
  518. package/plugins/lisa-expo-devin/skills/expo-module/SKILL.md +141 -0
  519. package/plugins/lisa-expo-devin/skills/expo-module/references/config-plugin.md +90 -0
  520. package/plugins/lisa-expo-devin/skills/expo-module/references/create-expo-module.md +206 -0
  521. package/plugins/lisa-expo-devin/skills/expo-module/references/lifecycle.md +127 -0
  522. package/plugins/lisa-expo-devin/skills/expo-module/references/module-config.md +48 -0
  523. package/plugins/lisa-expo-devin/skills/expo-module/references/native-module.md +286 -0
  524. package/plugins/lisa-expo-devin/skills/expo-module/references/native-view.md +171 -0
  525. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/SKILL.md +432 -0
  526. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/official-docs.md +290 -0
  527. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/static-export-verification.md +54 -0
  528. package/plugins/lisa-expo-devin/skills/expo-router-best-practices/scripts/generate-route.py +171 -0
  529. package/plugins/lisa-expo-devin/skills/expo-tailwind-setup/SKILL.md +487 -0
  530. package/plugins/lisa-expo-devin/skills/expo-ui-jetpack-compose/SKILL.md +40 -0
  531. package/plugins/lisa-expo-devin/skills/expo-ui-swift-ui/SKILL.md +39 -0
  532. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/SKILL.md +419 -0
  533. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/color-tokens.md +343 -0
  534. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/component-mapping.md +307 -0
  535. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/spacing-scale.md +300 -0
  536. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/test_validate_styling.py +240 -0
  537. package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/validate_styling.py +424 -0
  538. package/plugins/lisa-expo-devin/skills/jira-add-journey/SKILL.md +140 -0
  539. package/plugins/lisa-expo-devin/skills/jira-create/SKILL.md +161 -0
  540. package/plugins/lisa-expo-devin/skills/jira-evidence/SKILL.md +84 -0
  541. package/plugins/lisa-expo-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
  542. package/plugins/lisa-expo-devin/skills/jira-journey/SKILL.md +196 -0
  543. package/plugins/lisa-expo-devin/skills/jira-journey/scripts/generate-templates.py +281 -0
  544. package/plugins/lisa-expo-devin/skills/jira-journey/scripts/parse-plan.py +400 -0
  545. package/plugins/lisa-expo-devin/skills/jira-verify/SKILL.md +27 -0
  546. package/plugins/lisa-expo-devin/skills/local-state/SKILL.md +362 -0
  547. package/plugins/lisa-expo-devin/skills/local-state/references/async-storage.md +505 -0
  548. package/plugins/lisa-expo-devin/skills/local-state/references/persistence-patterns.md +711 -0
  549. package/plugins/lisa-expo-devin/skills/local-state/references/reactive-variables.md +446 -0
  550. package/plugins/lisa-expo-devin/skills/maestro-mcp-setup/SKILL.md +144 -0
  551. package/plugins/lisa-expo-devin/skills/native-data-fetching/SKILL.md +507 -0
  552. package/plugins/lisa-expo-devin/skills/native-data-fetching/references/expo-router-loaders.md +344 -0
  553. package/plugins/lisa-expo-devin/skills/ops-browser-uat/SKILL.md +124 -0
  554. package/plugins/lisa-expo-devin/skills/ops-check-logs/SKILL.md +217 -0
  555. package/plugins/lisa-expo-devin/skills/ops-db-ops/SKILL.md +214 -0
  556. package/plugins/lisa-expo-devin/skills/ops-deploy/SKILL.md +123 -0
  557. package/plugins/lisa-expo-devin/skills/ops-monitor-errors/SKILL.md +99 -0
  558. package/plugins/lisa-expo-devin/skills/ops-performance/SKILL.md +165 -0
  559. package/plugins/lisa-expo-devin/skills/ops-run-local/SKILL.md +169 -0
  560. package/plugins/lisa-expo-devin/skills/ops-verify-health/SKILL.md +101 -0
  561. package/plugins/lisa-expo-devin/skills/owasp-zap/SKILL.md +61 -0
  562. package/plugins/lisa-expo-devin/skills/play-store-access/SKILL.md +226 -0
  563. package/plugins/lisa-expo-devin/skills/playwright-ci-debugging/SKILL.md +140 -0
  564. package/plugins/lisa-expo-devin/skills/playwright-selectors/SKILL.md +496 -0
  565. package/plugins/lisa-expo-devin/skills/reduce-complexity/SKILL.md +251 -0
  566. package/plugins/lisa-expo-devin/skills/reduce-complexity/references/extraction-strategies.md +456 -0
  567. package/plugins/lisa-expo-devin/skills/reduce-complexity/references/refactoring-patterns.md +557 -0
  568. package/plugins/lisa-expo-devin/skills/testing-library/SKILL.md +314 -0
  569. package/plugins/lisa-expo-devin/skills/testing-library/references/async-patterns.md +420 -0
  570. package/plugins/lisa-expo-devin/skills/testing-library/references/expo-router-testing.md +556 -0
  571. package/plugins/lisa-expo-devin/skills/testing-library/references/mocking-patterns.md +612 -0
  572. package/plugins/lisa-expo-devin/skills/testing-library/references/query-priority.md +291 -0
  573. package/plugins/lisa-expo-devin/skills/upgrading-expo/SKILL.md +134 -0
  574. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-audio.md +132 -0
  575. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-video.md +160 -0
  576. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/native-tabs.md +124 -0
  577. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/new-architecture.md +79 -0
  578. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-19.md +79 -0
  579. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-compiler.md +59 -0
  580. package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-navigation-to-expo-router.md +61 -0
  581. package/plugins/lisa-expo-devin/skills/use-dom/SKILL.md +417 -0
  582. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  583. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  584. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  585. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  586. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  587. package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +11 -0
  588. package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +8 -0
  589. package/plugins/lisa-harper-fabric-devin/generated-artifact-globs.txt +5 -0
  590. package/plugins/lisa-harper-fabric-devin/hooks/block-generated-artifact-edits.sh +141 -0
  591. package/plugins/lisa-harper-fabric-devin/hooks/devin-tool-name-shim.sh +30 -0
  592. package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.mjs +196 -0
  593. package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.sh +46 -0
  594. package/plugins/lisa-harper-fabric-devin/hooks/inject-rules.sh +27 -0
  595. package/plugins/lisa-harper-fabric-devin/hooks.json +36 -0
  596. package/plugins/lisa-harper-fabric-devin/rules/eager/harper-fabric.md +31 -0
  597. package/plugins/lisa-harper-fabric-devin/rules/reference/harper-fabric.md +52 -0
  598. package/plugins/lisa-harper-fabric-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
  599. package/plugins/lisa-harper-fabric-devin/skills/harper-auth/SKILL.md +368 -0
  600. package/plugins/lisa-harper-fabric-devin/skills/harper-build-and-deploy/SKILL.md +198 -0
  601. package/plugins/lisa-harper-fabric-devin/skills/harper-caching/SKILL.md +265 -0
  602. package/plugins/lisa-harper-fabric-devin/skills/harper-component-model/SKILL.md +87 -0
  603. package/plugins/lisa-harper-fabric-devin/skills/harper-config-yaml/SKILL.md +283 -0
  604. package/plugins/lisa-harper-fabric-devin/skills/harper-operations/SKILL.md +239 -0
  605. package/plugins/lisa-harper-fabric-devin/skills/harper-realtime/SKILL.md +187 -0
  606. package/plugins/lisa-harper-fabric-devin/skills/harper-resources/SKILL.md +142 -0
  607. package/plugins/lisa-harper-fabric-devin/skills/harper-rest-queries/SKILL.md +403 -0
  608. package/plugins/lisa-harper-fabric-devin/skills/harper-schema-graphql/SKILL.md +123 -0
  609. package/plugins/lisa-harper-fabric-devin/skills/harper-testing/SKILL.md +254 -0
  610. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  611. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  612. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  613. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  614. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  615. package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +11 -0
  616. package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +8 -0
  617. package/plugins/lisa-nestjs-devin/hooks/block-migration-edits.sh +125 -0
  618. package/plugins/lisa-nestjs-devin/hooks/devin-tool-name-shim.sh +30 -0
  619. package/plugins/lisa-nestjs-devin/hooks/lisa-edit-gate.sh +152 -0
  620. package/plugins/lisa-nestjs-devin/hooks.json +15 -0
  621. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/SKILL.md +176 -0
  622. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/advanced-features.md +527 -0
  623. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/project-patterns.md +531 -0
  624. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/quick-start.md +257 -0
  625. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/resolvers-mutations.md +413 -0
  626. package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/types-scalars.md +513 -0
  627. package/plugins/lisa-nestjs-devin/skills/nestjs-rules/SKILL.md +560 -0
  628. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/SKILL.md +275 -0
  629. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/configuration-patterns.md +487 -0
  630. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/entity-patterns.md +450 -0
  631. package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/observability-patterns.md +536 -0
  632. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  633. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  634. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  635. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  636. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  637. package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +8 -0
  638. package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +8 -0
  639. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/SKILL.md +163 -0
  640. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/references/repo-topic-config.md +185 -0
  641. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/SKILL.md +175 -0
  642. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/platform-routing.md +123 -0
  643. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/prompts.md +78 -0
  644. package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-setup/SKILL.md +141 -0
  645. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  646. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  647. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  648. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  649. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  650. package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +11 -0
  651. package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +8 -0
  652. package/plugins/lisa-phaser-devin/agents/accessibility-advocate.md +55 -0
  653. package/plugins/lisa-phaser-devin/agents/art-director.md +90 -0
  654. package/plugins/lisa-phaser-devin/agents/audio-director.md +65 -0
  655. package/plugins/lisa-phaser-devin/agents/combat-designer.md +61 -0
  656. package/plugins/lisa-phaser-devin/agents/economy-designer.md +59 -0
  657. package/plugins/lisa-phaser-devin/agents/game-designer.md +62 -0
  658. package/plugins/lisa-phaser-devin/agents/game-feel-specialist.md +56 -0
  659. package/plugins/lisa-phaser-devin/agents/level-designer.md +59 -0
  660. package/plugins/lisa-phaser-devin/agents/localization-manager.md +61 -0
  661. package/plugins/lisa-phaser-devin/agents/marketing-strategist.md +59 -0
  662. package/plugins/lisa-phaser-devin/agents/monetization-designer.md +59 -0
  663. package/plugins/lisa-phaser-devin/agents/narrative-designer.md +56 -0
  664. package/plugins/lisa-phaser-devin/agents/onboarding-advocate.md +59 -0
  665. package/plugins/lisa-phaser-devin/agents/player-advocate.md +57 -0
  666. package/plugins/lisa-phaser-devin/agents/producer.md +61 -0
  667. package/plugins/lisa-phaser-devin/agents/product-analyst.md +62 -0
  668. package/plugins/lisa-phaser-devin/agents/publisher.md +59 -0
  669. package/plugins/lisa-phaser-devin/agents/qa-playtester.md +57 -0
  670. package/plugins/lisa-phaser-devin/agents/target-player.md +52 -0
  671. package/plugins/lisa-phaser-devin/agents/ux-ui-designer.md +60 -0
  672. package/plugins/lisa-phaser-devin/hooks/devin-tool-name-shim.sh +30 -0
  673. package/plugins/lisa-phaser-devin/hooks/inject-rules.sh +27 -0
  674. package/plugins/lisa-phaser-devin/hooks.json +14 -0
  675. package/plugins/lisa-phaser-devin/rules/eager/phaser.md +44 -0
  676. package/plugins/lisa-phaser-devin/rules/reference/phaser.md +184 -0
  677. package/plugins/lisa-phaser-devin/skills/phaser-accessibility/SKILL.md +135 -0
  678. package/plugins/lisa-phaser-devin/skills/phaser-asset-pipeline/SKILL.md +222 -0
  679. package/plugins/lisa-phaser-devin/skills/phaser-asset-sourcing/SKILL.md +151 -0
  680. package/plugins/lisa-phaser-devin/skills/phaser-build-deploy/SKILL.md +153 -0
  681. package/plugins/lisa-phaser-devin/skills/phaser-i18n/SKILL.md +122 -0
  682. package/plugins/lisa-phaser-devin/skills/phaser-project-structure/SKILL.md +89 -0
  683. package/plugins/lisa-phaser-devin/skills/phaser-services/SKILL.md +208 -0
  684. package/plugins/lisa-phaser-devin/skills/phaser-testing/SKILL.md +192 -0
  685. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  686. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  687. package/plugins/lisa-rails-agy/plugin.json +1 -1
  688. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  689. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  690. package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +11 -0
  691. package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +8 -0
  692. package/plugins/lisa-rails-devin/agents/ops-specialist.md +226 -0
  693. package/plugins/lisa-rails-devin/hooks/devin-tool-name-shim.sh +30 -0
  694. package/plugins/lisa-rails-devin/hooks/inject-rules.sh +40 -0
  695. package/plugins/lisa-rails-devin/hooks/lisa-edit-gate.sh +152 -0
  696. package/plugins/lisa-rails-devin/hooks/rubocop-on-edit.sh +101 -0
  697. package/plugins/lisa-rails-devin/hooks/sg-scan-on-edit.sh +97 -0
  698. package/plugins/lisa-rails-devin/hooks.json +29 -0
  699. package/plugins/lisa-rails-devin/rules/eager/rails-conventions.md +23 -0
  700. package/plugins/lisa-rails-devin/rules/reference/rails-conventions.md +176 -0
  701. package/plugins/lisa-rails-devin/skills/action-controller-best-practices/SKILL.md +374 -0
  702. package/plugins/lisa-rails-devin/skills/action-view-best-practices/SKILL.md +335 -0
  703. package/plugins/lisa-rails-devin/skills/active-record-model-best-practices/SKILL.md +166 -0
  704. package/plugins/lisa-rails-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
  705. package/plugins/lisa-rails-devin/skills/fix-linter-error/SKILL.md +45 -0
  706. package/plugins/lisa-rails-devin/skills/improve-code-complexity/SKILL.md +51 -0
  707. package/plugins/lisa-rails-devin/skills/improve-max-lines/SKILL.md +46 -0
  708. package/plugins/lisa-rails-devin/skills/improve-max-lines-per-function/SKILL.md +46 -0
  709. package/plugins/lisa-rails-devin/skills/improve-test-coverage/SKILL.md +48 -0
  710. package/plugins/lisa-rails-devin/skills/jira-add-journey/SKILL.md +79 -0
  711. package/plugins/lisa-rails-devin/skills/jira-create/SKILL.md +100 -0
  712. package/plugins/lisa-rails-devin/skills/jira-evidence/SKILL.md +76 -0
  713. package/plugins/lisa-rails-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
  714. package/plugins/lisa-rails-devin/skills/jira-journey/SKILL.md +65 -0
  715. package/plugins/lisa-rails-devin/skills/jira-verify/SKILL.md +26 -0
  716. package/plugins/lisa-rails-devin/skills/ops-check-logs/SKILL.md +191 -0
  717. package/plugins/lisa-rails-devin/skills/ops-deploy/SKILL.md +153 -0
  718. package/plugins/lisa-rails-devin/skills/ops-run-local/SKILL.md +169 -0
  719. package/plugins/lisa-rails-devin/skills/ops-verify-jobs/SKILL.md +157 -0
  720. package/plugins/lisa-rails-devin/skills/ops-verify-telemetry/SKILL.md +226 -0
  721. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  722. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  723. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  724. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  725. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  726. package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +11 -0
  727. package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +8 -0
  728. package/plugins/lisa-typescript-devin/hooks/block-suppress-directives.sh +141 -0
  729. package/plugins/lisa-typescript-devin/hooks/devin-tool-name-shim.sh +30 -0
  730. package/plugins/lisa-typescript-devin/hooks/format-on-edit.sh +102 -0
  731. package/plugins/lisa-typescript-devin/hooks/lint-on-edit.sh +117 -0
  732. package/plugins/lisa-typescript-devin/hooks/lisa-edit-gate.sh +152 -0
  733. package/plugins/lisa-typescript-devin/hooks/sg-scan-on-edit.sh +94 -0
  734. package/plugins/lisa-typescript-devin/hooks.json +34 -0
  735. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  736. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  737. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  738. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  739. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  740. package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +8 -0
  741. package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +8 -0
  742. package/plugins/lisa-wiki-devin/ci/lisa-wiki-validate.yml +33 -0
  743. package/plugins/lisa-wiki-devin/schema/lisa-wiki-config.schema.json +162 -0
  744. package/plugins/lisa-wiki-devin/schema/wiki-structure.schema.json +51 -0
  745. package/plugins/lisa-wiki-devin/scripts/_wiki-lib.mjs +204 -0
  746. package/plugins/lisa-wiki-devin/scripts/diff-guard.mjs +117 -0
  747. package/plugins/lisa-wiki-devin/scripts/ensure-gitignore.mjs +131 -0
  748. package/plugins/lisa-wiki-devin/scripts/ensure-wiki.mjs +268 -0
  749. package/plugins/lisa-wiki-devin/scripts/ingest-git.mjs +198 -0
  750. package/plugins/lisa-wiki-devin/scripts/ingest-memory.mjs +135 -0
  751. package/plugins/lisa-wiki-devin/scripts/ingest-roles.mjs +95 -0
  752. package/plugins/lisa-wiki-devin/scripts/ingest_slack_channel.py +361 -0
  753. package/plugins/lisa-wiki-devin/scripts/lib/bounded-child.mjs +159 -0
  754. package/plugins/lisa-wiki-devin/scripts/lint-wiki.mjs +513 -0
  755. package/plugins/lisa-wiki-devin/scripts/mcp-doctor.mjs +72 -0
  756. package/plugins/lisa-wiki-devin/scripts/render-contract.mjs +107 -0
  757. package/plugins/lisa-wiki-devin/scripts/rewrite-refs.mjs +144 -0
  758. package/plugins/lisa-wiki-devin/scripts/slack_oauth_user.py +179 -0
  759. package/plugins/lisa-wiki-devin/scripts/validate-config.mjs +321 -0
  760. package/plugins/lisa-wiki-devin/scripts/verify-migration.mjs +203 -0
  761. package/plugins/lisa-wiki-devin/scripts/verify-wiki-safety.mjs +126 -0
  762. package/plugins/lisa-wiki-devin/scripts/wiki-safety.mjs +378 -0
  763. package/plugins/lisa-wiki-devin/scripts/wiki-status.mjs +345 -0
  764. package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
  765. package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-role/SKILL.md +35 -0
  766. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
  767. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
  768. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-git/SKILL.md +25 -0
  769. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
  770. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
  771. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
  772. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
  773. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
  774. package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-web/SKILL.md +23 -0
  775. package/plugins/lisa-wiki-devin/skills/lisa-wiki-doctor/SKILL.md +47 -0
  776. package/plugins/lisa-wiki-devin/skills/lisa-wiki-ingest/SKILL.md +106 -0
  777. package/plugins/lisa-wiki-devin/skills/lisa-wiki-lint/SKILL.md +32 -0
  778. package/plugins/lisa-wiki-devin/skills/lisa-wiki-migrate/SKILL.md +43 -0
  779. package/plugins/lisa-wiki-devin/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
  780. package/plugins/lisa-wiki-devin/skills/lisa-wiki-query/SKILL.md +40 -0
  781. package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup/SKILL.md +75 -0
  782. package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup-automations/SKILL.md +83 -0
  783. package/plugins/lisa-wiki-devin/skills/lisa-wiki-status/SKILL.md +47 -0
  784. package/plugins/lisa-wiki-devin/skills/lisa-wiki-tear-down-automations/SKILL.md +34 -0
  785. package/plugins/lisa-wiki-devin/skills/lisa-wiki-usage/SKILL.md +50 -0
  786. package/plugins/lisa-wiki-devin/templates/agents/role-agent.claude.md +16 -0
  787. package/plugins/lisa-wiki-devin/templates/agents/role-agent.codex.toml +15 -0
  788. package/plugins/lisa-wiki-devin/templates/index.md +17 -0
  789. package/plugins/lisa-wiki-devin/templates/llm-wiki-contract.md +72 -0
  790. package/plugins/lisa-wiki-devin/templates/log.md +8 -0
  791. package/plugins/lisa-wiki-devin/templates/page-types/architecture.md +18 -0
  792. package/plugins/lisa-wiki-devin/templates/page-types/concept.md +18 -0
  793. package/plugins/lisa-wiki-devin/templates/page-types/decision.md +18 -0
  794. package/plugins/lisa-wiki-devin/templates/page-types/entity.md +19 -0
  795. package/plugins/lisa-wiki-devin/templates/page-types/open-question.md +18 -0
  796. package/plugins/lisa-wiki-devin/templates/page-types/playbook.md +18 -0
  797. package/plugins/lisa-wiki-devin/templates/page-types/project.md +19 -0
  798. package/plugins/lisa-wiki-devin/templates/page-types/requirement.md +19 -0
  799. package/plugins/lisa-wiki-devin/templates/page-types/staff.md +26 -0
  800. package/plugins/lisa-wiki-devin/templates/start-here.md +24 -0
  801. package/plugins/lisa-wiki-devin/templates/state-readme.md +20 -0
  802. package/plugins/lisa-wiki-devin/templates/wrapper-gitignore.txt +19 -0
  803. package/scripts/build-plugins.sh +2 -2
  804. package/scripts/deployed-guard-advice.mjs +1 -0
  805. package/scripts/generate-devin-plugin-artifacts.mjs +263 -0
  806. package/scripts/lib/hook-registration-audit.mjs +3 -1
  807. package/scripts/lib/per-agent-hook-filter.mjs +174 -5
  808. package/scripts/lib/plugin-rule-citations.mjs +2 -2
  809. package/scripts/two-channel-couplings.json +1 -1
@@ -0,0 +1,3275 @@
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: a safety net that blocks destructive shell commands
6
+ # before they run. Lisa-native reimplementation of the upstream
7
+ # `safety-net@cc-marketplace` plugin's PreToolUse Bash-guard (parity work,
8
+ # issues #1059 and #1960). It does NOT port upstream code — it re-expresses the
9
+ # behavior in Lisa's hook conventions, modeled on block-no-verify.sh.
10
+ #
11
+ # It reads the hook stdin JSON, inspects the proposed Bash command, and EXITS
12
+ # NON-ZERO (2) to BLOCK when a known-destructive pattern matches:
13
+ # - `rm -rf` of a root / home / wildcard path (quote-aware boundaries, so
14
+ # `bash -c "rm -rf /"` and interpreter one-liners are caught too; the rm
15
+ # guards also match path-prefixed invocations like `/bin/rm` and `./rm`)
16
+ # - `rm -rf` of the cwd (`.`), a `..`-traversal path, a `~/`-anchored path,
17
+ # an absolute path outside the project (with a /tmp, /var/tmp, $TMPDIR
18
+ # allowance, each also matched by its symlink-resolved spelling so /tmp and
19
+ # /private/tmp are ONE location), a `$VAR` target that neither is $TMPDIR
20
+ # nor resolves through an unambiguous in-command assignment, or ANY
21
+ # recursive forced delete while cwd is $HOME
22
+ # - force-pushing a protected branch (main/master/production/prod/release) —
23
+ # feature-branch force-push stays allowed (sanctioned rebase workflow;
24
+ # deliberate divergence from upstream's any-branch block). Every git guard
25
+ # sees through leading git GLOBAL options (`-C <path>`, `-c <k>=<v>`,
26
+ # `--git-dir[=…]`, `--no-pager`, …), so `git -C /path <destructive>` cannot
27
+ # dodge the subcommand anchor
28
+ # - `git reset --hard` / `--merge` while the working tree is dirty. Deliberate
29
+ # divergence: upstream blocks unconditionally; Lisa allows clean-tree resets.
30
+ # Residual risk (documented, accepted): the dirty check runs in the hook's
31
+ # cwd at hook time, so a `cd elsewhere && git reset --hard` evades it.
32
+ # - `git read-tree` with an update flag AND `--reset` while the working tree
33
+ # is dirty (issue #3978): the plumbing spelling of the same discard, on the
34
+ # same condition, printing the same remedy. Index-only `read-tree`, and
35
+ # `read-tree -m -u` without `--reset` (which git itself refuses rather than
36
+ # clobber a modified file), stay allowed. Lisa-only guard.
37
+ # - `git rebase --abort`/`--quit` while the in-progress rebase holds
38
+ # human-made conflict resolutions (AUTO_MERGE discriminator; issue #1956).
39
+ # Clean or untouched rebase state stays abortable; the apply backend and a
40
+ # missing AUTO_MERGE ref fail closed. Deliberate divergence: upstream
41
+ # blocks `rebase --abort` unconditionally, which strands agents mid-rebase.
42
+ # - `git checkout` discards (`--`, `-f/--force`, `--pathspec-from-file`,
43
+ # bare `.` — the bare-`.` block exceeds upstream)
44
+ # - `git switch --discard-changes` / `-f/--force`
45
+ # - `git restore` touching the worktree (allowed only with `--staged` and
46
+ # without `--worktree`)
47
+ # - `git stash drop` / `git stash clear`
48
+ # - `git clean` with force and no dry-run (`-n`/`--dry-run` anywhere wins)
49
+ # - `git branch -D` (or `-d` + `-f` in any spelling)
50
+ # - `git tag -d`, `git reflog delete`, `git worktree remove --force`
51
+ # - `find ... -delete`, `find ... -exec rm -rf`, `xargs ... rm -rf`
52
+ # - disk destroyers: `dd of=/dev/...`, `mkfs ... /dev/...`, `shred`
53
+ # - dropping or truncating a database/schema/table (Lisa-only guard)
54
+ # Otherwise it exits 0 and the command proceeds. Malformed hook input fails
55
+ # CLOSED: any parse error exits 2 (a non-2 exit would be a non-blocking hook
56
+ # error in Claude Code, silently failing open).
57
+ #
58
+ # Narrowed false-positive class (issue #3106): the rm TARGET WALK is now scoped
59
+ # to the quoting region that contains the rm, so a recursive delete quoted as
60
+ # PROSE no longer turns every later token on the line into a deletion target. A
61
+ # variable in an unrelated argument — a `gh issue create --body-file "$G/x"`, a
62
+ # `git commit -F "$MSG"` — is no longer read as the thing being deleted. An rm
63
+ # outside quotes, or first inside its quoted run (`bash -c "rm -rf /"`,
64
+ # `echo "$(rm -rf /)"`), still hands the whole statement to the walk. See
65
+ # rm_scan_scope below for the rule and its accepted residual.
66
+ #
67
+ # Known accepted false-positive class: this is a text scan, not a shell engine,
68
+ # so display commands quoting a destructive string (`echo "docs about rm -rf /"`)
69
+ # can match — including one whose target sits in the SAME quoted run as the
70
+ # prose, which the #3106 scoping deliberately does not reach. Upstream exempts
71
+ # those through an engine-only DISPLAY_COMMANDS list that a grep hook cannot
72
+ # replicate. WHAT TO DO WHEN YOU HIT IT (issue #3191): do not reword the prose.
73
+ # When the destructive text is a commit message, an issue body or a results
74
+ # table, the string IS the deliverable and there is no narrower spelling that
75
+ # still says what it must say. Write the text to a file with the Write tool and
76
+ # pass it BY PATH — `gh issue comment --body-file <file>`, `git commit -F
77
+ # <file>` — because these guards scan the COMMAND and never the file. block()
78
+ # prints this at the wall so the operator is told rather than left to find it.
79
+ # The same text-scan limit means the rm guard treats
80
+ # a substitution-wrapped catastrophic delete as verdict-neutral (issue #1982): an
81
+ # executable `echo "$(rm -rf /)"` is blocked, but so are inert twins the shell
82
+ # would never expand — a single-quoted `echo '$(rm -rf /)'` or an escaped
83
+ # `echo "\$(rm -rf /)"` — because the scan has no quote-context awareness. That
84
+ # over-block stays inside this accepted class. Workaround: the write-to-a-file
85
+ # and pass-by-path form above, or quote-break the string,
86
+ # or use the gh-writer heredoc form, whose payload is stripped before the guards
87
+ # run — spelled with a QUOTE-PAIR delimiter (`<<'EOF'`). That exact spelling is
88
+ # what the SAFE path recognises; `<<"EOF"` and `<<\EOF` do not reach it, and
89
+ # since #1993 a `gh … <<\EOF` writer fails closed instead (see parse_marker).
90
+ #
91
+ # Known accepted BYPASS class (the symmetric error direction, issue #1993): the
92
+ # heredoc classifier in parity-safety-net-heredoc.py re-implements bash's
93
+ # here-doc lexing in Python, and any divergence from real bash is a shape it
94
+ # mis-classifies. Five rounds on #1958 and three more on #1993 (R5a swallowed
95
+ # terminator, R5b body-apostrophe quote-state poisoning, R5c backslash-quoted
96
+ # delimiter) established that enumerating those divergences is an open-ended
97
+ # arms race, so the class is CLOSED as accepted rather than chased further. This
98
+ # is survivable because of where a mis-classification lands: ONLY the narrow
99
+ # gh-writer SAFE path strips payload text, and every other classifier exit hands
100
+ # the RAW command to the guards below. A missed here-doc therefore degrades to
101
+ # the content guards; it does not bypass them. Since #1982 taught those guards to
102
+ # see through substitution wrappers, every destructive class this net exists to
103
+ # stop is blocked on the raw text regardless of how the here-doc was read. The
104
+ # net catches ACCIDENTAL catastrophic commands; it is not a wall against an
105
+ # adversary who knows bash's lexer, and it was never the only thing standing
106
+ # between an agent and arbitrary execution.
107
+ #
108
+ # Operators extend the built-in rules with a project-local rule file — one
109
+ # extended-regex (ERE) per line, blank lines and `#` comments ignored — managed
110
+ # by the parity-safety-net-rules skill. Default location (overridable via
111
+ # SAFETY_NET_RULES_FILE):
112
+ # ${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt
113
+ set -euo pipefail
114
+
115
+ # Fail CLOSED on any unexpected error (malformed JSON, missing jq, …): exit 2 so
116
+ # the tool call is denied instead of surfacing a non-blocking hook warning.
117
+ # Deliberately NOT `set -E`: errtrace would propagate this trap into command
118
+ # substitutions (e.g. the heredoc parser call, whose non-zero exit codes are
119
+ # meaningful protocol) and rewrite their status to 2 before it can be read.
120
+ trap 'printf "%s\n" "Blocked by safety-net: hook failed while parsing its input; denying fail-closed." >&2; exit 2' ERR
121
+
122
+ input="$(cat)"
123
+
124
+ # Evaluate once per tool call when this guard is registered on both channels.
125
+ #
126
+ # Lisa reaches an agent through the repository dispatcher AND the plugin
127
+ # manifest, and where both are live the harness runs this guard twice for one
128
+ # tool call. `guard-dedupe.bash` short-circuits the second run ONLY when a
129
+ # byte-identical copy already ALLOWED this exact payload on this exact tool
130
+ # call; a differing vintage, a refusal, and a host with one channel all
131
+ # evaluate exactly as before. Nothing is de-registered by it
132
+ # (CodySwannGT/lisa#3814).
133
+ #
134
+ # Absent library means no dedupe, which is the pre-existing behaviour, so an
135
+ # older channel copy that predates it is unaffected.
136
+ lisa_guard_hook_dir="${BASH_SOURCE[0]%/*}"
137
+ lisa_guard_dedupe_lib="$lisa_guard_hook_dir/guard-dedupe.bash"
138
+ if [ -r "$lisa_guard_dedupe_lib" ]; then
139
+ # shellcheck source=guard-dedupe.bash
140
+ . "$lisa_guard_dedupe_lib"
141
+ trap 'lisa_guard_dedupe_record $?' EXIT
142
+ lisa_guard_dedupe parity-safety-net "$input" \
143
+ "$lisa_guard_hook_dir/parity-safety-net-heredoc.py"
144
+ fi
145
+
146
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
147
+ if [ "$tool_name" != "Bash" ]; then
148
+ exit 0
149
+ fi
150
+
151
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
152
+ if [ -z "$command_str" ]; then
153
+ exit 0
154
+ fi
155
+
156
+ # Guidance printed under an ordinary destructive-guard refusal.
157
+ #
158
+ # It teaches the remedy at the wall, the way block_heredoc() does (gardener
159
+ # #1789, extended by issue #3191). It used to end in "narrow the command so it
160
+ # no longer matches the guard", which is advice the accepted prose class CANNOT
161
+ # follow: when the destructive text is a commit message, an issue body or a
162
+ # results table, the string IS the deliverable and there is no narrower spelling
163
+ # that still says what it must say. An operator acts on a printed remedy, so a
164
+ # remedy that cannot be performed is worse than none — it spends the detour and
165
+ # teaches that the refusal was a formality. The remedy that does work is the
166
+ # sibling heredoc one, one surface over: write the text with the Write tool,
167
+ # then pass it BY PATH. The guards scan the COMMAND and never the file, so the
168
+ # path form carries the text through intact.
169
+ #
170
+ # Both branches are printed on every ordinary refusal, deliberately: this guard
171
+ # is a text scan and CANNOT tell which one applies, so naming both and letting
172
+ # the reader pick is the only honest shape. Deciding for them is what the
173
+ # engine-only DISPLAY_COMMANDS list does, and the header explains why a grep
174
+ # hook cannot have one.
175
+ DESTRUCTIVE_GUIDANCE="$(
176
+ cat <<'EOF'
177
+ This command matched a destructive-operation guard. The guard is a text scan,
178
+ not a shell engine, so it cannot tell a destructive command you are RUNNING
179
+ from the same text quoted as CONTENT you are writing. Take the branch that
180
+ describes yours:
181
+
182
+ - The destructive text is only QUOTED CONTENT — a commit message, an issue or
183
+ PR body, a report, a results table, a note. Do not try to reword it; the
184
+ string is the deliverable and no narrower spelling still says what it must.
185
+ Write the text to a file with the Write tool and pass it BY PATH instead:
186
+ `gh issue comment --body-file <file>`, `gh pr create --body-file <file>`,
187
+ `git commit -F <file>`, or read the file back with the Read tool. This guard
188
+ scans the command, never the file, so the path form goes through intact.
189
+
190
+ - You genuinely intend to run the destructive operation. Ask the user to
191
+ confirm, then run it manually outside the agent.
192
+ EOF
193
+ )"
194
+
195
+ # How to preserve uncommitted work before an operation that would discard it.
196
+ #
197
+ # This is appended to the reasons that refuse a discarding operation, and its
198
+ # whole point is what it does NOT say (issue #3722). The obvious advice is
199
+ # "stash it first", and on a single-checkout repository that is fine. Here it
200
+ # is the most dangerous thing an agent can be told: the stash is ONE STACK PER
201
+ # CLONE, shared by every worktree, so with concurrent agents a sibling's push
202
+ # can shift your entry and a sibling's pop can consume it. `lint-staged` pushes
203
+ # one on every commit too, so agent and tooling entries interleave.
204
+ #
205
+ # A remedy fires at the exact moment an agent is holding the work most worth
206
+ # protecting, and a guard's own output reads as authoritative — so pointing it
207
+ # at shared mutable state there is worse than the refusal it accompanies.
208
+ #
209
+ # The patch file is per-worktree: `mktemp` names it, so nothing has to invent a
210
+ # filename, and it lives in this agent's own TMPDIR where no sibling can reach
211
+ # it. Untracked files are unaffected by both refused operations, so `git diff`
212
+ # captures everything at risk. `HEAD` includes staged and unstaged changes, and
213
+ # `--binary` keeps binary edits recoverable rather than emitting a text-only
214
+ # placeholder that cannot be applied.
215
+ #
216
+ # Three corrections from issue #3696, each found by EXECUTING the text below
217
+ # rather than reading it:
218
+ #
219
+ # 1. It preserved but never RECOVERED. The text taught how to save the work and
220
+ # how to restore it later, and stopped there — so an agent that followed it
221
+ # exactly and successfully was returned to the identical refusal, because the
222
+ # tree was still dirty. A remedy that does not clear the wall it is printed
223
+ # at is a detour, and #3722 shipped only half the path. The discard branch
224
+ # below is the other half, and every command in it is one THIS GUARD ALREADY
225
+ # PERMITS: mixed `git reset`, `git diff`, and `git apply` are matched by no
226
+ # guard in this file. Nothing was loosened to make room for it — a blind
227
+ # `git reset --hard` on a dirty tree is still refused, unconditionally.
228
+ #
229
+ # That the operator must first WRITE DOWN what they are discarding is the
230
+ # feature, not a cost. It is a better acknowledgement than a marker the guard
231
+ # could look for, because a marker agents can type is a marker agents learn
232
+ # to type reflexively; this one cannot be satisfied without producing the
233
+ # artifact that makes the discard reversible.
234
+ #
235
+ # 2. It did not run as printed. `[-- <path>]` is conventional notation for an
236
+ # optional argument, but bash expands `[...]` as a glob, so pasting the line
237
+ # verbatim failed with `bash: path: No such file or directory` and wrote no
238
+ # patch at all. Guard remedies are unreviewed prose that agents execute
239
+ # literally, so the optional part is now prose and every line is runnable.
240
+ #
241
+ # 3. `mktemp` was silently a no-op. BSD/macOS mktemp substitutes only TRAILING
242
+ # `X`s, so the `.patch` suffix meant the template came back as the literal
243
+ # string `lisa-preserve-XXXXXX.patch` — one FIXED filename, where successive
244
+ # preserves overwrite each other, in the exact place the comment above claims
245
+ # uniqueness. The suffix is gone rather than spelled portably: GNU mktemp
246
+ # offers `--suffix` and BSD does not, and a patch file does not need an
247
+ # extension. The regression test asserts the produced name DIFFERS from the
248
+ # template, because asserting that the word `mktemp` appears is what let this
249
+ # ship.
250
+ #
251
+ # NO APOSTROPHES IN THE BODY BELOW. It is a quoted heredoc nested inside a `$( )`
252
+ # command substitution, and bash tracks quote state through the body when it
253
+ # scans for the closing paren — so a single `'` (in "the agent's TMPDIR", say)
254
+ # poisons the parse from there to the next quote and the whole file stops being
255
+ # valid shell, with the reported error landing hundreds of lines away in
256
+ # untouched code. Every guidance body in this file avoids them for this reason.
257
+ # Write "of this session" rather than a possessive.
258
+ PRESERVE_GUIDANCE="$(
259
+ cat <<'EOF'
260
+ Preserve the work first, per-worktree. `mktemp` names the file, so nothing has
261
+ to invent one, and it lands in the TMPDIR of this session, where no sibling
262
+ agent can reach it:
263
+
264
+ patch="$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX")"
265
+ git diff --binary HEAD > "$patch"
266
+
267
+ Add `-- <path>` before the `>` to scope the capture to one path. Restore it
268
+ later with `git apply "$patch"`.
269
+
270
+ If the work is NOT yours -- a concurrent agent dirtied this worktree and your
271
+ judgement is "none of this is mine" -- that same capture is how you return the
272
+ tree to HEAD. This block is self-contained; paste it as it stands:
273
+
274
+ patch="$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX")"
275
+ git reset
276
+ git diff --binary HEAD > "$patch"
277
+ git apply -R "$patch"
278
+
279
+ It names the file itself rather than inheriting one from the block above,
280
+ because the two blocks are alternatives and only one of them gets run. A remedy
281
+ printed at the moment an agent is blocked is followed literally; one that
282
+ depends on a variable the reader never set fails on its first line, and the
283
+ guard that printed it stops being believed.
284
+
285
+ `git reset` clears staged and unmerged entries first, so the capture sees the
286
+ whole tree as one diff; reversing it leaves the tree at HEAD with "$patch" as
287
+ the record of what you discarded. Untracked files survive, exactly as they
288
+ survive `git reset --hard`.
289
+
290
+ Do NOT reach for `git stash`. One stash stack is shared by every worktree of a
291
+ clone, so a concurrently running agent can consume the entry you just pushed.
292
+ EOF
293
+ )"
294
+
295
+ # block() prints the reason to stderr (surfaced to the model) and exits 2 so the
296
+ # Bash tool call is denied. $1 = human-readable reason for the block.
297
+ #
298
+ # $2 = optional guidance REPLACING $DESTRUCTIVE_GUIDANCE. block_heredoc() passes
299
+ # its own, because the two remedies are mutually exclusive by construction
300
+ # (issue #1958 F6): a heredoc payload IS executable shell, so the quoted-content
301
+ # branch is exactly the misdirection that finding removed, and the file-based
302
+ # execution advice is exactly what an ordinary prose refusal must not be given.
303
+ block() {
304
+ cat >&2 <<EOF
305
+ Blocked by safety-net: $1
306
+
307
+ ${2:-$DESTRUCTIVE_GUIDANCE}
308
+ EOF
309
+ exit 2
310
+ }
311
+
312
+ # grep answers three ways, not two: 0 it matched, 1 it did not match, and >=2 "I
313
+ # could not do this" — it was never exec'd, the pattern was rejected, the input
314
+ # could not be read. Every guard in this file used to reach its verdict through
315
+ # a bare `| grep -q` in a condition, where >=2 is indistinguishable from 1. So
316
+ # "the scan could not run" was read as "this command is safe", and the safety
317
+ # net stopped guarding without ever saying so.
318
+ #
319
+ # Measured rather than feared (CodySwannGT/lisa#3054). With a `grep` that exits
320
+ # 2 first on PATH, this hook ALLOWED `rm -rf /`, `git push --force origin main`,
321
+ # `dd of=/dev/disk0` and `git branch -D main` — every built-in guard, silently,
322
+ # exit 0. And the condition that gets there is live rather than theoretical: on
323
+ # the machine this was found on, 7,679 orphaned processes were outstanding
324
+ # against a `kern.maxprocperuid` of 10,666, putting a fork() that returns EAGAIN
325
+ # a few hundred spawns away.
326
+ #
327
+ # The reasoning is ALREADY in this file, at the bottom, for the project custom
328
+ # rules: a malformed ERE used to silently disable a rule the project believed it
329
+ # had, and that was fixed by reading grep's STATUS instead of its truthiness. It
330
+ # was never extended to the built-in guards. This is that extension.
331
+ #
332
+ # Fails CLOSED, which is this hook's stated contract everywhere else, and NAMES
333
+ # the environment failure — a guard that reports "safe" when it means "I could
334
+ # not tell" is the exact shape #3054 exists to remove.
335
+ #
336
+ # $1 = grep flags (-Ei / -E), $2 = text to scan, $3 = pattern.
337
+ scan() {
338
+ local flags="$1" text="$2" pattern="$3" status=0
339
+ printf '%s' "$text" | grep "$flags" -q -- "$pattern" || status=$?
340
+ case "$status" in
341
+ 0) return 0 ;;
342
+ 1) return 1 ;;
343
+ *) scan_failed "$status" ;;
344
+ esac
345
+ }
346
+
347
+ # Deny, and say what actually happened. Deliberately NOT block(): block() tells
348
+ # the reader their command matched a destructive pattern, which is a claim about
349
+ # the COMMAND. This is a claim about the MACHINE, and printing the first when
350
+ # the second is true is precisely what sends an operator to re-run instead of to
351
+ # look — the reporting failure #3054 is about.
352
+ scan_failed() {
353
+ cat >&2 <<EOF
354
+ Blocked by safety-net: a guard's pattern scan FAILED TO RUN (grep exited $1); denying fail-closed.
355
+
356
+ This is an ENVIRONMENT failure, not a property of your command. The guard could
357
+ not be evaluated at all, so the safety net denied rather than guessed — a scan
358
+ that cannot answer must never read as "safe".
359
+
360
+ The usual cause is a machine at its per-user process limit, where fork() starts
361
+ returning EAGAIN. Compare:
362
+
363
+ ps -u \$(id -u) | wc -l
364
+ sysctl -n kern.maxprocperuid # Linux: ulimit -u
365
+
366
+ Re-running is reasonable. If it repeats, the machine cannot fork and no number
367
+ of re-runs will change that.
368
+ EOF
369
+ exit 2
370
+ }
371
+
372
+ # Heredoc payloads are data only for a deliberately narrow set of GitHub CLI
373
+ # write commands. A companion parser proves that shape before removing payload
374
+ # text from the destructive-command scans below. Unknown executable heredocs
375
+ # remain visible to every built-in and custom rule. Ambiguous or malformed
376
+ # heredocs fail closed instead of guessing which text the shell would execute.
377
+ #
378
+ # block_heredoc() teaches the remediation the moment the wall is hit: a bare
379
+ # denial strands the agent with no path forward (gardener #1789). The remedy
380
+ # depends on the command shape (issue #1958): `git commit -m "$(cat <<EOF …)"`
381
+ # attempts get the commit -F text; every other heredoc denial gets the
382
+ # file-based execution guidance instead — the commit text is misleading there.
383
+ # Both are passed as block()'s SECOND argument, which REPLACES the ordinary
384
+ # destructive guidance rather than appending to it (issue #3191): that guidance
385
+ # offers a quoted-content branch, and a heredoc payload is executable shell, so
386
+ # printing it here would reintroduce the misdirection #1958 F6 removed.
387
+ # The git-commit detection inlines the GIT_GLOBAL_OPTS shape (defined later in
388
+ # this file, after the heredoc dispatch runs) so `git -C <path> commit` and
389
+ # `git -c k=v commit` spellings are still recognized.
390
+ block_heredoc() {
391
+ if scan -E "$command_str" \
392
+ '(^|[^[:alnum:]_-])git[[:space:]]+(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
393
+ block "$1" "Heredoc commit invocations are blocked (the payload is executable shell).
394
+ Fix: write the commit message to a file and run \`git commit -F <file>\`.
395
+ Every commit must also carry a Co-authored-by trailer for a supported agent
396
+ (Claude/Codex/Cursor/OpenCode/Antigravity/Copilot). OpenCode, and any agent
397
+ outside that fleet (e.g. Devin), must also carry AI-Agent/AI-Model/AI-Effort
398
+ trailers — the commit-msg hook enforces this."
399
+ fi
400
+ block "$1" "Heredoc payloads are blocked here (the payload is executable shell).
401
+ Fix: write the payload to a file with the Write tool, then run the file
402
+ (\`bash <file>\`). These guards READ the shell script an invocation runs, so a
403
+ destructive command inside that file is refused exactly as the inline form is:
404
+ the file is a way to get the payload out of a heredoc, never a way past the
405
+ guards."
406
+ }
407
+
408
+ command_for_guards="$command_str"
409
+ case "$command_str" in
410
+ *'<<'*)
411
+ hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
412
+ heredoc_parser="$hook_dir/parity-safety-net-heredoc.py"
413
+ if ! command -v python3 >/dev/null 2>&1 || [ ! -r "$heredoc_parser" ]; then
414
+ block_heredoc "cannot safely classify heredoc command because its parser runtime is unavailable"
415
+ fi
416
+ if ! printf '%s\n' "$command_str" | /bin/bash -n >/dev/null 2>&1; then
417
+ block_heredoc "malformed heredoc command failed shell syntax validation"
418
+ fi
419
+
420
+ parser_status=0
421
+ if parser_output="$(printf '%s' "$command_str" | python3 "$heredoc_parser" 2>/dev/null)"; then
422
+ parser_status=0
423
+ else
424
+ parser_status=$?
425
+ fi
426
+
427
+ case "$parser_status" in
428
+ 0) command_for_guards="$parser_output" ;;
429
+ 10) command_for_guards="$command_str" ;;
430
+ 20) block_heredoc "malformed or ambiguous heredoc command cannot be safely classified" ;;
431
+ *) block_heredoc "heredoc parser failed; command was denied fail-closed" ;;
432
+ esac
433
+ ;;
434
+ esac
435
+
436
+ custom_command_for_guards="$command_for_guards"
437
+ # A single literal printer writes data, not shell commands. Keep its actual
438
+ # redirection in the scan; unsupported syntax and unavailable parsing retain
439
+ # the original text. Chained commands and shell substitutions never qualify.
440
+ if [[ "$command_for_guards" =~ ^[[:blank:]]*(printf|echo)[[:blank:]] ]] \
441
+ && command -v python3 >/dev/null 2>&1; then
442
+ if display_projection="$(printf '%s' "$command_for_guards" | python3 \
443
+ "$lisa_guard_hook_dir/parity-safety-net-heredoc.py" --literal-display 2>/dev/null)"; then
444
+ command_for_guards="$display_projection"
445
+ fi
446
+ fi
447
+
448
+ # ─── Follow execution into the file a command RUNS (issue #3612) ─────────────
449
+ #
450
+ # Every guard below classifies SHELL COMMAND TEXT, and until this block existed
451
+ # the only text they ever saw was the command string. A destructive line
452
+ # written into a file and then executed reached them as an interpreter name and
453
+ # a path, and was ALLOWED — silently, with no output at all — while the same
454
+ # line typed inline was refused. Nine spellings were measured against 4.31.1 and
455
+ # all nine allowed: `bash <file>`, `sh <file>`, `zsh <file>`, `env bash <file>`,
456
+ # `source <file>`, `. <file>`, a shebang script invoked directly,
457
+ # `bash -c 'bash <file>'`, and `eval "$(cat <file>)"`. Two more found while
458
+ # fixing it — `bash < <file>` and `cat <file> | bash` — allowed as well.
459
+ #
460
+ # Three more found by review before this shipped, all one shape: a WRAPPER'S OWN
461
+ # OPERAND closing the command position before the program is reached, so
462
+ # `nice -n 5 bash <file>`, `sudo -u nobody bash <file>` and `timeout 5 bash
463
+ # <file>` walked past the invocation. See the wrapper-prefix arms below.
464
+ #
465
+ # A guard that fails CLOSED spends time; this one failed OPEN on destructive
466
+ # actions and reported nothing, so an operator who installed it to gate `rm -rf`
467
+ # outside the project got no signal that the check had not run.
468
+ #
469
+ # WHY IT IS THE DEFAULT PATH rather than an edge case: block_heredoc() above
470
+ # refuses heredocs with an instruction to write the payload to a file and
471
+ # execute that file. The tooling therefore routed agents onto exactly the shape
472
+ # the destructive scan could not see. Nobody had to evade anything. That remedy
473
+ # is reworded above, and this block is the half that makes the reworded remedy
474
+ # true.
475
+ #
476
+ # THE RULE: the blind spot is precisely FILE CONTENTS. Every other part of a
477
+ # command — including the inner command of a substitution — is text these guards
478
+ # already read. So resolve the file an invocation actually RUNS, read it under a
479
+ # size cap, and hand it to the guards as further command text.
480
+ #
481
+ # WHAT THIS DELIBERATELY DOES NOT DO — the known-wrong fix, already made once in
482
+ # this family. #3484 was fixed by teaching a sibling guard to read the contents
483
+ # of any file a command NAMES, and that produced #3604: the guard now opens any
484
+ # readable file named by any argument and attributes its contents to the
485
+ # command, including a path quoted as prose inside a fenced code block in a
486
+ # `--body-file` markdown. A path is followed here ONLY when the command EXECUTES
487
+ # it. `grep -n x <file>`, `gh pr create --body-file <file>` and
488
+ # `git commit -F <file>` name a file as DATA — and $DESTRUCTIVE_GUIDANCE above
489
+ # tells operators to use exactly those forms when the destructive text is a
490
+ # deliverable, so reading them would break the one remedy that works.
491
+ #
492
+ # FAIL CLOSED ON WHAT IT CANNOT FOLLOW. At the top level a computed target
493
+ # (`bash "$SCRIPT"`), a target that does not exist or cannot be read, a file
494
+ # past the size cap, and an operand that is not a path at all (`find … -exec
495
+ # bash {} \;`) are each REFUSED with a reason. Silence on an unclassifiable
496
+ # destructive command is the bug being fixed, and a truncated scan would report
497
+ # a confident ALLOW about text it never read.
498
+ #
499
+ # NOT FOLLOWED, deliberately. Each one is text these guards were never able to
500
+ # classify in the first place, so failing closed on it would spend the operator
501
+ # a refusal it cannot buy anything with:
502
+ # - Non-shell interpreters (`python3 <file>`, `node <file>`). These patterns
503
+ # are shell syntax; running them over a Python or JS file buys
504
+ # mis-attribution, not coverage. The heredoc remedy no longer names one.
505
+ # - A payload piped from a program's OUTPUT (`curl … | bash`). There is no
506
+ # file to read, and the producing command's own text is already scanned.
507
+ # - An indirection nested inside an ALREADY-FOLLOWED file whose target is
508
+ # computed — `source "$(dirname "$0")/lib.sh"` is the universal shell idiom,
509
+ # and failing closed on it would refuse most real scripts. Top-level
510
+ # indirection is authored by the agent in the command it just wrote, which
511
+ # is where a refusal is both actionable and rare.
512
+ # Each is a fail-open the header's ACCEPTED-BYPASS reasoning already covers: the
513
+ # net catches ACCIDENTAL catastrophic commands.
514
+ readonly FOLLOW_MAX_BYTES=262144
515
+ readonly FOLLOW_MAX_TOTAL_BYTES=524288
516
+ readonly FOLLOW_MAX_FILES=8
517
+ readonly FOLLOW_MAX_DEPTH=3
518
+ readonly FOLLOW_MAX_FUNCTION_DEPTH=16
519
+ readonly FOLLOW_MAX_FUNCTION_CALLS=128
520
+
521
+ # Effective working directory established by a literal `cd` in the command, and
522
+ # the flag that says a `cd` happened whose target could NOT be read literally.
523
+ # Empty + 0 means no `cd` has been seen and the session's own cwd governs.
524
+ follow_cwd=""
525
+ follow_cwd_unknown=0
526
+ follow_normalized=""
527
+ follow_paths=$'\n'
528
+ follow_names=""
529
+ follow_count=0
530
+ follow_bytes=0
531
+ follow_function_calls=0
532
+ follow_function_bytes=0
533
+ followed_text=""
534
+ followed_rm_text=""
535
+ # Generated from the canonical reviewed supervisor, never from a host manifest.
536
+ # Regenerate through scripts/generate-scratch-supervisor-profile.mjs.
537
+ readonly SCRATCH_SUPERVISOR_SHA256='76ccf8ca6f98b214361f02ebf6ad58041129b76ed29128455e810095db6c292f'
538
+ readonly SCRATCH_SUPERVISOR_CLEANUP_COUNT='7'
539
+
540
+ # An authenticated public supervisor owns its fixed cleanup sites. Its original
541
+ # source remains visible to every other policy; its actual payload is followed.
542
+ follow_supervisor() {
543
+ local token="$1" depth="$2" statement="$3" path="" profile="" payload="" rm_source="" original=""
544
+ case "$token" in *lisa-scratch-run.sh) ;; *) return 1 ;; esac
545
+ follow_locate "$token" || follow_refuse "$token" "supervisor source could not be resolved"
546
+ path="$follow_located"
547
+ profile="$(python3 - "$path" "$SCRATCH_SUPERVISOR_SHA256" "$statement" "$token" "$SCRATCH_SUPERVISOR_CLEANUP_COUNT" <<'PY'
548
+ import hashlib
549
+ import json
550
+ import os
551
+ import re
552
+ import shlex
553
+ import stat
554
+ import sys
555
+
556
+ path, digest, statement, operand, expected_count = sys.argv[1:]
557
+ def refuse():
558
+ sys.exit(2)
559
+
560
+ try:
561
+ info = os.lstat(path)
562
+ if not stat.S_ISREG(info.st_mode) or info.st_size > 262144:
563
+ refuse()
564
+ raw = open(path, 'rb').read(262145)
565
+ if len(raw) != info.st_size or not re.fullmatch(r'[0-9a-f]{64}', digest):
566
+ refuse()
567
+ if hashlib.sha256(raw).hexdigest() != digest:
568
+ refuse()
569
+ tokens = shlex.split(statement)
570
+ indices = [index for index, token in enumerate(tokens) if token == operand]
571
+ if len(indices) != 1 or '$' in operand or '`' in operand:
572
+ refuse()
573
+ index = indices[0]
574
+ if index < 1 or tokens[index - 1] not in ('sh', 'bash', '/bin/sh', '/bin/bash'):
575
+ refuse()
576
+ prefix = tokens[:index - 1]
577
+ if prefix[:1] == ['env']:
578
+ prefix = prefix[1:]
579
+ assignments = {}
580
+ for token in prefix:
581
+ if '=' not in token or re.search(r'[$`\r\n]', token):
582
+ refuse()
583
+ key, value = token.split('=', 1)
584
+ if key not in ('LISA_SCRATCH_BASE', 'LISA_SCRATCH_TRACE', 'TMPDIR') or key in assignments:
585
+ refuse()
586
+ assignments[key] = value
587
+ # Match actual shell :- precedence, including inherited and
588
+ # explicitly empty values. TMPDIR cannot override a nonempty inherited base.
589
+ effective = dict(os.environ, **assignments)
590
+ base = effective.get('LISA_SCRATCH_BASE') or effective.get('TMPDIR') or '/tmp'
591
+ if not base or not os.path.isabs(base) or re.search(r'[$`\r\n]', base):
592
+ refuse()
593
+ real_base = os.path.realpath(base)
594
+ roots = ['/tmp', '/var/tmp', os.environ.get('TMPDIR', '/tmp')]
595
+ if not any(os.path.commonpath([real_base, os.path.realpath(root)]) == os.path.realpath(root) for root in roots if root and os.path.isabs(root)):
596
+ refuse()
597
+ args = tokens[index + 1:]
598
+ if len(args) < 4 or args[0] != '--suite' or args[2] != '--':
599
+ refuse()
600
+ if not re.fullmatch(r'[A-Za-z0-9._-]{1,64}', args[1]):
601
+ refuse()
602
+ payload = args[3:]
603
+ if not payload[0] or payload[0].startswith('-') or any(re.search(r'[$`\x00\r\n]', value) for value in payload):
604
+ refuse()
605
+ source = raw.decode('utf8')
606
+ cleaned, count = re.subn(r'(?m)^(\s*)rm -rf "(\$_quarantine|\$_root|\$_entry|\$LISA_SCRATCH_ROOT)"', r'\1:', source)
607
+ if not re.fullmatch(r'[1-9][0-9]*', expected_count) or count != int(expected_count):
608
+ refuse()
609
+ print(json.dumps({'source': cleaned, 'original': source, 'payload': shlex.join(payload)}))
610
+ except (OSError, UnicodeError, ValueError):
611
+ refuse()
612
+ PY
613
+ )" || follow_refuse "$path" "supervisor source or public entry authority could not be authenticated"
614
+ rm_source="$(printf '%s' "$profile" | jq -er '.source')" || follow_refuse "$path" "invalid supervisor profile"
615
+ original="$(printf '%s' "$profile" | jq -er '.original')" || follow_refuse "$path" "invalid supervisor source"
616
+ payload="$(printf '%s' "$profile" | jq -er '.payload')" || follow_refuse "$path" "invalid supervisor payload"
617
+ follow_take "$path" "$depth" "$rm_source" "$original"
618
+ [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ] || follow_refuse "$path" "supervisor payload exceeds inspection depth"
619
+ follow_scan "$payload" "$((depth + 1))"
620
+ return 0
621
+ }
622
+
623
+ FOLLOW_GUIDANCE="This command EXECUTES a file. These guards classify shell text, so the script an
624
+ invocation runs is read and scanned as part of the command — a destructive line
625
+ inside the file is refused exactly as the inline form is. This one could not be
626
+ read, so the guard denied rather than passed: a destructive command it cannot
627
+ see is the thing it exists to stop.
628
+
629
+ - Name the script by a LITERAL path that exists and is readable — \`bash
630
+ ./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
631
+ - A leading \`cd\` the guard cannot read literally — \`cd \"\$DIR\" && bash x.sh\` —
632
+ leaves it unable to say WHICH copy of \`x.sh\` would run, and a same-named file
633
+ in another directory is a different file. Spell the directory out, or give the
634
+ script its full path.
635
+ - A file past the inspection cap is refused rather than half-scanned, because a
636
+ truncated scan reports a confident ALLOW about text it never read. Split it,
637
+ or run it manually outside the agent.
638
+ - You genuinely intend to run it as it stands. Ask the user to confirm, then run
639
+ it manually outside the agent."
640
+
641
+ # Deny, naming the file the guard could not classify. Deliberately NOT the
642
+ # ordinary destructive guidance: that guidance is about a pattern the COMMAND
643
+ # matched, and this is a claim that the guard could not look at all.
644
+ follow_refuse() {
645
+ block "cannot classify the file this command executes ($1): $2" "$FOLLOW_GUIDANCE"
646
+ }
647
+
648
+ # Result of the last follow_unwrap / follow_next_operand. These two SET a global
649
+ # rather than printing, and every caller reads it.
650
+ #
651
+ # Printing would be the natural shape, but `$(follow_unwrap …)` is a FORK, and
652
+ # the walk runs it once per token of every followed file. Measured: a script
653
+ # just under the 256 KiB inspection cap holds ~48k tokens and cost 27 SECONDS of
654
+ # wall clock — inside a PreToolUse hook that gates every Bash call. Both
655
+ # functions are pure parameter expansion, so the subshell bought nothing.
656
+ #
657
+ # The concurrent-process ceiling was never the risk: the forks are sequential,
658
+ # so peak process count never rises. The latency was the whole problem.
659
+ follow_unwrapped=""
660
+ follow_operand_index=0
661
+ follow_located=""
662
+
663
+ # Strip substitution, quote and alias wrappers off one token. A sibling of
664
+ # strip_subst_wrappers() below rather than a call to it: that one is defined
665
+ # after this block runs, and it exists for a different job (normalizing a
666
+ # DELETION TARGET). Kept separate so tightening either cannot silently move the
667
+ # other's verdicts.
668
+ follow_unwrap() {
669
+ local t="$1" prev=""
670
+ while [ "$t" != "$prev" ]; do
671
+ prev="$t"
672
+ case "$t" in
673
+ '$(('*) break ;;
674
+ '\'*) t="${t#\\}" ;;
675
+ '$('*) t="${t#'$('}" ;;
676
+ '<('*) t="${t#'<('}" ;;
677
+ '>('*) t="${t#'>('}" ;;
678
+ '"'*) t="${t#'"'}" ;;
679
+ "'"*) t="${t#\'}" ;;
680
+ '`'*) t="${t#'`'}" ;;
681
+ '('*) t="${t#(}" ;;
682
+ esac
683
+ done
684
+ prev=""
685
+ while [ "$t" != "$prev" ]; do
686
+ prev="$t"
687
+ case "$t" in
688
+ *')') t="${t%)}" ;;
689
+ *'`') t="${t%'`'}" ;;
690
+ *'"') t="${t%'"'}" ;;
691
+ *"'") t="${t%\'}" ;;
692
+ *';') t="${t%;}" ;;
693
+ esac
694
+ done
695
+ follow_unwrapped="$t"
696
+ }
697
+
698
+ # A shell interpreter, bare or path-prefixed. Only shells: see NOT FOLLOWED.
699
+ follow_is_interpreter() {
700
+ case "$1" in
701
+ bash | sh | zsh | ksh | ksh93 | dash | ash \
702
+ | */bash | */sh | */zsh | */ksh | */ksh93 | */dash | */ash) return 0 ;;
703
+ esac
704
+ return 1
705
+ }
706
+
707
+ # Could this token itself be the thing a wrapper prefix goes on to invoke?
708
+ #
709
+ # Used ONLY to yield when stepping over a wrapper's own operand, so that
710
+ # step-over can never swallow the invocation. Deliberately generous, because the
711
+ # cost of the two answers is asymmetric: yielding wrongly leaves the walk where
712
+ # it already was, while consuming wrongly loses the program and the guard falls
713
+ # open — which is the entire defect this block exists to close. Anything
714
+ # path-shaped yields too, so `env -i <file>` still reaches the script rather
715
+ # than eating it as a value `-i` does not take.
716
+ follow_may_invoke() {
717
+ follow_is_interpreter "$1" && return 0
718
+ # shellcheck disable=SC2088
719
+ case "$1" in
720
+ "" | -* | [A-Za-z_]*=*) return 0 ;;
721
+ /* | ./* | ../* | '~/'* | */*) return 0 ;;
722
+ sudo | env | command | exec | nohup | nice | time | builtin | timeout \
723
+ | do | then | else | while | until | if | '!' | '{' \
724
+ | eval | find | xargs | cd | source | . | cat) return 0 ;;
725
+ esac
726
+ return 1
727
+ }
728
+
729
+ # Split the command into statements at UNQUOTED separators, printing one
730
+ # statement per line as "<separator><TAB><statement>". Quote-aware on purpose: a
731
+ # `;` or `|` inside a quoted string is not a statement boundary, and splitting
732
+ # there would invent a command position in the middle of a quoted argument and
733
+ # make prose look like an invocation — the #3604 failure mode, arrived at from
734
+ # the other side. Literal subshell parentheses carry scope events, while quoted
735
+ # parentheses and substitutions stay in their original statement so `eval
736
+ # "$(cat <file>)"` retains its command position and executed-file inspection.
737
+ #
738
+ # awk rather than a bash char loop because this runs on EVERY intercepted Bash
739
+ # command, and it addresses the text by index instead of accumulating it a
740
+ # character at a time so the cost stays linear on a followed file. The text
741
+ # arrives through the ENVIRONMENT: `awk -v` processes backslash escapes in the
742
+ # value it is given and would rewrite the command before it was ever read.
743
+ follow_split() {
744
+ LC_ALL=C SN_FOLLOW_TEXT="$1" awk '
745
+ # Lexical lookahead keeps an asynchronous return from ending its parent
746
+ # scan before the trailing & event. Aggregate work is capped linearly in
747
+ # input size, including overlap from nested scopes; no partial parse passes.
748
+ function lookahead_step() {
749
+ if (++lookahead_work > 8 * n + 1024) { exit 2 }
750
+ }
751
+ function brace_pipe(from, opening, closing, j, c, quote, escape, braces, nesting, tail, tail_cases, tail_header, word, word_end) {
752
+ quote = ""; escape = 0; braces = 1; nesting = 0; brace_redirect = ""; tail_cases = 0; tail_header = 0;
753
+ for (j = from; j <= n; j++) {
754
+ lookahead_step(); c = substr(s, j, 1);
755
+ if (escape) { escape = 0; continue }
756
+ if (c == "\\" && quote != "\047") { escape = 1; continue }
757
+ if (quote != "") { if (c == quote) { quote = "" } continue }
758
+ if (c == "\"" || c == "\047" || c == "`") { quote = c; continue }
759
+ if (braces > 0) {
760
+ # A case-pattern ) is not the closing parenthesis of its shell.
761
+ if (opening == "(" && c ~ /[A-Za-z_]/) {
762
+ word_end = j + 1;
763
+ while (word_end <= n && substr(s, word_end, 1) ~ /[A-Za-z_0-9-]/) { lookahead_step(); word_end++ }
764
+ word = substr(s, j, word_end - j); j = word_end - 1;
765
+ if (word == "case" && (tail_cases == 0 || !tail_patterns[tail_cases])) { tail_header++ }
766
+ if (word == "in" && tail_header > 0) {
767
+ tail_header--; tail_patterns[++tail_cases] = 1; tail_levels[tail_cases] = braces;
768
+ }
769
+ if (word == "esac" && tail_cases > 0) { tail_cases-- }
770
+ continue;
771
+ }
772
+ if (tail_cases > 0 && tail_levels[tail_cases] == braces) {
773
+ if (c == ")" && tail_patterns[tail_cases]) { tail_patterns[tail_cases] = 0; continue }
774
+ if (c == ";" && substr(s, j + 1, 1) == ";") { tail_patterns[tail_cases] = 1; j++; continue }
775
+ }
776
+ if (c == opening) { braces++ }
777
+ if (c == closing && --braces == 0) { tail = j + 1 }
778
+ continue;
779
+ }
780
+ # Redirections belong to the compound command too. Quoted operator
781
+ # bytes and nested substitutions are operands, not the pipeline edge.
782
+ if (c == "(") { nesting++; continue }
783
+ if (c == ")") { if (nesting == 0) { brace_redirect = substr(s, tail, j - tail); return 0 } nesting--; continue }
784
+ if (nesting > 0) { continue }
785
+ if (c == "|") { brace_redirect = substr(s, tail, j - tail); return substr(s, j + 1, 1) != "|" }
786
+ if (c == ";" || c == "\n") { brace_redirect = substr(s, tail, j - tail); return 0 }
787
+ if (c == "&" && substr(s, j - 1, 1) !~ /[<>]/ && substr(s, j + 1, 1) != ">") { brace_redirect = substr(s, tail, j - tail); return 0 }
788
+ }
789
+ if (tail > 0) { brace_redirect = substr(s, tail) }
790
+ return 0;
791
+ }
792
+ function list_async(from, j, c, quote, escape, nesting, braces) {
793
+ quote = ""; escape = 0; nesting = 0; braces = 0;
794
+ for (j = from; j <= n; j++) {
795
+ lookahead_step();
796
+ c = substr(s, j, 1);
797
+ if (escape) { escape = 0; continue }
798
+ if (c == "\\" && quote != "\047") { escape = 1; continue }
799
+ if (quote != "") { if (c == quote) { quote = "" } continue }
800
+ if (c == "\"" || c == "\047" || c == "`") { quote = c; continue }
801
+ if (c == "(") { nesting++; continue }
802
+ if (c == ")") { if (nesting == 0) { return 0 } nesting--; continue }
803
+ if (c == "{") { braces++; continue }
804
+ if (c == "}") { if (braces > 0) { braces-- } continue }
805
+ if (nesting > 0 || braces > 0) { continue }
806
+ if (c == ";" || c == "\n") { return 0 }
807
+ if (c == "&" && substr(s, j - 1, 1) !~ /[&<>]/ && substr(s, j + 1, 1) !~ /[&>]/) { return 1 }
808
+ }
809
+ return 0;
810
+ }
811
+ BEGIN {
812
+ s = ENVIRON["SN_FOLLOW_TEXT"]; n = length(s);
813
+ q = ""; esc = 0; start = 1; sep = "o"; parens = 0;
814
+ functions = 0; cases = 0; backtick = 0; brace_depth = 0; post_brace = 0;
815
+ printf "L\t%d\n", list_async(1);
816
+ for (i = 1; i <= n; i++) {
817
+ c = substr(s, i, 1);
818
+ if (esc == 1) { if (c == "$") { literal_dollars[i] = 1 } esc = 0; continue }
819
+ if (c == "\\" && q != "\047") { esc = 1; continue }
820
+ if (functions > 0) {
821
+ if (q != "") { if (c == q) { q = "" } continue }
822
+ if (c == "\"" || c == "\047") { q = c; continue }
823
+ if (c == "{") { functions++ }
824
+ if (c == "}" && --functions == 0) {
825
+ printf "N\t%s\t%s\n", sep, function_name;
826
+ body = substr(s, function_start, i - function_start);
827
+ count = split(body, body_lines, "\n");
828
+ for (j = 1; j <= count; j++) { printf "D\t%s\n", body_lines[j] }
829
+ printf "F\t\n"; start = i + 1; sep = "o";
830
+ }
831
+ continue;
832
+ }
833
+ quoted_expression = q == "\"" && ((c == "(" && substr(s, i - 1, 1) == "$" && !literal_dollars[i - 1]) || c == "`");
834
+ if (q != "" && !quoted_expression) { if (c == q) { q = "" } continue }
835
+ if (c == "\"" || c == "\047") { q = c; continue }
836
+ if (c == "`") {
837
+ if (backtick) {
838
+ piece = substr(s, start, i - start);
839
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
840
+ printf "]\t\n"; start = backtick_start; q = backtick_quote; backtick = 0;
841
+ } else {
842
+ backtick_start = start; backtick_quote = q; q = ""; backtick = 1;
843
+ printf "[\t\nL\t%d\n", list_async(i + 1); start = i + 1;
844
+ }
845
+ continue;
846
+ }
847
+ if (i == start && c ~ /[[:space:]]/) { start = i + 1; continue }
848
+ if (c ~ /[[:space:]]/ && substr(s, i - 1, 1) !~ /[[:space:]]/ && substr(s, start, 4) == "case") {
849
+ piece = substr(s, start, i - start);
850
+ if (piece ~ /^case[[:space:]].*[[:space:]]in$/) {
851
+ case_scopes[++cases] = parens;
852
+ case_patterns[cases] = 1; printf "C\t\n"; start = i + 1; continue;
853
+ }
854
+ }
855
+ if (cases > 0 && c ~ /[[:space:];&|)]/ && substr(s, start, 4) == "esac") {
856
+ piece = substr(s, start, i - start);
857
+ if (piece ~ /^esac[[:space:]]*$/) { cases--; printf "K\t\n" }
858
+ }
859
+ if (cases > 0 && case_scopes[cases] == parens && case_patterns[cases]) {
860
+ if (c == ")") { case_patterns[cases] = 0; start = i + 1 }
861
+ continue;
862
+ }
863
+ if (c == "{") {
864
+ piece = substr(s, start, i - start);
865
+ definition = piece ~ /^[[:space:]]*([A-Za-z_][A-Za-z_0-9]*[[:space:]]*\([[:space:]]*\)|function[[:space:]]+[A-Za-z_][A-Za-z_0-9]*([[:space:]]*\([[:space:]]*\))?)[[:space:]]*$/;
866
+ if (definition) {
867
+ function_name = piece;
868
+ sub(/^[[:space:]]*(function[[:space:]]+)?/, "", function_name);
869
+ sub(/[[:space:]\(].*$/, "", function_name);
870
+ functions = 1; function_start = i + 1;
871
+ continue;
872
+ }
873
+ previous = i > 1 ? substr(s, i - 1, 1) : "";
874
+ group = (i == 1 || previous ~ /[[:space:];&|()]/) && substr(s, i + 1, 1) ~ /[[:space:];]/ &&
875
+ piece ~ /^[[:space:]]*((then|do|else|!|time)[[:space:]]+)*$/;
876
+ brace_groups[++brace_depth] = group;
877
+ if (group) {
878
+ brace_sep = sep;
879
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
880
+ # A pipeline owns the whole compound command, not just the final
881
+ # statement before |. Snapshot before any brace-body cd/return.
882
+ outgoing = brace_pipe(i + 1, "{", "}");
883
+ brace_pipes[brace_depth] = brace_sep == "|" || outgoing;
884
+ if (brace_pipes[brace_depth]) { printf "(\t%s%s\nL\t%d\n", (brace_sep == "|" ? "|" : ""), (outgoing ? "+" : ""), list_async(i + 1) }
885
+ printf "G\t%s\nL\t%d\n", brace_sep, list_async(i + 1);
886
+ if (brace_redirect ~ /[^[:space:]]/) { printf "I\t%s\n", brace_redirect }
887
+ # P retains incoming stdin provenance without wrapping each body
888
+ # statement in another child: all statements share the brace cwd.
889
+ if (brace_sep == "|") { sep = "P" }
890
+ start = i + 1; continue;
891
+ }
892
+ }
893
+ if (c == "}" && brace_depth > 0) {
894
+ group = brace_groups[brace_depth--];
895
+ if (group) {
896
+ piece = substr(s, start, i - start);
897
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
898
+ printf "H\t\n";
899
+ if (brace_pipes[brace_depth + 1]) { printf ")\t\n" }
900
+ start = i + 1; post_brace = 1; continue;
901
+ }
902
+ }
903
+ if (c == "(") {
904
+ previous = i > 1 ? substr(s, i - 1, 1) : "";
905
+ piece = substr(s, start, i - start);
906
+ header = piece ~ /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z_0-9]*[[:space:]]*$/ && substr(s, i + 1) ~ /^[[:space:]]*\)/;
907
+ expression = previous ~ /[$<>]/ && !literal_dollars[i - 1] && substr(s, i + 1, 1) != "(";
908
+ group = (parens == 0 || scopes[parens] != 0) &&
909
+ (i == 1 || previous ~ /[[:space:];&|()]/) && substr(s, i + 1, 1) != "(" && !header;
910
+ scopes[++parens] = expression ? 2 : group;
911
+ if (expression) {
912
+ expression_starts[parens] = start;
913
+ expression_quotes[parens] = q; q = "";
914
+ printf "[\t\nL\t%d\n", list_async(i + 1); start = i + 1; continue;
915
+ }
916
+ if (group) {
917
+ piece = substr(s, start, i - start);
918
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
919
+ outgoing = brace_pipe(i + 1, "(", ")");
920
+ printf "(\t%s%s\nL\t%d\n", sep, (outgoing ? "+" : ""), list_async(i + 1);
921
+ if (brace_redirect ~ /[^[:space:]]/) { printf "I\t%s\n", brace_redirect }
922
+ if (sep == "|") { sep = "P" }
923
+ start = i + 1; continue;
924
+ }
925
+ }
926
+ if (c == ")" && parens > 0) {
927
+ piece = substr(s, start, i - start);
928
+ group = scopes[parens--];
929
+ if (group == 2) {
930
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
931
+ printf "]\t\n"; start = expression_starts[parens + 1]; q = expression_quotes[parens + 1]; continue;
932
+ }
933
+ if (group) {
934
+ piece = substr(s, start, i - start);
935
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
936
+ printf ")\t\n"; start = i + 1; post_brace = 1; continue;
937
+ }
938
+ }
939
+ if (c == ";" || c == "&" || c == "|" || c == "\n") {
940
+ if (parens > 0 && scopes[parens] == 0) { continue }
941
+ if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { continue }
942
+ piece = substr(s, start, i - start);
943
+ pipe = c == "|" && substr(s, i + 1, 1) != "|";
944
+ if (pipe || sep == "|") { printf "(\t%s%s\nL\t%d\n", (sep == "|" ? "|" : ""), (pipe ? "+" : ""), list_async(start) }
945
+ if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", post_brace ? "R" : sep, piece }
946
+ post_brace = 0;
947
+ if (pipe || sep == "|") { printf ")\t\n" }
948
+ if (c == ";" && substr(s, i + 1, 1) == ";" && cases > 0) {
949
+ case_patterns[cases] = 1; printf "A\t\n"; i++;
950
+ }
951
+ if (c == "|" && substr(s, i + 1, 1) == "|") {
952
+ i++; sep = "?";
953
+ } else if (c == "&" && substr(s, i + 1, 1) == "&") {
954
+ i++; sep = "&";
955
+ } else if (c == "|") { sep = "|" } else { sep = "o" }
956
+ if (c == "&" && sep == "o" && substr(s, i - 1, 1) !~ /[&<>]/ && substr(s, i + 1, 1) != ">") {
957
+ printf "B\t\n";
958
+ }
959
+ if ((c == ";" || c == "\n" || c == "&") && sep == "o") { printf "L\t%d\n", list_async(i + 1) }
960
+ start = i + 1;
961
+ continue
962
+ }
963
+ }
964
+ if (sep == "|") { printf "(\t|\nL\t%d\n", list_async(start) }
965
+ printf "%s\t%s\n", sep, substr(s, start, n - start + 1);
966
+ if (sep == "|") { printf ")\t\n" }
967
+ }
968
+ '
969
+ }
970
+
971
+ # Collapse `.`, `..` and empty segments so the path a verdict NAMES is the one a
972
+ # reader recognises. Purely lexical, and it cannot move a verdict: every
973
+ # component has already been proven to exist by the `-d` test that follows, and
974
+ # the `-f`/`-r` tests are applied to the collapsed path either way.
975
+ follow_normalize() {
976
+ local p="$1" out="" seg
977
+ local IFS=/
978
+ set -f
979
+ # The word split is the point: `$p` is being addressed one path segment at a
980
+ # time, and the glob guard above keeps a literal `*` from expanding.
981
+ # shellcheck disable=SC2086
982
+ for seg in $p; do
983
+ case "$seg" in
984
+ "" | .) ;;
985
+ ..) out="${out%/*}" ;;
986
+ *) out="$out/$seg" ;;
987
+ esac
988
+ done
989
+ set +f
990
+ follow_normalized="${out:-/}"
991
+ }
992
+
993
+ # Track the working directory a literal `cd` establishes, so a relative executed
994
+ # path resolves against the directory the command will actually have.
995
+ #
996
+ # This REPLACES the session's own cwd rather than adding to it. It used to be an
997
+ # extra base appended AFTER `$PWD`, so `$PWD` always won and
998
+ # `cd <other-checkout> && bash scripts/x.sh` was classified against the session
999
+ # checkout's same-named copy. That is not a resolution failure — it succeeds,
1000
+ # against a real and readable file, and the verdict is reported with full
1001
+ # confidence about text nobody was about to run (CodySwannGT/lisa#3933). Two
1002
+ # checkouts of one repository differ by exactly the work in flight, which is the
1003
+ # entire point of the second one, so the copy that would RUN is the only copy
1004
+ # whose verdict means anything. Wrong in both directions, and the expensive
1005
+ # direction is silent: the dangerous line sits in the copy that would run and
1006
+ # the guard reads the pristine one.
1007
+ #
1008
+ # A target this hook cannot turn into a literal directory marks the cwd UNKNOWN
1009
+ # instead of leaving a stale one in place. `follow_locate` then refuses a
1010
+ # relative token rather than guessing, which is the honest-refusal doctrine the
1011
+ # rest of this block already follows: a confident scan of the wrong file is
1012
+ # strictly worse than saying it could not tell.
1013
+ follow_cd() {
1014
+ local d="$1" home="${HOME:-}"
1015
+ # The tilde arms are LITERAL text — the unexpanded spelling the hook was
1016
+ # handed — for the reason follow_locate's arms document.
1017
+ # shellcheck disable=SC2088
1018
+ case "$d" in
1019
+ '~') d="$home" ;;
1020
+ '~/'*) d="$home/${d#'~/'}" ;;
1021
+ esac
1022
+ case "$d" in
1023
+ "" | -* | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*)
1024
+ follow_cwd=""
1025
+ follow_cwd_unknown=1
1026
+ return 0
1027
+ ;;
1028
+ esac
1029
+ case "$d" in
1030
+ /*) ;;
1031
+ *)
1032
+ # A relative cd cannot recover an unknown base. Falling back to this
1033
+ # hook's PWD would confidently inspect another directory (#4299).
1034
+ [ "$follow_cwd_unknown" -eq 0 ] || return 0
1035
+ d="${follow_cwd:-$PWD}/$d"
1036
+ ;;
1037
+ esac
1038
+ follow_normalize "$d"
1039
+ d="$follow_normalized"
1040
+ if [ -d "$d" ]; then
1041
+ follow_cwd="$d"
1042
+ follow_cwd_unknown=0
1043
+ else
1044
+ follow_cwd=""
1045
+ follow_cwd_unknown=1
1046
+ fi
1047
+ }
1048
+
1049
+ # Resolve one executed-file operand to a readable regular file, or fail.
1050
+ #
1051
+ # The variable expansions are LOCATE-ONLY and cannot widen a verdict: a wrong
1052
+ # guess simply fails to find a file, and an unresolved target is refused, not
1053
+ # passed. They are here because an agent's own scratch path arrives spelled with
1054
+ # one of them, and refusing every `bash "$TMPDIR/x.sh"` would be a wall with no
1055
+ # door. Nothing else is expanded — any remaining `$`, glob or brace is a target
1056
+ # this hook cannot evaluate.
1057
+ follow_locate() {
1058
+ local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
1059
+ local cwd="${follow_cwd:-$PWD}"
1060
+ follow_located=""
1061
+ tmp="${tmp%/}"
1062
+ [ -n "$tmp" ] || tmp="/tmp"
1063
+ # The tilde arms below are LITERAL text: this is the unexpanded spelling the
1064
+ # hook was handed, and turning it into a path is the whole point of the arm.
1065
+ # shellcheck disable=SC2088
1066
+ case "$token" in
1067
+ '~') token="$home" ;;
1068
+ '~/'*) token="$home/${token#'~/'}" ;;
1069
+ '$TMPDIR' | '$TMPDIR'/*) token="$tmp${token#'$TMPDIR'}" ;;
1070
+ '${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
1071
+ '$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
1072
+ '${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
1073
+ '$PWD' | '$PWD'/*)
1074
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
1075
+ token="$cwd${token#'$PWD'}"
1076
+ ;;
1077
+ '${PWD}' | '${PWD}'/*)
1078
+ [ "$follow_cwd_unknown" -eq 0 ] || return 1
1079
+ token="$cwd${token#'${PWD}'}"
1080
+ ;;
1081
+ '$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
1082
+ '${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
1083
+ esac
1084
+ case "$token" in
1085
+ "" | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*) return 1 ;;
1086
+ /*)
1087
+ [ -f "$token" ] && [ -r "$token" ] || return 1
1088
+ follow_located="$token"
1089
+ return 0
1090
+ ;;
1091
+ esac
1092
+ # Past this point the token is RELATIVE, so which directory it is resolved
1093
+ # from decides which file the verdict is about.
1094
+ if [ "$follow_cwd_unknown" -eq 1 ]; then
1095
+ return 1
1096
+ fi
1097
+ if [ -n "$follow_cwd" ]; then
1098
+ # A literal `cd` names the directory the shell will be in. It is the only
1099
+ # candidate — falling back to `$PWD` here is what made the guard report
1100
+ # confidently about a same-named file in another checkout.
1101
+ [ -f "$follow_cwd/$token" ] && [ -r "$follow_cwd/$token" ] || return 1
1102
+ follow_located="$follow_cwd/$token"
1103
+ return 0
1104
+ fi
1105
+ while IFS= read -r base; do
1106
+ [ -n "$base" ] || continue
1107
+ if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
1108
+ follow_located="$base/$token"
1109
+ return 0
1110
+ fi
1111
+ done <<<"$PWD"$'\n'"$proj"
1112
+ return 1
1113
+ }
1114
+
1115
+ # Read one executed file into the text the guards will scan, then follow what IT
1116
+ # executes, to a bounded depth. Full-line comments are dropped: a commented line
1117
+ # is never executed, so scanning it can only manufacture a false refusal.
1118
+ follow_take() {
1119
+ local path="$1" depth="$2" size="" content="" rm_content="${3-}"
1120
+ local invocation_kind=child
1121
+ if [ "$#" -eq 3 ]; then invocation_kind="$3"; fi
1122
+ case "$follow_paths" in
1123
+ *$'\n'"$path"$'\n'*)
1124
+ # Sourcing executes in the caller each time; a read cache cannot erase
1125
+ # its directory changes or newly defined functions.
1126
+ [ "$invocation_kind" = source ] || return 0
1127
+ ;;
1128
+ esac
1129
+ follow_paths="$follow_paths$path"$'\n'
1130
+ follow_count=$((follow_count + 1))
1131
+ if [ "$follow_count" -gt "$FOLLOW_MAX_FILES" ]; then
1132
+ follow_refuse "$path" "this command executes more files than the guard will inspect (${FOLLOW_MAX_FILES})"
1133
+ fi
1134
+ size="$(wc -c <"$path" 2>/dev/null | tr -cd '0-9')" || size=""
1135
+ case "$size" in
1136
+ "") follow_refuse "$path" "its size could not be determined" ;;
1137
+ esac
1138
+ if [ "$size" -gt "$FOLLOW_MAX_BYTES" ]; then
1139
+ follow_refuse "$path" "it is larger than the ${FOLLOW_MAX_BYTES}-byte inspection cap"
1140
+ fi
1141
+ follow_bytes=$((follow_bytes + size))
1142
+ if [ "$follow_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
1143
+ follow_refuse "$path" "the files this command executes total more than the ${FOLLOW_MAX_TOTAL_BYTES}-byte inspection budget"
1144
+ fi
1145
+ if [ "$#" -eq 4 ]; then
1146
+ # Scan the same complete bytes whose digest was authenticated, not a
1147
+ # second file read that could pair changed source with the cleanup view.
1148
+ content="$(printf '%s' "$4" | sed 's/^[[:space:]]*#.*$//')" || follow_refuse "$path" "authenticated source could not be read"
1149
+ else
1150
+ content="$(LC_ALL=C tr -d '\000' <"$path" | sed 's/^[[:space:]]*#.*$//')" \
1151
+ || follow_refuse "$path" "it could not be read"
1152
+ fi
1153
+ follow_names="$follow_names $path"
1154
+ followed_text="$followed_text"$'\n'"$content"
1155
+ if [ "$#" -ne 4 ]; then
1156
+ rm_content="$content"
1157
+ else
1158
+ rm_content="$(printf '%s' "$rm_content" | sed 's/^[[:space:]]*#.*$//')" || follow_refuse "$path" "supervisor cleanup view could not be read"
1159
+ fi
1160
+ followed_rm_text="$followed_rm_text"$'\n'"$rm_content"
1161
+ if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
1162
+ # Child scripts inherit cwd but cannot change their parent. A sourced
1163
+ # script runs in the caller and keeps both cwd and function definitions.
1164
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
1165
+ if [ "$#" -eq 4 ]; then
1166
+ # Only follow_supervisor reaches this form, after exact byte identity and
1167
+ # the public argv profile were authenticated. Its owned bootstrap is
1168
+ # already scanned in full by every text policy; its user payload is
1169
+ # followed separately by follow_supervisor. Re-expanding all bootstrap
1170
+ # functions would re-interpret its internal protocol, not inspect a new
1171
+ # user-controlled file, and multiplied its native hook time fourfold.
1172
+ follow_scan "$content" "$((depth + 1))" 0 1
1173
+ else
1174
+ follow_scan "$content" "$((depth + 1))" 0 0 "$invocation_kind"
1175
+ fi
1176
+ if [ "$invocation_kind" != source ]; then
1177
+ follow_cwd="$saved_cwd"
1178
+ follow_cwd_unknown="$saved_unknown"
1179
+ fi
1180
+ fi
1181
+ }
1182
+
1183
+ # An operand a command EXECUTES. Unresolvable at the top level is a refusal;
1184
+ # nested inside an already-followed script it is the documented residual above.
1185
+ follow_target() {
1186
+ local token="$1" depth="$2" path=""
1187
+ local invocation_kind="${3:-child}"
1188
+ [ -n "$token" ] || return 0
1189
+ if [ "$follow_cwd_unknown" -ne 0 ]; then
1190
+ case "$token" in
1191
+ /* | '~/'*) ;;
1192
+ *) follow_refuse "$token" "the execution directory is unknown" ;;
1193
+ esac
1194
+ fi
1195
+ # Entry authority belongs to each invocation, not the file-read cache. Only
1196
+ # follow_supervisor may admit the exact public interpreter/argv profile.
1197
+ case "$token" in
1198
+ *lisa-scratch-run.sh) follow_refuse "$token" "supervisor requires its authenticated public interpreter entry" ;;
1199
+ esac
1200
+ if ! follow_locate "$token"; then
1201
+ if [ "$depth" -eq 0 ]; then
1202
+ follow_refuse "$token" "no readable file could be resolved for it"
1203
+ fi
1204
+ return 0
1205
+ fi
1206
+ path="$follow_located"
1207
+ follow_take "$path" "$depth" "$invocation_kind"
1208
+ }
1209
+
1210
+ # A command word that IS the executable (`./deploy.sh`, `/tmp/x.sh`). Followed
1211
+ # only when the file's shebang names a shell: a compiled binary or a Python/Node
1212
+ # script is not text these patterns can classify, and `/usr/bin/git` sitting at
1213
+ # a command position must stay an ordinary command.
1214
+ #
1215
+ # This arm alone does NOT fail closed on an unresolvable token, and the line is
1216
+ # deliberate. Elsewhere the command TEXT declares that a file is being executed
1217
+ # — `bash <file>` says so whether or not the file can be read — so "I cannot
1218
+ # read it" is a real inability to classify a stated execution. Here the only
1219
+ # evidence that the token is a script at all is the shebang inside it, so an
1220
+ # unreadable token is not an unclassifiable execution; it is a command word this
1221
+ # hook cannot see, and a shell that cannot see it cannot run it either. Refusing
1222
+ # on shape alone would deny `/usr/bin/charm x` and `./scripts/confirm -rf /`,
1223
+ # both of which the guard-parity matrix pins as ALLOW (#1960 PR-A2/PR-A3).
1224
+ # Accepted residual: a shebang script whose relative path resolves only from a
1225
+ # working directory this hook does not share is not followed.
1226
+ follow_direct() {
1227
+ local token="$1" depth="$2" path="" first="" raw="" char="" k=0
1228
+ local LC_ALL=C
1229
+ if ! follow_locate "$token"; then
1230
+ return 0
1231
+ fi
1232
+ path="$follow_located"
1233
+ case "$path" in
1234
+ *lisa-scratch-run.sh) follow_refuse "$path" "supervisor requires its authenticated public interpreter entry" ;;
1235
+ esac
1236
+ IFS= read -r -n 256 raw <"$path" 2>/dev/null || true
1237
+ while [ "$k" -lt "${#raw}" ]; do
1238
+ char="${raw:k:1}"
1239
+ case "$char" in [[:print:]]) first="$first$char" ;; esac
1240
+ k=$((k + 1))
1241
+ done
1242
+ case "$first" in
1243
+ '#!'*[/[:space:]]bash* | '#!'*[/[:space:]]sh* | '#!'*[/[:space:]]zsh* \
1244
+ | '#!'*[/[:space:]]ksh* | '#!'*[/[:space:]]dash* | '#!'*[/[:space:]]ash*) ;;
1245
+ *) return 0 ;;
1246
+ esac
1247
+ follow_take "$path" "$depth"
1248
+ }
1249
+
1250
+ # Index of the next non-option token after $1, or $2 when there is none.
1251
+ follow_next_operand() {
1252
+ local from="$1" upto="$2" j
1253
+ j=$((from + 1))
1254
+ while [ "$j" -lt "$upto" ]; do
1255
+ follow_unwrap "${toks[j]}"
1256
+ case "$follow_unwrapped" in
1257
+ -*) j=$((j + 1)) ;;
1258
+ *) break ;;
1259
+ esac
1260
+ done
1261
+ follow_operand_index="$j"
1262
+ }
1263
+
1264
+ # Resolve a piped file in the PRODUCER's directory, which can differ from the
1265
+ # interpreter's after a subshell closes. Preserve unknown-state provenance too.
1266
+ follow_pipe_target() {
1267
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
1268
+ follow_cwd="$3"
1269
+ follow_cwd_unknown="$4"
1270
+ follow_target "$1" "$2"
1271
+ follow_cwd="$saved_cwd"
1272
+ follow_cwd_unknown="$saved_unknown"
1273
+ }
1274
+
1275
+ # Carry branch uncertainty into sourced declarations as well as cwd changes.
1276
+ # The local projection lasts only for this source invocation, not later scans.
1277
+ follow_source_target() {
1278
+ local follow_source_flow_unknown="$3"
1279
+ follow_target "$1" "$2" source
1280
+ }
1281
+
1282
+ # Extract the literal -c operand without evaluating it or treating positional
1283
+ # arguments after its closing quote as commands. Prefix indexing matches the
1284
+ # whitespace tokens used by follow_scan; the operand keeps its internal bytes.
1285
+ follow_inline() {
1286
+ local stmt="$1" from="$2" depth="$3" body
1287
+ local follow_inline_depth="${follow_inline_depth:-0}"
1288
+ [ "$follow_inline_depth" -lt "$FOLLOW_MAX_FUNCTION_DEPTH" ] || follow_refuse "-c" "inline shells exceed the inspection depth"
1289
+ follow_inline_depth=$((follow_inline_depth + 1))
1290
+ body="$(LC_ALL=C SN_INLINE_TEXT="$stmt" SN_INLINE_FROM="$from" awk '
1291
+ BEGIN {
1292
+ s = ENVIRON["SN_INLINE_TEXT"]; n = length(s); i = 1;
1293
+ for (word = 0; word < ENVIRON["SN_INLINE_FROM"]; word++) {
1294
+ while (i <= n && substr(s, i, 1) ~ /[[:space:]]/) { i++ }
1295
+ while (i <= n && substr(s, i, 1) !~ /[[:space:]]/) { i++ }
1296
+ }
1297
+ while (i <= n && substr(s, i, 1) ~ /[[:space:]]/) { i++ }
1298
+ q = substr(s, i, 1);
1299
+ if (q == "\047" || q == "\"") {
1300
+ start = ++i; esc = 0;
1301
+ for (; i <= n; i++) {
1302
+ c = substr(s, i, 1);
1303
+ if (esc) { esc = 0; continue }
1304
+ if (c == "\\" && q == "\"") { esc = 1; continue }
1305
+ if (c == q) {
1306
+ # Adjacent quoted/unquoted fragments are the same shell operand.
1307
+ # Refuse unsupported concatenation instead of scanning a prefix.
1308
+ if (i < n && substr(s, i + 1, 1) !~ /[[:space:]]/) { exit 1 }
1309
+ printf "%s", substr(s, start, i - start); exit;
1310
+ }
1311
+ }
1312
+ exit 1;
1313
+ }
1314
+ start = i;
1315
+ while (i <= n && substr(s, i, 1) !~ /[[:space:]]/) {
1316
+ c = substr(s, i, 1);
1317
+ if (c == "\047" || c == "\"" || c == "\\") { exit 1 }
1318
+ i++;
1319
+ }
1320
+ printf "%s", substr(s, start, i - start);
1321
+ }
1322
+ ')" || follow_refuse "-c" "its command string could not be determined"
1323
+ local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
1324
+ follow_scan "$body" "$depth"
1325
+ follow_cwd="$saved_cwd"
1326
+ follow_cwd_unknown="$saved_unknown"
1327
+ }
1328
+
1329
+ # Resolve literal descriptor duplication without running the proposed shell.
1330
+ # Compound input is established before its body; output is projected when the
1331
+ # producer reaches the pipe. Unknown descriptors refuse rather than drop bytes.
1332
+ follow_redirect_io() {
1333
+ local statement="$1" mode="$2" result
1334
+ result="$(SN_COMPOUND_REDIRECT="$statement" SN_DESCRIPTOR_MAP="$follow_descriptor_map" SN_REDIRECT_CWD="$follow_cwd" SN_REDIRECT_UNKNOWN="$follow_cwd_unknown" python3 - "$mode" <<'PY'
1335
+ import json
1336
+ import os
1337
+ import sys
1338
+ try:
1339
+ mode = sys.argv[1]
1340
+ text = os.environ['SN_COMPOUND_REDIRECT']
1341
+ tokens = []
1342
+ cursor = 0
1343
+ # Keep syntax provenance and byte adjacency: quoted/escaped > is data,
1344
+ # and `cat 2 >&2` names a file rather than redirecting descriptor 2.
1345
+ while cursor < len(text):
1346
+ if text[cursor].isspace():
1347
+ cursor += 1
1348
+ continue
1349
+ start = cursor
1350
+ syntax = text[cursor] in '<>&'
1351
+ plain = True
1352
+ value = ''
1353
+ quote = ''
1354
+ if syntax:
1355
+ while cursor < len(text) and text[cursor] in '<>&':
1356
+ value += text[cursor]; cursor += 1
1357
+ else:
1358
+ while cursor < len(text):
1359
+ char = text[cursor]
1360
+ if not quote and (char.isspace() or char in '<>&'):
1361
+ break
1362
+ if char == '\\' and quote != "'":
1363
+ plain = False
1364
+ cursor += 1
1365
+ if cursor == len(text):
1366
+ raise ValueError('trailing escape')
1367
+ if text[cursor] == '\n':
1368
+ cursor += 1
1369
+ continue
1370
+ if quote == '"' and text[cursor] not in '$`"\\':
1371
+ value += '\\'
1372
+ value += text[cursor]; cursor += 1
1373
+ continue
1374
+ if char in ('"', "'"):
1375
+ if not quote:
1376
+ quote = char; plain = False; cursor += 1
1377
+ continue
1378
+ if quote == char:
1379
+ quote = ''; cursor += 1
1380
+ continue
1381
+ value += char; cursor += 1
1382
+ if quote:
1383
+ raise ValueError('unterminated quote')
1384
+ tokens.append((value, syntax, plain, start, cursor))
1385
+ descriptors = {int(fd): value for fd, value in json.loads(os.environ['SN_DESCRIPTOR_MAP']).items()}
1386
+ operators = ('<', '>', '>>', '<>', '<&', '>&', '&>', '&>>')
1387
+ index = 0
1388
+ while index < len(tokens):
1389
+ fd = None
1390
+ token, syntax, plain, start, end = tokens[index]
1391
+ if token.isdigit() and plain and index + 1 < len(tokens) and tokens[index + 1][1] and end == tokens[index + 1][3]:
1392
+ fd = int(token); index += 1
1393
+ token, syntax, plain, start, end = tokens[index]
1394
+ if not syntax or token not in operators:
1395
+ if mode == 'cat':
1396
+ index += 1
1397
+ continue
1398
+ sys.exit(2)
1399
+ if index + 1 >= len(tokens):
1400
+ sys.exit(2)
1401
+ operator = token
1402
+ operand, operand_syntax, _, _, _ = tokens[index + 1]
1403
+ if operand_syntax:
1404
+ sys.exit(2)
1405
+ index += 2
1406
+ if fd is None:
1407
+ fd = 0 if operator.startswith('<') else 1
1408
+ if operator in ('<&', '>&'):
1409
+ if operand == '-':
1410
+ descriptors[fd] = {'kind': 'closed'}
1411
+ elif operand.isdigit():
1412
+ descriptors[fd] = descriptors.get(int(operand), {'kind': 'unknown'})
1413
+ else:
1414
+ sys.exit(2)
1415
+ else:
1416
+ descriptors[fd] = {'kind': 'file', 'path': operand, 'cwd': os.environ['SN_REDIRECT_CWD'], 'unknown': int(os.environ['SN_REDIRECT_UNKNOWN'])}
1417
+ if operator.startswith('&>'):
1418
+ descriptors[2] = descriptors[fd]
1419
+ output = descriptors[1]['kind']
1420
+ if output == 'unknown' or descriptors[0]['kind'] == 'unknown':
1421
+ sys.exit(2)
1422
+ print(json.dumps({'input': descriptors[0], 'output': output, 'descriptors': descriptors}))
1423
+ except (ValueError, OSError):
1424
+ sys.exit(2)
1425
+ PY
1426
+ )" || follow_refuse "compound redirection" "its input or pipeline output could not be determined"
1427
+ follow_redirect_input="$(printf '%s' "$result" | jq -er '.input.kind')" || follow_refuse "compound redirection" "invalid input projection"
1428
+ follow_redirect_path="$(printf '%s' "$result" | jq -r '.input.path // ""')" || follow_refuse "compound redirection" "invalid input path"
1429
+ follow_redirect_map="$(printf '%s' "$result" | jq -ce '.descriptors')" || follow_refuse "compound redirection" "invalid descriptor projection"
1430
+ follow_redirect_input_cwd="$(printf '%s' "$result" | jq -r '.input.cwd // ""')" || follow_refuse "compound redirection" "invalid input cwd"
1431
+ follow_redirect_input_unknown="$(printf '%s' "$result" | jq -r '.input.unknown // 1')" || follow_refuse "compound redirection" "invalid input uncertainty"
1432
+ follow_redirect_output="$(printf '%s' "$result" | jq -er '.output')" || follow_refuse "compound redirection" "invalid output projection"
1433
+ }
1434
+
1435
+ # Walk one text for the files it executes. Runs in the PARENT shell, never in a
1436
+ # command substitution: follow_refuse() must be able to deny, and an exit inside
1437
+ # a subshell ends that subshell rather than this hook — the mistake documented
1438
+ # on the rm segmentation below.
1439
+ follow_scan() {
1440
+ local text="$1" depth="$2"
1441
+ local function_call_depth="${3:-0}"
1442
+ local authenticated_bootstrap="${4:-0}"
1443
+ local invocation_kind="${5:-child}"
1444
+ local function_flow_unknown="${6:-0}" function_lookup_allowed statement_conditional function_conditional=0
1445
+ local and_cwd="" and_unknown=1 and_ready=0 join_cwd join_unknown
1446
+ if [ "$invocation_kind" = source ]; then
1447
+ function_flow_unknown="${follow_source_flow_unknown:-0}"
1448
+ fi
1449
+ local return_scope_depth=-1
1450
+ local return_list_depth=-1
1451
+ local return_flow_unknown=0
1452
+ local list_depth=0 list_index
1453
+ local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
1454
+ local heredoc redirected pipe_cat prev_cat pending_operand stdin_mode
1455
+ local pipe_cwd pipe_unknown prev_cwd prev_unknown
1456
+ local follow_stdin_cat="${follow_stdin_cat:-}" follow_stdin_cwd="${follow_stdin_cwd:-}" follow_stdin_unknown="${follow_stdin_unknown:-1}"
1457
+ local follow_redirect_input follow_redirect_path follow_redirect_output follow_redirect_map follow_redirect_input_cwd follow_redirect_input_unknown
1458
+ local follow_descriptor_map="${follow_descriptor_map:-}"
1459
+ if [ -z "$follow_descriptor_map" ]; then
1460
+ follow_descriptor_map='{"0":{"kind":"input"},"1":{"kind":"pipe"},"2":{"kind":"stderr"}}'
1461
+ fi
1462
+ local find_mode dispatch_pending
1463
+ local scope_depth=0 case_depth=0 function_name="" function_body="" function_index function_found
1464
+ local -a toks=()
1465
+ local -a cwd_scopes=() unknown_scopes=() cat_scopes=() cat_cwd_scopes=() cat_unknown_scopes=() function_scopes=() flow_scopes=() return_flows=()
1466
+ local -a list_cwds=() list_unknowns=() list_functions=() list_flows=() list_asyncs=() list_scope_depths=() list_return_flows=() brace_flows=()
1467
+ local -a brace_descriptor_maps=() scope_descriptor_maps=()
1468
+ local -a brace_stdin_cats=() brace_stdin_cwds=() brace_stdin_unknowns=() scope_stdin_cats=() scope_stdin_cwds=() scope_stdin_unknowns=()
1469
+ local -a case_cwds=() case_unknowns=() case_changes=()
1470
+ if [ "$function_call_depth" -eq 0 ] && [ "$invocation_kind" != source ]; then
1471
+ # Ordinary child shells do not inherit unexported functions. Sourcing and
1472
+ # function calls share the caller's definitions instead.
1473
+ local function_count=0
1474
+ local -a function_names=() function_bodies=() function_uncertainties=()
1475
+ fi
1476
+ split_out="$(follow_split "$text")" || follow_refuse "shell scope" "scope parsing exceeds the inspection budget"
1477
+ prev_cat=""
1478
+ while IFS= read -r line; do
1479
+ sep="${line%%$'\t'*}"
1480
+ stmt="${line#*$'\t'}"
1481
+ statement_conditional=0
1482
+ case "$sep" in '&' | '?') statement_conditional=1 ;; esac
1483
+ # A reached && successor observes the preceding successful command's cwd.
1484
+ # Scope/list events invalidate this projection; it must never carry a
1485
+ # child's or a background list's directory into another shell/list.
1486
+ case "$sep" in
1487
+ L | G | H | B | N | F | C | A | K | '(' | '[' | ')' | ']') and_ready=0 ;;
1488
+ esac
1489
+ if [ "$return_list_depth" -ge 0 ]; then
1490
+ case "$sep" in
1491
+ B) [ "$list_depth" -eq "$return_list_depth" ] || continue ;;
1492
+ '(' | '[' | ')' | ']' | G | H) ;;
1493
+ *) continue ;;
1494
+ esac
1495
+ fi
1496
+ if [ "$return_scope_depth" -ge 0 ]; then
1497
+ # A return in a child scope ends that child, not its enclosing function.
1498
+ # Keep only scope events until the matching close restores the parent.
1499
+ case "$sep" in '(' | '[' | ')' | ']' | G | H) ;; *) continue ;; esac
1500
+ fi
1501
+ case "$sep" in
1502
+ L)
1503
+ list_cwds[list_depth]="$follow_cwd"
1504
+ list_unknowns[list_depth]="$follow_cwd_unknown"
1505
+ list_functions[list_depth]=$function_count
1506
+ list_flows[list_depth]=$function_flow_unknown
1507
+ list_asyncs[list_depth]="$stmt"
1508
+ list_scope_depths[list_depth]=$scope_depth
1509
+ list_return_flows[list_depth]=$return_flow_unknown
1510
+ continue
1511
+ ;;
1512
+ G)
1513
+ brace_flows[list_depth]=$function_flow_unknown
1514
+ brace_descriptor_maps[list_depth]="$follow_descriptor_map"
1515
+ brace_stdin_cats[list_depth]="$follow_stdin_cat"
1516
+ brace_stdin_cwds[list_depth]="$follow_stdin_cwd"
1517
+ brace_stdin_unknowns[list_depth]="$follow_stdin_unknown"
1518
+ if [ "$stmt" = "|" ]; then
1519
+ follow_stdin_cat="$prev_cat"; follow_stdin_cwd="${prev_cwd:-}"; follow_stdin_unknown="${prev_unknown:-1}"
1520
+ fi
1521
+ case "$stmt" in '&' | '?') function_flow_unknown=1 ;; esac
1522
+ list_depth=$((list_depth + 1)); continue
1523
+ ;;
1524
+ H)
1525
+ list_depth=$((list_depth - 1))
1526
+ function_flow_unknown="${brace_flows[list_depth]}"
1527
+ follow_descriptor_map="${brace_descriptor_maps[list_depth]}"
1528
+ follow_stdin_cat="${brace_stdin_cats[list_depth]}"
1529
+ follow_stdin_cwd="${brace_stdin_cwds[list_depth]}"
1530
+ follow_stdin_unknown="${brace_stdin_unknowns[list_depth]}"
1531
+ # A conditional return may exit this function/source before commands
1532
+ # after the brace. Unlike a child return, that uncertainty persists.
1533
+ if [ "$return_flow_unknown" -ne 0 ]; then function_flow_unknown=1; fi
1534
+ continue
1535
+ ;;
1536
+ B)
1537
+ # A single & runs the whole AND/OR list asynchronously. Its cwd and
1538
+ # definitions cannot escape into the parent's next list.
1539
+ follow_cwd="${list_cwds[list_depth]}"
1540
+ follow_cwd_unknown="${list_unknowns[list_depth]}"
1541
+ function_flow_unknown="${list_flows[list_depth]}"
1542
+ return_flow_unknown="${list_return_flows[list_depth]}"
1543
+ return_list_depth=-1
1544
+ while [ "$function_count" -gt "${list_functions[list_depth]}" ]; do
1545
+ function_count=$((function_count - 1))
1546
+ function_index=$function_count
1547
+ unset 'function_names[function_index]' 'function_bodies[function_index]' 'function_uncertainties[function_index]'
1548
+ done
1549
+ continue
1550
+ ;;
1551
+ N)
1552
+ function_name="${stmt#*$'\t'}"; function_body=""
1553
+ function_conditional="$function_flow_unknown"
1554
+ case "${stmt%%$'\t'*}" in '&' | '?') function_conditional=1 ;; esac
1555
+ continue
1556
+ ;;
1557
+ D)
1558
+ if [ "$authenticated_bootstrap" -eq 0 ]; then
1559
+ function_body="$function_body$stmt"$'\n'
1560
+ fi
1561
+ continue
1562
+ ;;
1563
+ F)
1564
+ [ "$authenticated_bootstrap" -eq 0 ] || continue
1565
+ function_index=$function_count
1566
+ function_names[function_index]="$function_name"
1567
+ function_bodies[function_index]="$function_body"
1568
+ function_uncertainties[function_index]="$function_conditional"
1569
+ function_count=$((function_count + 1))
1570
+ continue
1571
+ ;;
1572
+ C)
1573
+ function_flow_unknown=1
1574
+ case_cwds[case_depth]="$follow_cwd"
1575
+ case_unknowns[case_depth]="$follow_cwd_unknown"
1576
+ case_changes[case_depth]=0
1577
+ case_depth=$((case_depth + 1))
1578
+ continue
1579
+ ;;
1580
+ A | K)
1581
+ function_index=$((case_depth - 1))
1582
+ if [ "$follow_cwd" != "${case_cwds[function_index]}" ] || [ "$follow_cwd_unknown" != "${case_unknowns[function_index]}" ]; then
1583
+ case_changes[function_index]=1
1584
+ fi
1585
+ follow_cwd="${case_cwds[function_index]}"
1586
+ follow_cwd_unknown="${case_unknowns[function_index]}"
1587
+ if [ "$sep" = K ]; then
1588
+ if [ "${case_changes[function_index]}" -ne 0 ]; then
1589
+ follow_cwd=""; follow_cwd_unknown=1
1590
+ fi
1591
+ case_depth=$((case_depth - 1))
1592
+ fi
1593
+ continue
1594
+ ;;
1595
+ '(' | '[')
1596
+ cwd_scopes[scope_depth]="$follow_cwd"
1597
+ unknown_scopes[scope_depth]="$follow_cwd_unknown"
1598
+ cat_scopes[scope_depth]="$prev_cat"
1599
+ cat_cwd_scopes[scope_depth]="${prev_cwd:-}"
1600
+ cat_unknown_scopes[scope_depth]="${prev_unknown:-1}"
1601
+ scope_descriptor_maps[scope_depth]="$follow_descriptor_map"
1602
+ function_scopes[scope_depth]=$function_count
1603
+ flow_scopes[scope_depth]=$function_flow_unknown
1604
+ return_flows[scope_depth]=$return_flow_unknown
1605
+ scope_stdin_cats[scope_depth]="$follow_stdin_cat"
1606
+ scope_stdin_cwds[scope_depth]="$follow_stdin_cwd"
1607
+ scope_stdin_unknowns[scope_depth]="$follow_stdin_unknown"
1608
+ if [ "$stmt" = "|" ] || [ "$stmt" = "|+" ]; then
1609
+ follow_stdin_cat="$prev_cat"; follow_stdin_cwd="${prev_cwd:-}"; follow_stdin_unknown="${prev_unknown:-1}"
1610
+ follow_descriptor_map="$(printf '%s' "$follow_descriptor_map" | jq -ce '.["0"] = {kind:"input"}')" || follow_refuse "pipeline input" "its descriptor could not be established"
1611
+ fi
1612
+ case "$stmt" in
1613
+ *+)
1614
+ follow_descriptor_map="$(printf '%s' "$follow_descriptor_map" | jq -ce '.["1"] = {kind:"pipe"}')" || follow_refuse "pipeline output" "its descriptor could not be established"
1615
+ ;;
1616
+ esac
1617
+ scope_depth=$((scope_depth + 1))
1618
+ list_depth=$((list_depth + 1))
1619
+ continue
1620
+ ;;
1621
+ ')' | ']')
1622
+ scope_depth=$((scope_depth - 1))
1623
+ list_depth=$((list_depth - 1))
1624
+ follow_cwd="${cwd_scopes[scope_depth]}"
1625
+ follow_descriptor_map="${scope_descriptor_maps[scope_depth]}"
1626
+ follow_cwd_unknown="${unknown_scopes[scope_depth]}"
1627
+ function_flow_unknown="${flow_scopes[scope_depth]}"
1628
+ return_flow_unknown="${return_flows[scope_depth]}"
1629
+ follow_stdin_cat="${scope_stdin_cats[scope_depth]}"
1630
+ follow_stdin_cwd="${scope_stdin_cwds[scope_depth]}"
1631
+ follow_stdin_unknown="${scope_stdin_unknowns[scope_depth]}"
1632
+ while [ "$function_count" -gt "${function_scopes[scope_depth]}" ]; do
1633
+ function_count=$((function_count - 1))
1634
+ function_index=$function_count
1635
+ unset 'function_names[function_index]' 'function_bodies[function_index]' 'function_uncertainties[function_index]'
1636
+ done
1637
+ if [ "$sep" = ']' ]; then
1638
+ prev_cat="${cat_scopes[scope_depth]}"
1639
+ prev_cwd="${cat_cwd_scopes[scope_depth]}"
1640
+ prev_unknown="${cat_unknown_scopes[scope_depth]}"
1641
+ fi
1642
+ if [ "$return_scope_depth" -ge 0 ] && [ "$scope_depth" -lt "$return_scope_depth" ]; then
1643
+ return_scope_depth=-1
1644
+ fi
1645
+ continue
1646
+ ;;
1647
+ I)
1648
+ follow_redirect_io "$stmt" compound
1649
+ follow_descriptor_map="$follow_redirect_map"
1650
+ case "$follow_redirect_input" in
1651
+ file)
1652
+ follow_stdin_cat="$follow_redirect_path"
1653
+ follow_stdin_cwd="$follow_redirect_input_cwd"; follow_stdin_unknown="$follow_redirect_input_unknown"
1654
+ ;;
1655
+ closed) follow_stdin_cat="" ;;
1656
+ input) ;;
1657
+ *) follow_refuse "compound redirection" "its input source could not be determined" ;;
1658
+ esac
1659
+ continue
1660
+ ;;
1661
+ R)
1662
+ # The whole compound tail was applied before its body by I. Its cat
1663
+ # producer already records whether its effective output reaches pipe.
1664
+ continue
1665
+ ;;
1666
+ esac
1667
+ join_cwd="$follow_cwd"; join_unknown="$follow_cwd_unknown"
1668
+ if [ "$sep" = '&' ] && [ "$and_ready" -eq 1 ]; then
1669
+ follow_cwd="$and_cwd"; follow_cwd_unknown="$and_unknown"
1670
+ fi
1671
+ and_ready=0
1672
+ pipe_cat="$follow_stdin_cat"
1673
+ pipe_cwd="$follow_stdin_cwd"
1674
+ pipe_unknown="$follow_stdin_unknown"
1675
+ if [ "$sep" = "|" ]; then
1676
+ pipe_cat="$prev_cat"
1677
+ pipe_cwd="${prev_cwd:-}"
1678
+ pipe_unknown="${prev_unknown:-1}"
1679
+ fi
1680
+ prev_cat=""
1681
+ case "$stmt" in
1682
+ *[![:space:]]*) ;;
1683
+ *) continue ;;
1684
+ esac
1685
+ set -f
1686
+ # The word split is the point: this is shell tokenization, and the glob
1687
+ # guard above keeps a literal `*` from expanding against the hook's cwd.
1688
+ # shellcheck disable=SC2206
1689
+ toks=($stmt)
1690
+ set +f
1691
+ n=${#toks[@]}
1692
+ [ "$n" -gt 0 ] || continue
1693
+ i=0
1694
+ cmd_pos=1
1695
+ function_lookup_allowed=1
1696
+ eval_mode=0
1697
+ arg_exec=0
1698
+ pending_operand=0
1699
+ find_mode=0
1700
+ dispatch_pending=0
1701
+ while [ "$i" -lt "$n" ]; do
1702
+ # Only a COMMAND POSITION can execute something. A path anywhere else is
1703
+ # an argument, and an argument is data (#3604).
1704
+ #
1705
+ # The one exception is a dispatcher that BUILDS a command out of its own
1706
+ # arguments: `find … -exec bash {} \;` and `xargs bash` put an interpreter
1707
+ # at a command position the text does not spell as one. Scoping the
1708
+ # exception to a `find`/`xargs` statement is what keeps it from becoming
1709
+ # "an interpreter name anywhere is an invocation", which would read
1710
+ # `echo "run bash <file>"` as one.
1711
+ if [ "$cmd_pos" -ne 1 ]; then
1712
+ # A dispatcher BUILDS a command out of its own arguments, putting an
1713
+ # interpreter at a command position the text does not spell as one.
1714
+ # That position is exactly ONE token: the word after `find -exec` or
1715
+ # `-execdir`, and the first operand of `xargs`.
1716
+ #
1717
+ # Latching "any interpreter NAME until the statement ends" instead was
1718
+ # a false positive, because an interpreter name is far more often data
1719
+ # than a command: it refused `find . -name bash`, where `bash` is a
1720
+ # filename PATTERN, and `find … | xargs grep bash`, where it is a
1721
+ # search term. Both are ordinary commands that delete nothing.
1722
+ if [ "$dispatch_pending" -ne 1 ]; then
1723
+ if [ "$find_mode" -eq 1 ]; then
1724
+ follow_unwrap "${toks[i]}"
1725
+ case "$follow_unwrapped" in
1726
+ -exec | -execdir) dispatch_pending=1 ;;
1727
+ esac
1728
+ fi
1729
+ i=$((i + 1))
1730
+ continue
1731
+ fi
1732
+ follow_unwrap "${toks[i]}"
1733
+ t="$follow_unwrapped"
1734
+ # `xargs -n1 bash` — the dispatcher's own options precede its command.
1735
+ case "$t" in
1736
+ -*)
1737
+ i=$((i + 1))
1738
+ continue
1739
+ ;;
1740
+ esac
1741
+ dispatch_pending=0
1742
+ cmd_pos=1
1743
+ else
1744
+ follow_unwrap "${toks[i]}"
1745
+ t="$follow_unwrapped"
1746
+ fi
1747
+ function_found=0
1748
+ for ((function_index = function_count - 1; function_lookup_allowed && function_index >= 0; function_index--)); do
1749
+ if [ "$t" = "${function_names[function_index]}" ]; then
1750
+ [ "${function_uncertainties[function_index]}" -eq 0 ] || follow_refuse "$t" "its function definition is conditional"
1751
+ # Calls execute in this shell and do not consume a file-follow depth.
1752
+ # A separate bound refuses recursion without truncating the body.
1753
+ [ "$function_call_depth" -lt "$FOLLOW_MAX_FUNCTION_DEPTH" ] || follow_refuse "$t" "function calls exceed the inspection depth"
1754
+ follow_function_calls=$((follow_function_calls + 1))
1755
+ follow_function_bytes=$((follow_function_bytes + ${#function_bodies[function_index]}))
1756
+ if [ "$follow_function_calls" -gt "$FOLLOW_MAX_FUNCTION_CALLS" ] || [ "$follow_function_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
1757
+ follow_refuse "$t" "function expansion exceeds the inspection budget"
1758
+ fi
1759
+ local call_cwd="$follow_cwd" call_unknown="$follow_cwd_unknown"
1760
+ follow_scan "${function_bodies[function_index]}" "$depth" "$((function_call_depth + 1))" 0 child "$((function_flow_unknown || statement_conditional))"
1761
+ if { [ "$function_flow_unknown" -ne 0 ] || [ "$statement_conditional" -ne 0 ]; } && { [ "$follow_cwd" != "$call_cwd" ] || [ "$follow_cwd_unknown" != "$call_unknown" ]; }; then
1762
+ follow_cwd=""; follow_cwd_unknown=1
1763
+ fi
1764
+ function_found=1
1765
+ break
1766
+ fi
1767
+ done
1768
+ if [ "$function_found" -eq 1 ]; then
1769
+ cmd_pos=0; i=$((i + 1)); continue
1770
+ fi
1771
+ case "$t" in
1772
+ return)
1773
+ if [ "$statement_conditional" -ne 0 ]; then function_flow_unknown=1; fi
1774
+ if { [ "$function_call_depth" -gt 0 ] || [ "$invocation_kind" = source ]; } && [ "$function_flow_unknown" -ne 0 ]; then
1775
+ return_flow_unknown=1
1776
+ fi
1777
+ if { [ "$function_call_depth" -gt 0 ] || [ "$invocation_kind" = source ]; } && [ "$function_flow_unknown" -eq 0 ]; then
1778
+ # Ordinary braces share the shell but have distinct list frames.
1779
+ # A surrounding background brace list ends only its child; do not
1780
+ # cross a literal subshell boundary while finding that parent list.
1781
+ for ((list_index = list_depth; list_index >= 0; list_index--)); do
1782
+ [ "${list_scope_depths[list_index]}" -eq "$scope_depth" ] || break
1783
+ if [ "${list_asyncs[list_index]}" -ne 0 ]; then
1784
+ return_list_depth="$list_index"
1785
+ break
1786
+ fi
1787
+ done
1788
+ if [ "$return_list_depth" -ge 0 ]; then
1789
+ :
1790
+ elif [ "$scope_depth" -eq 0 ]; then
1791
+ return 0
1792
+ else
1793
+ return_scope_depth="$scope_depth"
1794
+ fi
1795
+ fi
1796
+ cmd_pos=0; i=$((i + 1)); continue
1797
+ ;;
1798
+ if | while | until)
1799
+ function_flow_unknown=1
1800
+ i=$((i + 1)); continue
1801
+ ;;
1802
+ for | select)
1803
+ function_flow_unknown=1
1804
+ cmd_pos=0; i=$((i + 1)); continue
1805
+ ;;
1806
+ # Prefixes that keep the command position open: assignments, wrappers,
1807
+ # and the shell keywords a statement can start with. Without them
1808
+ # `if …; then bash <file>` and `env bash <file>` walk past the
1809
+ # invocation.
1810
+ "" | [A-Za-z_]*=* | --*) i=$((i + 1)); continue ;;
1811
+ -*)
1812
+ # A SHORT option inside a wrapper prefix can take a separate VALUE,
1813
+ # and that value is not a command. `nice -n 5 bash <file>` and
1814
+ # `sudo -u nobody bash <file>` put `5` and `nobody` where the walk
1815
+ # expected the program, so the invocation was never reached and the
1816
+ # file was never scanned — the same fail-open this whole block exists
1817
+ # to close, one layer along. Step the value over.
1818
+ #
1819
+ # But YIELD whenever the next token could itself be the invocation,
1820
+ # so `env -i bash <file>` still reads `bash` as the program: `-i`
1821
+ # takes no value, and consuming one would lose the interpreter.
1822
+ #
1823
+ # Long options are deliberately left alone. Their value is spelled
1824
+ # with `=`, and a `--foo <path> bash <file>` is rare enough that
1825
+ # consuming the operand would cost more than it buys.
1826
+ i=$((i + 1))
1827
+ if [ "$i" -lt "$n" ]; then
1828
+ follow_unwrap "${toks[i]}"
1829
+ follow_may_invoke "$follow_unwrapped" || i=$((i + 1))
1830
+ fi
1831
+ continue
1832
+ ;;
1833
+ timeout | */timeout)
1834
+ # `timeout` takes a mandatory DURATION before the program. A bare
1835
+ # number is not an option, so the short-option arm above cannot see
1836
+ # it. Mark the operand pending and let the fall-through consume it,
1837
+ # which keeps working when options sit in between — `timeout -s KILL
1838
+ # 5 bash <file>` reaches `bash` just as `timeout 5 bash <file>` does.
1839
+ pending_operand=1
1840
+ function_lookup_allowed=0
1841
+ i=$((i + 1))
1842
+ continue
1843
+ ;;
1844
+ sudo | env | command | exec | nohup | nice | time | builtin \
1845
+ | do | then | else | '!' | '{' \
1846
+ | */sudo | */env | */nohup | */nice | */time)
1847
+ case "$t" in
1848
+ sudo | env | command | exec | nohup | nice | builtin | */sudo | */env | */nohup | */nice) function_lookup_allowed=0 ;;
1849
+ do | then | else) function_flow_unknown=1 ;;
1850
+ esac
1851
+ i=$((i + 1))
1852
+ continue
1853
+ ;;
1854
+ eval)
1855
+ # `eval` executes its ARGUMENTS as shell, so a file whose contents are
1856
+ # interpolated into them is executed text.
1857
+ eval_mode=1
1858
+ i=$((i + 1))
1859
+ continue
1860
+ ;;
1861
+ find | */find)
1862
+ # `find` only builds a command after `-exec`/`-execdir`. Every other
1863
+ # operand is a path, a predicate, or a pattern.
1864
+ arg_exec=1
1865
+ find_mode=1
1866
+ cmd_pos=0
1867
+ i=$((i + 1))
1868
+ continue
1869
+ ;;
1870
+ xargs | */xargs)
1871
+ # `xargs` takes its command as the first non-option operand; anything
1872
+ # after that is an argument TO that command, not another command.
1873
+ arg_exec=1
1874
+ dispatch_pending=1
1875
+ cmd_pos=0
1876
+ i=$((i + 1))
1877
+ continue
1878
+ ;;
1879
+ cd)
1880
+ if [ $((i + 1)) -lt "$n" ]; then
1881
+ follow_unwrap "${toks[i + 1]}"
1882
+ follow_cd "$follow_unwrapped"
1883
+ else
1884
+ # A bare `cd` is `cd "$HOME"`, and leaving the previous directory in
1885
+ # place would resolve later tokens from a directory the shell left.
1886
+ follow_cd "${HOME:-}"
1887
+ fi
1888
+ if [ "$function_flow_unknown" -ne 0 ]; then
1889
+ follow_cwd=""; follow_cwd_unknown=1
1890
+ fi
1891
+ cmd_pos=0
1892
+ i=$((i + 1))
1893
+ continue
1894
+ ;;
1895
+ source | .)
1896
+ follow_next_operand "$i" "$n"
1897
+ j="$follow_operand_index"
1898
+ if [ "$j" -lt "$n" ]; then
1899
+ follow_unwrap "${toks[j]}"
1900
+ local source_cwd="$follow_cwd" source_unknown="$follow_cwd_unknown"
1901
+ follow_source_target "$follow_unwrapped" "$depth" "$((function_flow_unknown || statement_conditional))"
1902
+ if { [ "$function_flow_unknown" -ne 0 ] || [ "$statement_conditional" -ne 0 ]; } && { [ "$follow_cwd" != "$source_cwd" ] || [ "$follow_cwd_unknown" != "$source_unknown" ]; }; then
1903
+ follow_cwd=""; follow_cwd_unknown=1
1904
+ fi
1905
+ fi
1906
+ cmd_pos=0
1907
+ i=$((j + 1))
1908
+ continue
1909
+ ;;
1910
+ cat | '<')
1911
+ follow_next_operand "$i" "$n"
1912
+ j="$follow_operand_index"
1913
+ follow_unwrapped=""
1914
+ if [ "$j" -lt "$n" ]; then follow_unwrap "${toks[j]}"; fi
1915
+ if [ "$t" = cat ] && { [ "$j" -ge "$n" ] || [ "$follow_unwrapped" = - ]; }; then
1916
+ # Bare cat and cat - forward the group's named stdin unchanged.
1917
+ prev_cat="$pipe_cat"; prev_cwd="$pipe_cwd"; prev_unknown="$pipe_unknown"
1918
+ if [ "$eval_mode" -eq 1 ] && [ -n "$prev_cat" ]; then
1919
+ follow_pipe_target "$prev_cat" "$depth" "$prev_cwd" "$prev_unknown"
1920
+ fi
1921
+ elif [ "$j" -lt "$n" ]; then
1922
+ if [ "$eval_mode" -eq 1 ]; then
1923
+ follow_target "$follow_unwrapped" "$depth"
1924
+ else
1925
+ # Not executed here — the pipe consumer decides whether the
1926
+ # producer's file content becomes an executable shell input.
1927
+ prev_cat="$follow_unwrapped"
1928
+ prev_cwd="$follow_cwd"; prev_unknown="$follow_cwd_unknown"
1929
+ fi
1930
+ fi
1931
+ if [ -n "$prev_cat" ] && [ "$eval_mode" -eq 0 ]; then
1932
+ if [[ "$stmt" == *'>'* ]] || [ "$follow_descriptor_map" != '{"0":{"kind":"input"},"1":{"kind":"pipe"},"2":{"kind":"stderr"}}' ]; then
1933
+ follow_redirect_io "$stmt" cat
1934
+ case "$follow_redirect_output" in
1935
+ pipe) ;;
1936
+ file | closed | stderr | input) prev_cat="" ;;
1937
+ *) follow_refuse "cat redirection" "its output destination could not be determined" ;;
1938
+ esac
1939
+ fi
1940
+ fi
1941
+ cmd_pos=0
1942
+ i=$((j + 1))
1943
+ continue
1944
+ ;;
1945
+ esac
1946
+ if follow_is_interpreter "$t"; then
1947
+ inline=0
1948
+ redirected=""
1949
+ heredoc=0
1950
+ stdin_mode=0
1951
+ noexec=0
1952
+ j=$((i + 1))
1953
+ while [ "$j" -lt "$n" ]; do
1954
+ follow_unwrap "${toks[j]}"
1955
+ tj="$follow_unwrapped"
1956
+ case "$tj" in
1957
+ --) j=$((j + 1)); break ;;
1958
+ # A heredoc or here-string payload is IN the command text, which the
1959
+ # guards already read. It is not a file, and treating it as one
1960
+ # refused `bash <<EOF` outright.
1961
+ '<<'*) heredoc=1; break ;;
1962
+ # `bash < <file>` executes the file just as `bash <file>` does.
1963
+ '<')
1964
+ j=$((j + 1))
1965
+ if [ "$j" -lt "$n" ]; then
1966
+ follow_unwrap "${toks[j]}"
1967
+ redirected="$follow_unwrapped"
1968
+ fi
1969
+ break
1970
+ ;;
1971
+ '<'*) redirected="${tj#<}"; break ;;
1972
+ # An output redirection names a DESTINATION, never the script. Step
1973
+ # over it (and its target when the operator stands alone) so the
1974
+ # operand scan does not stop on `>` and read `/dev/null` as a
1975
+ # script it cannot resolve.
1976
+ *'>'*)
1977
+ case "$tj" in
1978
+ *'>') j=$((j + 2)) ;;
1979
+ *) j=$((j + 1)) ;;
1980
+ esac
1981
+ ;;
1982
+ # `-n` is NOEXEC: the shell reads and parses its operand, then exits
1983
+ # without running a line of it. So a syntax check is the one shape
1984
+ # that puts a path at a command position while provably executing
1985
+ # nothing, and following it reads a file the command never runs.
1986
+ #
1987
+ # This guard hit that on ITSELF: its pattern list is destructive
1988
+ # command literals living inside the guard, so `bash -n` on its own
1989
+ # source was refused by its own patterns — and the same held for any
1990
+ # file documenting destructive commands. See CodySwannGT/lisa#3803.
1991
+ #
1992
+ # `bash <file>` still executes and is still followed. Noexec is the
1993
+ # distinction, not the program: this is not a read-only exemption.
1994
+ --noexec) noexec=1; break ;;
1995
+ --*) j=$((j + 1)) ;;
1996
+ -*c*) inline=1; j=$((j + 1)) ;;
1997
+ -*n*) noexec=1; break ;;
1998
+ -s | -[a-zA-Z]*s[a-zA-Z]*) stdin_mode=1; j=$((j + 1)) ;;
1999
+ -*) j=$((j + 1)) ;;
2000
+ *) break ;;
2001
+ esac
2002
+ done
2003
+ if [ "$noexec" -eq 1 ]; then
2004
+ # Nothing runs, so there is no script to follow and nothing to fail
2005
+ # closed about. Checked before the heredoc and dispatcher arms: those
2006
+ # decide WHICH text to read, and here there is no execution at all.
2007
+ cmd_pos=0
2008
+ i=$((j + 1))
2009
+ continue
2010
+ fi
2011
+ if [ "$heredoc" -eq 1 ] && [ "$inline" -eq 0 ]; then
2012
+ cmd_pos=0
2013
+ i=$((j + 1))
2014
+ continue
2015
+ fi
2016
+ if [ -n "$redirected" ]; then
2017
+ follow_target "$redirected" "$depth"
2018
+ cmd_pos=0
2019
+ i=$((j + 1))
2020
+ continue
2021
+ fi
2022
+ if [ "$inline" -eq 1 ]; then
2023
+ # The command string runs in a child shell. It gets its own function
2024
+ # map and cwd effects; positional arguments are data, not commands.
2025
+ follow_inline "$stmt" "$j" "$depth"
2026
+ cmd_pos=0
2027
+ i="$n"
2028
+ continue
2029
+ fi
2030
+ if [ "$stdin_mode" -eq 1 ]; then
2031
+ # `sh -s -- -y` reads its program from stdin; tokens after `--` are
2032
+ # positional arguments to that program, never a script path.
2033
+ [ -z "$pipe_cat" ] || follow_pipe_target "$pipe_cat" "$depth" "$pipe_cwd" "$pipe_unknown"
2034
+ cmd_pos=0
2035
+ i="$n"
2036
+ continue
2037
+ fi
2038
+ if [ "$j" -ge "$n" ]; then
2039
+ # A dispatcher supplies the script from ANOTHER command's output, so
2040
+ # the operand exists but is unreadable here. That is the fail-closed
2041
+ # case, not the interactive one.
2042
+ if [ "$arg_exec" -eq 1 ]; then
2043
+ follow_refuse "$t" "a dispatcher supplies its script from another command's output"
2044
+ fi
2045
+ # No operand: an interactive or stdin-fed interpreter. Only the
2046
+ # `cat <file> | bash` shape names a FILE, and only that is followed.
2047
+ [ -z "$pipe_cat" ] || follow_pipe_target "$pipe_cat" "$depth" "$pipe_cwd" "$pipe_unknown"
2048
+ cmd_pos=0
2049
+ i="$j"
2050
+ continue
2051
+ fi
2052
+ follow_unwrap "${toks[j]}"
2053
+ tj="$follow_unwrapped"
2054
+ if ! follow_supervisor "$tj" "$depth" "$stmt"; then
2055
+ follow_target "$tj" "$depth"
2056
+ fi
2057
+ cmd_pos=0
2058
+ i=$((j + 1))
2059
+ continue
2060
+ fi
2061
+ if [ "$pending_operand" -eq 1 ]; then
2062
+ # A wrapper's own mandatory operand (a `timeout` duration), not the
2063
+ # program it wraps. Consume it and leave the command position OPEN so
2064
+ # the walk carries on to the invocation. Yields on anything that could
2065
+ # itself be the program, for the reason follow_may_invoke documents.
2066
+ pending_operand=0
2067
+ if ! follow_may_invoke "$t"; then
2068
+ i=$((i + 1))
2069
+ continue
2070
+ fi
2071
+ fi
2072
+ # The tilde arm is LITERAL text, not a path this line expands:
2073
+ # follow_locate turns it into one.
2074
+ # shellcheck disable=SC2088
2075
+ case "$t" in
2076
+ /* | ./* | ../* | '~/'* | */*) follow_direct "$t" "$depth" ;;
2077
+ esac
2078
+ cmd_pos=0
2079
+ i=$((i + 1))
2080
+ done
2081
+ # Keep successful-branch provenance separate from the continuation join.
2082
+ # OR can skip its RHS and still reach a following &&, so it cannot prove
2083
+ # that RHS's cwd. A semicolon observes every possible preceding outcome.
2084
+ and_cwd="$follow_cwd"; and_unknown="$follow_cwd_unknown"
2085
+ if [ "$sep" != '?' ] && [ "$function_flow_unknown" -eq 0 ]; then and_ready=1; fi
2086
+ if [ "$statement_conditional" -ne 0 ] && { [ "$join_unknown" -ne 0 ] || [ "$follow_cwd_unknown" -ne 0 ] || [ "$follow_cwd" != "$join_cwd" ]; }; then
2087
+ follow_cwd=""; follow_cwd_unknown=1
2088
+ fi
2089
+ done <<<"$split_out"
2090
+ }
2091
+
2092
+ follow_scan "$command_for_guards" 0
2093
+ rm_command_for_guards="$command_for_guards"$'\n'"$followed_rm_text"
2094
+ if [ -n "$follow_names" ]; then
2095
+ command_for_guards="$command_for_guards"$'\n'"$followed_text"
2096
+ # A refusal must say WHERE the match is. Without this the operator reads
2097
+ # advice about rewording a quoted string for a line they never typed.
2098
+ DESTRUCTIVE_GUIDANCE="$DESTRUCTIVE_GUIDANCE
2099
+
2100
+ The match may be inside a file this command EXECUTES rather than in the command
2101
+ you typed. These scripts were read and scanned as part of it:$follow_names
2102
+ Edit the file, or ask the user to confirm and run it manually outside the agent."
2103
+ fi
2104
+
2105
+ # Normalize bash line-continuations (a trailing backslash + newline → space)
2106
+ # before segmenting the command. Without this, "git push --force origin
2107
+ # \<newline>main" splits into a segment matching --force but not `main`, letting a
2108
+ # protected force-push slip past. Uses awk (POSIX) instead of a GNU-only
2109
+ # `sed ':a;N;$!ba;…'`, which errors on BSD sed (macOS) and there silently no-ops.
2110
+ normalized_command_str="$(printf '%s' "$command_for_guards" \
2111
+ | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
2112
+ rm_normalized_command_str="$(printf '%s' "$rm_command_for_guards" \
2113
+ | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
2114
+
2115
+ # matches / matches_cs run an ERE against the guarded command text. matches is
2116
+ # case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
2117
+ # for guards where flag case is meaningful (`git branch -d` vs `-D`).
2118
+ #
2119
+ # Both read the NORMALIZED text. They used to read $command_for_guards, so only
2120
+ # the two segment-walking guards (rm, git push) were protected against line
2121
+ # continuations and every guard expressed through these helpers could be evaded
2122
+ # by breaking the command across lines:
2123
+ #
2124
+ # git reset --hard \
2125
+ # origin/main
2126
+ #
2127
+ # The patterns match on intermediate whitespace, and an embedded "\<newline>" is
2128
+ # not whitespace to grep -E, so the anchor failed to span the break. Defined
2129
+ # after the normalization above so the value exists when these are first called.
2130
+ matches() {
2131
+ scan -Ei "$normalized_command_str" "$1"
2132
+ }
2133
+ matches_cs() {
2134
+ scan -E "$normalized_command_str" "$1"
2135
+ }
2136
+
2137
+ # Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
2138
+ # crossing a statement separator, so a flag in a LATER statement can never be
2139
+ # attributed to an earlier git subcommand.
2140
+ readonly GIT_TOKENS='([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+'
2141
+ # Git GLOBAL options (`-C <path>`, `-c <k>=<v>`, `--git-dir[=…]`, `--no-pager`,
2142
+ # …) legally sit between `git` and its subcommand, so every subcommand guard
2143
+ # consumes them — otherwise `git -C /path checkout -- f` dodges the anchor
2144
+ # (issue #1960 security review F1). Shape: zero or more dash-led tokens, each
2145
+ # optionally followed by ONE non-dash value token, which covers both the
2146
+ # `--git-dir=/x` and `--git-dir /x` spellings without naming every option.
2147
+ readonly GIT_GLOBAL_OPTS='(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*'
2148
+ # Anchor for every git subcommand guard: word-bounded `git`, whitespace, then
2149
+ # any run of global options. Callers append the subcommand name directly.
2150
+ readonly GIT_CMD='(^|[^[:alnum:]_-])git[[:space:]]+'"$GIT_GLOBAL_OPTS"
2151
+ # An rm invocation: bare `rm` or a path whose basename is exactly `rm`
2152
+ # (`/bin/rm`, `./rm`) — issue #1960 security review F2. The optional prefix must
2153
+ # END IN `/` immediately before `rm`, so charm/confirm/informant/rmdir never
2154
+ # match. The preceding-char class still excludes `.`, `/`, and `-` to keep
2155
+ # `--rm`-style flags and `foo.rm` names out.
2156
+ readonly RM_CMD='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?rm'
2157
+ # Where a recursive-forced flag cluster is allowed to END.
2158
+ #
2159
+ # It used to be `([[:space:]]|$)` — whitespace or end of segment — and a closing
2160
+ # parenthesis is neither, so a SUBSHELL-wrapped pipeline fell out of every guard
2161
+ # built on these two patterns while its brace-wrapped twin was caught: `{ find
2162
+ # .git -type d | parallel rm -rf; }` matched because the `;` supplied the
2163
+ # boundary, and `(find .git -type d | parallel rm -rf)` did not (issue #3494).
2164
+ #
2165
+ # So the two STATEMENT-GROUPING terminators join whitespace here, and nothing
2166
+ # else. The general spelling — "any character that cannot be part of a flag",
2167
+ # `([^[:alnum:]-]|$)` — is the more principled statement of the intent, and is
2168
+ # deliberately NOT used: these patterns feed the rm TARGET WALK, the guard with
2169
+ # this file's longest false-positive history, and it would newly match ordinary
2170
+ # prose punctuation (`echo "rm -rf, then …"` on the comma). Paren and semicolon
2171
+ # buy the brace/paren parity the defect is about and buy nothing else.
2172
+ #
2173
+ # The cluster must still END at the boundary, so `-rfoo` is not read as `-rf`.
2174
+ readonly RM_RF_END='([[:space:];)]|$)'
2175
+ # rm invoked with BOTH a recursive and a force flag — clustered (-rf/-fr, any
2176
+ # extra letters) or split, in either order. The split gate pairs ANY recursive
2177
+ # form (short cluster containing r, or --recursive) with ANY force form (short
2178
+ # cluster containing f, or --force), so mixed spellings like `rm -r --force /`
2179
+ # and `rm --recursive -f /` cannot slip between the short/short and long/long
2180
+ # alternations (PR #1976 review).
2181
+ #
2182
+ # The FLAG TAILS are named separately from the `rm`-anchored patterns because
2183
+ # the find/xargs family below needs the identical flag vocabulary against a
2184
+ # different verb anchor. Before #3494 it carried its own short-cluster-only copy
2185
+ # and therefore missed `rm --recursive --force` — a divergence that could only
2186
+ # happen because the spelling set was written twice.
2187
+ readonly RM_RF_TAIL_CLUSTER='([[:space:]]+-[[:alnum:]-]+)*[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)'"$RM_RF_END"
2188
+ readonly RM_RF_TAIL_SPLIT='(([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*r[[:alnum:]]*|--recursive)([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*f[[:alnum:]]*|--force)'"$RM_RF_END"'|([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*r[[:alnum:]]*|--recursive)'"$RM_RF_END"')'
2189
+ readonly RM_RF_CLUSTER="$RM_CMD$RM_RF_TAIL_CLUSTER"
2190
+ readonly RM_RF_SPLIT="$RM_CMD$RM_RF_TAIL_SPLIT"
2191
+
2192
+ # 1. Recursive forced delete (`rm -rf`) of a filesystem root, home, or top-level
2193
+ # wildcard. Two gates ANDed: the statement must invoke `rm` with BOTH a
2194
+ # recursive and a force flag, AND name a catastrophic target. Splitting the
2195
+ # flag check from the target check keeps each regex legible and testable.
2196
+ # The target boundary classes include quote characters (issue #1960): without
2197
+ # them, `bash -c "rm -rf /"` and interpreter one-liners like
2198
+ # `python -c "… os.system('rm -rf /')"` slip through because the target is
2199
+ # quote-adjacent instead of space-bounded.
2200
+ # Both gates run PER STATEMENT inside the shared rm loop below (quality
2201
+ # review S1): a harmless `/` in a LATER statement (`rm -rf build && cd /`)
2202
+ # is never attributed to the rm in an earlier one.
2203
+ qc="'\""
2204
+ # Path/home targets also accept the command-substitution closers `)` and backtick
2205
+ # as a trailing boundary (issue #1982): in `echo "$(rm -rf /)"` the target `/` is
2206
+ # followed by `)`, not a space or quote, so without them an executable
2207
+ # substitution-wrapped `rm -rf <root>` slips past this gate. Leading boundary is
2208
+ # unchanged — the target is always space-separated from the recursive+force flag.
2209
+ tc="'\"\`)"
2210
+ # The bare-`*` wildcard deliberately KEEPS the narrow (space/quote/end) boundary:
2211
+ # admitting `)` there would misread a shell `case … in *)` default arm as a
2212
+ # catastrophic `rm -rf *` (T4b F1). A genuinely substitution-wrapped bare `*`
2213
+ # (`echo "$(rm -rf *)"`) is instead caught by the token-walk (guard 1b) below.
2214
+ readonly RM_CATASTROPHIC_TARGET='([[:space:]='"$qc"'])((/|/\*|/\.\*?|~|~/\*?|\$HOME\b|\$\{HOME\})([[:space:]'"$tc"']|/?\*?['"$tc"']?$)|\*([[:space:]'"$qc"']|/?\*?['"$qc"']?$))'
2215
+
2216
+ # 1b. rm target hardening (issue #1960). For every statement that invokes rm
2217
+ # with recursive+force flags, additionally block when:
2218
+ # - cwd is $HOME (any target is one argument away from wiping home), or
2219
+ # - the target is the cwd itself (`.` / `./`), or
2220
+ # - the target traverses out via `..`, or
2221
+ # - the target is home-anchored (`~/…` — the shell expands it to $HOME at
2222
+ # execution time, so it is always outside the project), or
2223
+ # - the target is an absolute path outside the project — with an allowance
2224
+ # for /tmp, /var/tmp, and $TMPDIR — or
2225
+ # - the target is a `$VAR` expansion other than the sanctioned $TMPDIR.
2226
+ # Globbing is disabled around the token walk so a literal `*` in the command
2227
+ # is never expanded against the hook's own cwd.
2228
+ project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
2229
+ tmp_dir_allow="${TMPDIR:-/tmp}"
2230
+ tmp_dir_allow="${tmp_dir_allow%/}"
2231
+ [ -n "$tmp_dir_allow" ] || tmp_dir_allow="/tmp"
2232
+ # Physical (symlink-resolved) spelling of an allowed root — issue #3106 arm B.
2233
+ # The allowance below is a set of LITERAL string prefixes, so on macOS, where
2234
+ # /tmp is a symlink to /private/tmp, one directory had two spellings and only
2235
+ # one of them was allowed: `rm -rf /tmp/x` passed while `rm -rf /private/tmp/x`
2236
+ # — the SAME directory, and the spelling an agent's own scratchpad path is
2237
+ # handed to it in — was refused as "outside the project". Two opposite verdicts
2238
+ # for one location is a defect regardless of which verdict is right.
2239
+ #
2240
+ # This widens no LOCATION. It adds the second name of a location the guard has
2241
+ # always allowed, computed from the filesystem rather than hardcoded, so a
2242
+ # platform where /tmp is not a symlink gains nothing. Falls back to the logical
2243
+ # path when the directory does not exist or resolves to `/`, so the allowance
2244
+ # can never become an empty or root-anchored prefix (an empty prefix would make
2245
+ # `""/*` match every absolute path).
2246
+ phys_dir() {
2247
+ local logical="$1" real=""
2248
+ real="$(cd -- "$logical" 2>/dev/null && pwd -P)" || real=""
2249
+ case "$real" in
2250
+ "" | /) printf '%s' "$logical" ;;
2251
+ *) printf '%s' "$real" ;;
2252
+ esac
2253
+ }
2254
+ project_dir_phys="$(phys_dir "$project_dir")"
2255
+ tmp_phys="$(phys_dir /tmp)"
2256
+ var_tmp_phys="$(phys_dir /var/tmp)"
2257
+ tmp_dir_allow_phys="$(phys_dir "$tmp_dir_allow")"
2258
+ [ -n "$project_dir_phys" ] || project_dir_phys="$project_dir"
2259
+ [ -n "$tmp_phys" ] || tmp_phys="/tmp"
2260
+ [ -n "$var_tmp_phys" ] || var_tmp_phys="/var/tmp"
2261
+ [ -n "$tmp_dir_allow_phys" ] || tmp_dir_allow_phys="$tmp_dir_allow"
2262
+ # Peel command-substitution wrappers off a token so an rm nested in `$(…)`,
2263
+ # backticks, `<(…)`/`>(…)`, a `"…"`/`'…'` quote, or a leading `\` alias-bypass
2264
+ # (`\rm`) is recognized and its target classified as if the token were unwrapped
2265
+ # (issue #1982) — substitution-wrapping is verdict-neutral for the rm guard.
2266
+ # Leading wrappers (\ $( ` ( <( >( and quotes) and trailing closers () ` and
2267
+ # quotes) are stripped in runs, so `"$(\rm` → `rm` and `/etc)"` → `/etc`. `$((`
2268
+ # is ARITHMETIC, never command substitution: the LEADING peel stops at it (its
2269
+ # case is ordered first), so the inner expression is never exposed as a command
2270
+ # or a target. The trailing loop may still trim a closing `))`, but that is
2271
+ # harmless — an arithmetic token like `$((10/2))` still keeps its `$` for the
2272
+ # variable-target guard and can never be mis-normalized into an rm or a `/`-target.
2273
+ strip_subst_wrappers() {
2274
+ local t="$1" prev=""
2275
+ while [ "$t" != "$prev" ]; do
2276
+ prev="$t"
2277
+ case "$t" in
2278
+ '$(('*) break ;;
2279
+ '\'*) t="${t#\\}" ;;
2280
+ '$('*) t="${t#'$('}" ;;
2281
+ '<('*) t="${t#'<('}" ;;
2282
+ '>('*) t="${t#'>('}" ;;
2283
+ '"'*) t="${t#'"'}" ;;
2284
+ "'"*) t="${t#\'}" ;;
2285
+ '`'*) t="${t#'`'}" ;;
2286
+ '('*) t="${t#(}" ;;
2287
+ esac
2288
+ done
2289
+ prev=""
2290
+ while [ "$t" != "$prev" ]; do
2291
+ prev="$t"
2292
+ case "$t" in
2293
+ *')') t="${t%)}" ;;
2294
+ *'`') t="${t%'`'}" ;;
2295
+ *'"') t="${t%'"'}" ;;
2296
+ *"'") t="${t%\'}" ;;
2297
+ esac
2298
+ done
2299
+ printf '%s' "$t"
2300
+ }
2301
+ # Decide which text of an rm statement the TARGET WALK may read — issue #3106
2302
+ # arm A. The statement selector matches the RAW command text, so a recursive
2303
+ # delete quoted as PROSE inside a string argument selects the line, and every
2304
+ # remaining token on it is then classified as a deletion target. Three
2305
+ # independent sightings in one day, all of them commands that delete nothing:
2306
+ # a `printf` reduction, a `gh issue create --title …` filing the report about
2307
+ # this guard, and a commit message describing it. Each was refused for a
2308
+ # variable in a LATER, unrelated argument.
2309
+ #
2310
+ # The rule is structural, with no allowlist of "display commands":
2311
+ # - an rm invocation OUTSIDE any quote is real; the walk reads the whole
2312
+ # statement, exactly as before (`rm -rf "$HOME"` keeps its target),
2313
+ # - an rm at the START of its quoted run is an invocation the quote merely
2314
+ # wraps (`bash -c "rm -rf /"`, `echo "$(rm -rf /)"`); the walk reads the
2315
+ # whole statement, exactly as before,
2316
+ # - an rm preceded by other words INSIDE its quoted run is prose; the walk is
2317
+ # scoped to that run, so the rm's own quoted arguments are still classified
2318
+ # in full while a variable in a later argument is no longer read as a
2319
+ # deletion target.
2320
+ #
2321
+ # Failure directions all point at blocking: an unterminated quote, an
2322
+ # over-long statement, or any run the scan cannot place falls back to the whole
2323
+ # statement. Residual (accepted, documented): `eval "prose rm -rf" "$X"` would
2324
+ # concatenate its words at run time and is scoped to the first run here.
2325
+ rm_scan_scope() {
2326
+ local stmt="$1"
2327
+ local n=${#stmt}
2328
+ # Long statements fall back to the full walk rather than pay a char scan.
2329
+ if [ "$n" -gt 4000 ]; then
2330
+ printf '%s' "$stmt"
2331
+ return
2332
+ fi
2333
+ local i=0 ch q="" esc=0 outside="" cur_run="" rm_runs="" found=0
2334
+ local run_leading=0
2335
+ while [ "$i" -lt "$n" ]; do
2336
+ ch="${stmt:i:1}"
2337
+ i=$((i + 1))
2338
+ if [ "$esc" -eq 1 ]; then
2339
+ esc=0
2340
+ if [ -n "$q" ]; then cur_run="$cur_run$ch"; else outside="$outside$ch"; fi
2341
+ continue
2342
+ fi
2343
+ if [ "$ch" = '\' ] && [ "$q" != "'" ]; then
2344
+ esc=1
2345
+ continue
2346
+ fi
2347
+ if [ -n "$q" ]; then
2348
+ if [ "$ch" = "$q" ]; then
2349
+ case "$cur_run" in
2350
+ *[Rr][Mm]*)
2351
+ if scan -Ei "$cur_run" "$RM_CMD"'([[:space:]]|$)'; then
2352
+ found=$((found + 1))
2353
+ if [ -n "$rm_runs" ]; then rm_runs="$rm_runs"$'\n'; fi
2354
+ rm_runs="$rm_runs$cur_run"
2355
+ if scan -Ei "$cur_run" \
2356
+ '^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
2357
+ run_leading=1
2358
+ fi
2359
+ fi
2360
+ ;;
2361
+ esac
2362
+ q=""
2363
+ cur_run=""
2364
+ else
2365
+ cur_run="$cur_run$ch"
2366
+ fi
2367
+ continue
2368
+ fi
2369
+ case "$ch" in
2370
+ '"' | "'")
2371
+ q="$ch"
2372
+ cur_run=""
2373
+ ;;
2374
+ *) outside="$outside$ch" ;;
2375
+ esac
2376
+ done
2377
+ # An unterminated quote is text the scan cannot place: treat it as unquoted so
2378
+ # the fallback below hands back the whole statement.
2379
+ [ -z "$q" ] || outside="$outside$cur_run"
2380
+ if scan -Ei "$outside" "$RM_CMD"'([[:space:]]|$)'; then
2381
+ printf '%s' "$stmt"
2382
+ return
2383
+ fi
2384
+ if [ "$found" -eq 0 ]; then
2385
+ printf '%s' "$stmt"
2386
+ return
2387
+ fi
2388
+ # `rm` first in ANY run (modulo whitespace and substitution/alias openers) is
2389
+ # an invocation, not prose. Inspect every run before deciding: returning on
2390
+ # the first prose run used to hide a real delete in a later quoted run.
2391
+ if [ "$run_leading" -eq 1 ]; then
2392
+ printf '%s' "$stmt"
2393
+ return
2394
+ fi
2395
+ printf '%s' "$rm_runs"
2396
+ }
2397
+ # Look up the ONE unambiguous assignment of NAME inside the command being
2398
+ # classified — issue #3106 arm B, the variable half.
2399
+ #
2400
+ # Prints the recorded right-hand side, or fails when the name is not assigned
2401
+ # before the first rm token or is assigned more than one distinct value there.
2402
+ # A later write cannot affect an earlier delete, and a quoted assignment-like
2403
+ # argument can be prose, so neither is considered. Only assignments literally
2404
+ # present in this command are ever trusted; nothing is expanded from the hook's
2405
+ # own environment.
2406
+ rm_assignment_stmt=""
2407
+ rm_assignment_value() {
2408
+ local name="$1"
2409
+ case "$name" in
2410
+ "" | *[!A-Za-z0-9_]*) return 1 ;;
2411
+ esac
2412
+ # Only assignments that occur before this statement's rm token can influence
2413
+ # its delete safely. A later assignment is too late at runtime, and an
2414
+ # assignment whose NAME begins inside a quoted argument can be prose. The
2415
+ # small lexer below preserves quoted right-hand sides such as V="/tmp/x"
2416
+ # while excluding an argument such as echo "V=/tmp/x". Any ambiguity fails
2417
+ # closed.
2418
+ RM_ASSIGNMENT_NAME="$name" RM_ASSIGNMENT_COMMAND="$rm_normalized_command_str" \
2419
+ RM_ASSIGNMENT_STATEMENT="$rm_assignment_stmt" \
2420
+ python3 - <<'PY'
2421
+ import os
2422
+ import re
2423
+ import sys
2424
+
2425
+ name = os.environ.get("RM_ASSIGNMENT_NAME", "")
2426
+ command = os.environ.get("RM_ASSIGNMENT_COMMAND", "")
2427
+ statement = os.environ.get("RM_ASSIGNMENT_STATEMENT", "")
2428
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
2429
+ sys.exit(1)
2430
+
2431
+ rm_pattern = re.compile(
2432
+ r"(?i)(?<![A-Za-z0-9_./-])(?:[A-Za-z0-9_./-]*/)?rm(?=$|[\s;&|()'\"`])",
2433
+ )
2434
+ rm_token = rm_pattern.search(statement)
2435
+ if rm_token is None or not statement or command.count(statement) != 1:
2436
+ sys.exit(1)
2437
+ statement_start = command.find(statement)
2438
+ if statement_start < 0:
2439
+ sys.exit(1)
2440
+ prefix = command[: statement_start + rm_token.start()]
2441
+
2442
+ tokens = []
2443
+ buffer = []
2444
+ quote = None
2445
+ escaped = False
2446
+ started = False
2447
+ started_unquoted = False
2448
+
2449
+ def flush():
2450
+ global buffer, started, started_unquoted
2451
+ if started:
2452
+ tokens.append(("".join(buffer), started_unquoted))
2453
+ buffer = []
2454
+ started = False
2455
+ started_unquoted = False
2456
+
2457
+ for char in prefix:
2458
+ if escaped:
2459
+ buffer.append(char)
2460
+ escaped = False
2461
+ continue
2462
+ if quote is not None:
2463
+ if char == quote:
2464
+ quote = None
2465
+ elif char == "\\" and quote == '"':
2466
+ escaped = True
2467
+ else:
2468
+ buffer.append(char)
2469
+ continue
2470
+ if char in "'\"":
2471
+ if not started:
2472
+ started = True
2473
+ started_unquoted = False
2474
+ quote = char
2475
+ elif char == "\\":
2476
+ if not started:
2477
+ started = True
2478
+ started_unquoted = True
2479
+ escaped = True
2480
+ elif char.isspace() or char in ";&|()":
2481
+ flush()
2482
+ else:
2483
+ if not started:
2484
+ started = True
2485
+ started_unquoted = True
2486
+ buffer.append(char)
2487
+ flush()
2488
+
2489
+ needle = f"{name}="
2490
+ hits = [token[len(needle):] for token, outside in tokens
2491
+ if outside and token.startswith(needle)]
2492
+ distinct = set(hits)
2493
+ if len(distinct) != 1:
2494
+ sys.exit(1)
2495
+ value = hits[0]
2496
+ if not value:
2497
+ sys.exit(1)
2498
+ sys.stdout.write(value)
2499
+ PY
2500
+ }
2501
+ # Rewrite a leading `$NAME` / `${NAME}` in a deletion target to the value the
2502
+ # command itself assigns it, so the target is classified as if the agent had
2503
+ # written that value literally — issue #3106 arm B.
2504
+ #
2505
+ # The guard's standing reason for refusing variable targets is that "unset or
2506
+ # mistyped variables can point anywhere", and that reason survives intact: a
2507
+ # name with no in-command assignment, an ambiguous one, or a value that is
2508
+ # itself a command substitution never resolves and stays refused. What changes
2509
+ # is only that a target the agent could have spelled literally is no longer
2510
+ # refused for being spelled through a variable.
2511
+ resolve_var_prefix() {
2512
+ local token="$1" name="" rest="" value=""
2513
+ case "$token" in
2514
+ '${'*'}'*)
2515
+ name="${token#'${'}"
2516
+ name="${name%%\}*}"
2517
+ rest="${token#'${'"$name"'}'}"
2518
+ ;;
2519
+ '$'*)
2520
+ rest="${token#'$'}"
2521
+ name="${rest%%[!A-Za-z0-9_]*}"
2522
+ rest="${rest#"$name"}"
2523
+ ;;
2524
+ *) return 1 ;;
2525
+ esac
2526
+ [ -n "$name" ] || return 1
2527
+ value="$(rm_assignment_value "$name")" || return 1
2528
+ # A value that is itself a command substitution names no location the guard
2529
+ # can read, so it never resolves — the refusal stands. `$(…)` already fails
2530
+ # below (its leading `$` yields no variable name); backticks are named here so
2531
+ # both spellings of substitution behave the same way.
2532
+ case "$value" in
2533
+ *'`'*) return 1 ;;
2534
+ esac
2535
+ case "$value" in
2536
+ '"'*'"')
2537
+ value="${value#\"}"
2538
+ value="${value%\"}"
2539
+ ;;
2540
+ "'"*"'")
2541
+ value="${value#\'}"
2542
+ value="${value%\'}"
2543
+ ;;
2544
+ esac
2545
+ [ -n "$value" ] || return 1
2546
+ printf '%s%s' "$value" "$rest"
2547
+ }
2548
+ # Classify ONE deletion target. Extracted from the token walk so a variable
2549
+ # target can be re-classified through every arm after resolution (#3106),
2550
+ # instead of the variable arm being a terminal verdict of its own.
2551
+ classify_rm_target() {
2552
+ local token="$1" depth="${2:-0}" resolved=""
2553
+ case "$token" in
2554
+ -*) return 0 ;;
2555
+ . | ./)
2556
+ set +f
2557
+ block "recursive forced delete of the current directory (rm -rf .)"
2558
+ ;;
2559
+ .git | .git/* | */.git | */.git/*)
2560
+ set +f
2561
+ block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it)"
2562
+ ;;
2563
+ .. | ../* | */.. | */../*)
2564
+ set +f
2565
+ block "recursive forced delete of a path outside the project (.. traversal)"
2566
+ ;;
2567
+ '~' | '~'/*)
2568
+ set +f
2569
+ block "recursive forced delete of a home-anchored path (~/…) outside the project"
2570
+ ;;
2571
+ /*)
2572
+ case "$token" in
2573
+ "$project_dir" | "$project_dir"/* | "$project_dir_phys" | "$project_dir_phys"/* \
2574
+ | /tmp | /tmp/* | "$tmp_phys" | "$tmp_phys"/* \
2575
+ | /var/tmp | /var/tmp/* | "$var_tmp_phys" | "$var_tmp_phys"/* \
2576
+ | "$tmp_dir_allow" | "$tmp_dir_allow"/* | "$tmp_dir_allow_phys" | "$tmp_dir_allow_phys"/*) : ;;
2577
+ *)
2578
+ set +f
2579
+ block "recursive forced delete of an absolute path outside the project (only the project, /tmp, /var/tmp, and \$TMPDIR are allowed)"
2580
+ ;;
2581
+ esac
2582
+ ;;
2583
+ '*')
2584
+ # Bare top-level wildcard expands to every entry in the cwd. Guard 1a no
2585
+ # longer bounds `*)` (T4b F1), so a substitution-wrapped `rm -rf *` is
2586
+ # caught here after the trailing-closer strip peels `*)"` down to `*`.
2587
+ set +f
2588
+ block "recursive forced delete of a top-level wildcard (rm -rf *)"
2589
+ ;;
2590
+ *'$'*)
2591
+ case "$token" in
2592
+ '$TMPDIR' | '$TMPDIR'/* | '${TMPDIR}' | '${TMPDIR}'/*) : ;;
2593
+ *)
2594
+ if [ "$depth" -lt 4 ] \
2595
+ && resolved="$(resolve_var_prefix "$token")" \
2596
+ && [ -n "$resolved" ] \
2597
+ && [ "$resolved" != "$token" ]; then
2598
+ classify_rm_target "$resolved" $((depth + 1))
2599
+ else
2600
+ set +f
2601
+ block "recursive forced delete of a variable-expanded target (unset or mistyped variables can point anywhere; \$TMPDIR is the only sanctioned dynamic target)"
2602
+ fi
2603
+ ;;
2604
+ esac
2605
+ ;;
2606
+ esac
2607
+ return 0
2608
+ }
2609
+ # Segmented in the PARENT shell rather than in a process substitution, and the
2610
+ # `|| true` that used to sit on the feeder is gone. Both together were the same
2611
+ # three-way read as a bare `grep -q` condition (see scan() above), just wearing
2612
+ # a loop: a grep that could not run produced no lines, the body never executed,
2613
+ # and the guard was silently skipped with the command ALLOWED. `exit` inside a
2614
+ # process substitution cannot deny anything either — it ends that subshell, not
2615
+ # this hook.
2616
+ rm_segments_status=0
2617
+ rm_segments="$(printf '%s' "$rm_normalized_command_str" | tr '&|;' '\n' \
2618
+ | grep -Ei -- "$RM_CMD"'([[:space:]]|$)')" || rm_segments_status=$?
2619
+ case "$rm_segments_status" in
2620
+ 0 | 1) ;;
2621
+ *) scan_failed "$rm_segments_status" ;;
2622
+ esac
2623
+ while IFS= read -r rm_stmt; do
2624
+ # A here-string over empty input still yields one empty line.
2625
+ [ -n "$rm_stmt" ] || continue
2626
+ if ! scan -Ei "$rm_stmt" "$RM_RF_CLUSTER" \
2627
+ && ! scan -Ei "$rm_stmt" "$RM_RF_SPLIT"; then
2628
+ continue
2629
+ fi
2630
+ # Guard 1: catastrophic target named in the SAME statement as the rm -rf.
2631
+ if scan -E "$rm_stmt" "$RM_CATASTROPHIC_TARGET"; then
2632
+ block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
2633
+ fi
2634
+ # Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
2635
+ # a symlink (/var → /private/var), and a string compare would miss the match.
2636
+ if [ -n "${HOME:-}" ] \
2637
+ && [ "$(pwd -P)" = "$(cd -- "$HOME" 2>/dev/null && pwd -P)" ]; then
2638
+ block "recursive forced delete while the working directory is \$HOME (cd into a project first)"
2639
+ fi
2640
+ # Scope the walk to the quoting region that actually contains the rm (#3106
2641
+ # arm A). Unquoted and run-leading invocations get the whole statement back,
2642
+ # so this is a no-op for every real delete.
2643
+ rm_assignment_stmt="$rm_stmt"
2644
+ rm_walk_status=0
2645
+ rm_walk_text="$(rm_scan_scope "$rm_stmt")" || rm_walk_status=$?
2646
+ if [ "$rm_walk_status" -ne 0 ]; then
2647
+ # The substitution above runs in a subshell. A denial inside it cannot stop
2648
+ # this parent hook, so carry the failure out explicitly and deny here.
2649
+ scan_failed "$rm_walk_status"
2650
+ fi
2651
+ while IFS= read -r rm_scope; do
2652
+ set -f
2653
+ seen_rm=0
2654
+ for raw_token in $rm_scope; do
2655
+ token="$(strip_subst_wrappers "$raw_token")"
2656
+ if [ "$seen_rm" -eq 0 ]; then
2657
+ # Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
2658
+ case "$token" in
2659
+ rm | */rm) seen_rm=1 ;;
2660
+ esac
2661
+ continue
2662
+ fi
2663
+ classify_rm_target "$token"
2664
+ done
2665
+ set +f
2666
+ done <<< "$rm_walk_text"
2667
+ done <<<"$rm_segments"
2668
+
2669
+ # 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
2670
+ # non-clobbering alternative and is intentionally NOT blocked. Deliberate
2671
+ # divergence from upstream (which blocks force-push on ANY branch):
2672
+ # feature-branch force-push is a sanctioned rebase-and-push agent workflow.
2673
+ #
2674
+ # The force flag AND the protected-branch name must appear in the SAME
2675
+ # `git push` statement. Checking them independently over the whole command is
2676
+ # a false-positive magnet: an unrelated `-f` (a `[ -f file ]` test, `rm -f`,
2677
+ # `grep -f`, `tail -f`) plus an unrelated protected name (`--base main`,
2678
+ # `origin/main`, `git fetch origin main`) alongside any feature-branch
2679
+ # `git push` would wrongly block. So split the command into statements
2680
+ # (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
2681
+ # inspect each in isolation — a real `git push --force origin main` still
2682
+ # matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
2683
+ # Same reason as the rm segmentation above: a feeder that swallows grep's error
2684
+ # skips the guard entirely and allows the command.
2685
+ push_segments_status=0
2686
+ push_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
2687
+ | grep -Ei -- "${GIT_CMD}push\b")" || push_segments_status=$?
2688
+ case "$push_segments_status" in
2689
+ 0 | 1) ;;
2690
+ *) scan_failed "$push_segments_status" ;;
2691
+ esac
2692
+ while IFS= read -r push_stmt; do
2693
+ [ -n "$push_stmt" ] || continue
2694
+ if scan -Ei "$push_stmt" \
2695
+ '(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
2696
+ && ! scan -Ei "$push_stmt" '--force-with-lease' \
2697
+ && scan -Ei "$push_stmt" \
2698
+ '(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
2699
+ block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
2700
+ fi
2701
+ done <<<"$push_segments"
2702
+
2703
+ # 3. `git reset --hard` / `git reset --merge` while the working tree has
2704
+ # uncommitted changes — both silently discard them. Only blocks when the tree
2705
+ # is actually dirty, so a clean-tree reset (a legitimate workflow) passes.
2706
+ # Deliberate divergence from upstream's unconditional block; the accepted
2707
+ # residual risk (dirty check runs at hook time in the hook's cwd) is
2708
+ # documented in the header.
2709
+ if matches "${GIT_CMD}"'reset\b.*--(hard|merge)\b'; then
2710
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
2711
+ && [ -n "$(git status --porcelain 2>/dev/null)" ]; then
2712
+ block "git reset --hard/--merge on a dirty working tree would discard uncommitted changes (commit first, or preserve as below)" "$PRESERVE_GUIDANCE
2713
+
2714
+ $DESTRUCTIVE_GUIDANCE"
2715
+ fi
2716
+ fi
2717
+
2718
+ # 3a. `git read-tree` with BOTH an update flag and `--reset`, on the same dirty
2719
+ # tree — the plumbing spelling of guard 3 (issue #3978). It resets the index
2720
+ # and writes the result into the working tree, discarding tracked
2721
+ # modifications: the identical destruction, reached through a command that
2722
+ # carries no `--hard` and no `reset` subcommand, so every pattern in
2723
+ # guard 3 misses it. A guard an agent can walk around is a guard that stops
2724
+ # the honest caller and not the one looking for a way through, and this
2725
+ # spelling is strictly worse than the blocked one — it discards WITHOUT
2726
+ # preserving, which is exactly the property guard 3's remedy supplies. Both
2727
+ # refusals therefore print the same $PRESERVE_GUIDANCE, so what they teach
2728
+ # cannot drift apart.
2729
+ #
2730
+ # `--reset` is the discriminator, not `-u`, and the pairing is deliberate:
2731
+ #
2732
+ # - `read-tree` with no update flag touches the INDEX only and destroys
2733
+ # nothing in the working tree, so a blanket `read-tree` block would be
2734
+ # over-broad.
2735
+ # - `read-tree -m -u` (a merge without `--reset`) is safe by git's own
2736
+ # construction: it REFUSES rather than overwrite a locally modified file
2737
+ # ("entry would be overwritten by merge"). `--reset` is precisely the flag
2738
+ # that switches that refusal off.
2739
+ #
2740
+ # So the destructive combination is an update flag AND `--reset` together,
2741
+ # in either order. The update flag is matched in the checkout guard's idiom
2742
+ # so a bundled short option (`-iu`) cannot dodge the anchor, with leading
2743
+ # whitespace required so a `--index-output=/tmp/out-u` style value cannot
2744
+ # read as the flag. Same conditional dirty-tree shape and same accepted
2745
+ # residual risk as guard 3: the probe runs in the hook's cwd at hook time.
2746
+ readonly GIT_READ_TREE="${GIT_CMD}read-tree"
2747
+ if matches "${GIT_READ_TREE}"'[^;&|]*[[:space:]]--reset([[:space:]]|$)' \
2748
+ && matches "${GIT_READ_TREE}"'[^;&|]*[[:space:]](-[[:alnum:]]*u[[:alnum:]]*|--update)([[:space:]]|=|$)'; then
2749
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
2750
+ && [ -n "$(git status --porcelain 2>/dev/null)" ]; then
2751
+ block "git read-tree -u --reset on a dirty working tree would discard uncommitted changes (commit first, or preserve as below)" "$PRESERVE_GUIDANCE
2752
+
2753
+ $DESTRUCTIVE_GUIDANCE"
2754
+ fi
2755
+ fi
2756
+
2757
+ # 3b. `git rebase --abort` / `--quit` ONLY while human/agent conflict
2758
+ # resolutions exist (issue #1956). `--abort` restores the pre-rebase branch
2759
+ # and discards every resolution; `--quit` deletes the rebase bookkeeping
2760
+ # (head-name, todo) while stranding a detached HEAD, making recovery
2761
+ # ambiguous — both are treated the same, conditionally. A rebase state with
2762
+ # nothing human-made in it (a clean-pick wedge, or a conflict stop nobody
2763
+ # has touched) is agent-recoverable, so aborting it stays ALLOWED.
2764
+ # Discriminator (empirical, git 2.53): diff the worktree/index against the
2765
+ # AUTO_MERGE ref (the merge-ort recorded conflicted tree). Abort-safe ⇔
2766
+ # worktree diff is quiet AND (the cached diff is quiet OR unmerged index
2767
+ # entries exist — an untouched conflict stop has unmerged entries that make
2768
+ # the cached diff non-quiet without any human edit). Fail CLOSED on the
2769
+ # rebase-apply backend (no AUTO_MERGE contract) and on a missing AUTO_MERGE
2770
+ # ref while rebase-merge state exists. Same accepted residual risk as
2771
+ # guard 3: the probes run in the hook's cwd at hook time.
2772
+ if matches "${GIT_CMD}"'rebase'"${GIT_TOKENS}"'--(abort|quit)([[:space:]]|$)'; then
2773
+ rebase_apply_dir="$(git rev-parse --git-path rebase-apply 2>/dev/null || true)"
2774
+ rebase_merge_dir="$(git rev-parse --git-path rebase-merge 2>/dev/null || true)"
2775
+ if [ -n "$rebase_apply_dir" ] && [ -d "$rebase_apply_dir" ]; then
2776
+ block "git rebase --abort/--quit on an apply-backend rebase cannot prove no conflict resolutions would be lost (fail closed; finish or continue the rebase instead)"
2777
+ fi
2778
+ if [ -n "$rebase_merge_dir" ] && [ -d "$rebase_merge_dir" ]; then
2779
+ if ! git rev-parse -q --verify AUTO_MERGE >/dev/null 2>&1; then
2780
+ block "git rebase --abort/--quit with an unresolvable AUTO_MERGE ref cannot prove no conflict resolutions would be lost (fail closed)"
2781
+ fi
2782
+ if ! git diff --quiet AUTO_MERGE 2>/dev/null; then
2783
+ block "git rebase --abort/--quit would discard conflict resolutions in the working tree (finish resolving and run git rebase --continue instead)"
2784
+ fi
2785
+ unmerged_entries="$(git ls-files -u 2>/dev/null || true)"
2786
+ if [ -z "$unmerged_entries" ] \
2787
+ && ! git diff --cached --quiet AUTO_MERGE 2>/dev/null; then
2788
+ block "git rebase --abort/--quit would discard staged conflict resolutions (finish resolving and run git rebase --continue instead)"
2789
+ fi
2790
+ fi
2791
+ fi
2792
+
2793
+ # 4. `git checkout` worktree discards: the `--` pathspec form (with or without a
2794
+ # ref), `-f/--force`, `--pathspec-from-file`, and bare `git checkout .`.
2795
+ # Blocking bare `.` exceeds upstream (which allows any single positional) —
2796
+ # issue #1960 names it explicitly: it discards the entire tree. Branch
2797
+ # switches and creation (`-b`/`-B`) stay allowed.
2798
+ readonly GIT_CHECKOUT="${GIT_CMD}checkout"
2799
+ if matches "${GIT_CHECKOUT}${GIT_TOKENS}"'--([[:space:]]|$)' \
2800
+ || matches "${GIT_CHECKOUT}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
2801
+ || matches "${GIT_CHECKOUT}"'[[:space:]][^;&|]*--pathspec-from-file' \
2802
+ || matches "${GIT_CHECKOUT}"'[[:space:]]+\.(/)?([[:space:]]|$)'; then
2803
+ block "git checkout discarding local changes (--, -f/--force, --pathspec-from-file, or bare .)" "$PRESERVE_GUIDANCE
2804
+
2805
+ $DESTRUCTIVE_GUIDANCE"
2806
+ fi
2807
+
2808
+ # 5. `git switch` discards: `--discard-changes` and its `-f/--force` aliases.
2809
+ # Carries the same guidance as guards 3 and 4 (issue #3696). It discards the
2810
+ # same uncommitted work by the same mechanism, so a refusal that named no
2811
+ # remedy left the reader to invent one — and the one they invent is the
2812
+ # stash, which is the single thing $PRESERVE_GUIDANCE exists to steer off.
2813
+ if matches "${GIT_CMD}switch${GIT_TOKENS}"'(--discard-changes|--force|-f)([[:space:]]|=|$)'; then
2814
+ block "git switch discarding local changes (--discard-changes/-f/--force)" "$PRESERVE_GUIDANCE
2815
+
2816
+ $DESTRUCTIVE_GUIDANCE"
2817
+ fi
2818
+
2819
+ # 6. `git restore` defaults to overwriting the WORKTREE — a silent discard. Only
2820
+ # the pure staging-area form is safe: `--staged` present and `--worktree`
2821
+ # absent. Two conditions, because an ERE has no lookahead: `--staged
2822
+ # --worktree` still discards, so `--worktree` blocks regardless of `--staged`.
2823
+ # Carries the same guidance as guards 3 and 4 (issue #3696). The permitted
2824
+ # alternative this reason names — `git restore --staged <path>` — is real,
2825
+ # but it only unstages; it leaves the worktree exactly as dirty as it found
2826
+ # it. So the operator who reached for `git restore` to CLEAN a tree is told
2827
+ # of an operation that does not do what they came for, and was told nothing
2828
+ # about what does. This is the refusal #3696 was filed from.
2829
+ if matches "${GIT_CMD}"'restore([[:space:]]|$)'; then
2830
+ if matches "${GIT_CMD}"'restore[^;&|]*--worktree' \
2831
+ || ! matches "${GIT_CMD}"'restore[^;&|]*--staged'; then
2832
+ block "git restore overwriting worktree files (only 'git restore --staged <path>' without --worktree is allowed)" "$PRESERVE_GUIDANCE
2833
+
2834
+ $DESTRUCTIVE_GUIDANCE"
2835
+ fi
2836
+ fi
2837
+
2838
+ # 7. `git stash drop` / `git stash clear` destroy stashed work. push/pop/list/
2839
+ # apply stay ALLOWED — this guard moves no verdict on them — but they are no
2840
+ # longer RECOMMENDED anywhere in this file, and this comment used to say the
2841
+ # opposite (issue #3692). It called them "the safe alternatives the reset
2842
+ # guard recommends", which stopped being true when #3722 rewrote guards 3
2843
+ # and 4 to point at $PRESERVE_GUIDANCE instead. A stale comment endorsing
2844
+ # the stash is the same hazard as a stale message doing it: the next reader
2845
+ # takes the file's word for what the file does. See $PRESERVE_GUIDANCE for
2846
+ # why one stack shared by every worktree of a clone makes push/pop unsafe
2847
+ # under this project's concurrency.
2848
+ if matches "${GIT_CMD}"'stash[[:space:]]+(drop|clear)([[:space:]]|$)'; then
2849
+ block "git stash drop/clear destroys stashed work"
2850
+ fi
2851
+
2852
+ # 8. `git clean` with a force flag wipes untracked files. A dry-run flag
2853
+ # (`-n`/`--dry-run`) ANYWHERE wins — git itself performs no deletion under
2854
+ # dry-run, so `git clean -fdn` is a safe preview.
2855
+ readonly GIT_CLEAN="${GIT_CMD}clean"
2856
+ if matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
2857
+ && ! matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*n[[:alnum:]]*|--dry-run)([[:space:]]|=|$)'; then
2858
+ block "git clean --force deletes untracked files (preview with git clean -n first)"
2859
+ fi
2860
+
2861
+ # 9. `git branch` force-delete loses unmerged commits: `-D`, `-d` combined with
2862
+ # `-f` (clustered or split), or `--delete` + `--force`. Case-SENSITIVE so the
2863
+ # safe `-d` (which refuses unmerged work) and rename `-m` stay allowed.
2864
+ readonly GIT_BRANCH="${GIT_CMD}branch"
2865
+ if matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*D[[:alnum:]]*([[:space:]]|$)' \
2866
+ || { matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)' \
2867
+ && matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$)'; } \
2868
+ || { matches "${GIT_BRANCH}"'[^;&|]*--delete' && matches "${GIT_BRANCH}"'[^;&|]*--force'; }; then
2869
+ block "git branch force-delete (-D) loses unmerged commits (use -d, which refuses unmerged work)"
2870
+ fi
2871
+
2872
+ # 10. Cheap destructive-ref guards: `git tag -d` (tags are shared refs),
2873
+ # `git reflog delete` (erases recovery history), and
2874
+ # `git worktree remove --force` (discards a dirty worktree). Tag deletion is
2875
+ # case-SENSITIVE so annotated-tag `-a` and message `-m` flags never match.
2876
+ if matches_cs "${GIT_CMD}tag${GIT_TOKENS}"'(-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)|--delete([[:space:]]|=|$))'; then
2877
+ block "git tag -d deletes a shared ref"
2878
+ fi
2879
+ if matches "${GIT_CMD}"'reflog[[:space:]]+delete([[:space:]]|$)'; then
2880
+ block "git reflog delete erases recovery history"
2881
+ fi
2882
+ if matches "${GIT_CMD}"'worktree[[:space:]]+remove[^;&|]*(--force([[:space:]]|=|$)|[[:space:]]-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$))'; then
2883
+ block "git worktree remove --force discards a dirty worktree (remove without --force, which refuses dirty trees)"
2884
+ fi
2885
+
2886
+ # 11. Deletion through find/xargs, where the target never appears as a literal
2887
+ # argument: `find … -delete`, `find … -exec rm -rf`, and `xargs … rm -rf`
2888
+ # (targets arrive from dynamic stdin — unauditable). Plain `rm` (no
2889
+ # recursive+force) on find/xargs output stays allowed for normal cleanups.
2890
+ if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*[[:space:]]-delete([[:space:]]|$)'; then
2891
+ block "find -delete removes files tree-wide (use -print to preview, or an explicit rm on reviewed paths)"
2892
+ fi
2893
+ if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*-exec[[:space:]]+rm[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'; then
2894
+ block "find -exec rm -rf performs a recursive forced delete on unreviewed paths"
2895
+ fi
2896
+ # A command that reads its operands from STDIN and runs a program on them. The
2897
+ # targets are therefore whatever the upstream stage emitted, which is exactly
2898
+ # what makes a recursive forced delete behind one unauditable.
2899
+ #
2900
+ # Three fail-opens closed here (issue #3494), all of them the same mistake —
2901
+ # this rule was written standalone instead of inheriting vocabulary the file
2902
+ # already had:
2903
+ #
2904
+ # 1. The old prefix was `(^|[^[:alnum:]_./-])xargs`, whose excluded class
2905
+ # contains `/`, so the character before `xargs` in `/usr/bin/xargs` was
2906
+ # itself disqualifying and a PATH-SPELLED invocation never matched.
2907
+ # `gxargs` — the GNU findutils spelling shipped by macOS package managers —
2908
+ # failed the same way on the alphanumeric side. RM_CMD above solved this
2909
+ # exact problem for `rm` by putting the boundary before the WHOLE path and
2910
+ # making the directory part optional; that shape is reused verbatim.
2911
+ # 2. `parallel` was absent. It is a drop-in `xargs` substitute reaching the
2912
+ # same delete on the same unaudited stdin, and guard 14 below already had
2913
+ # to name it in its own evidence — it was missing only here.
2914
+ # 3. The flag set was a short-cluster-only copy, so `rm --recursive --force`
2915
+ # was allowed where `rm -rf` was refused. It now uses the SAME tails as the
2916
+ # rm walk, which is what makes that class of divergence unrepeatable.
2917
+ readonly STDIN_DELETER='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?(g?xargs|parallel)[[:space:]]([^;&|]*[[:space:]])?rm'
2918
+ if matches "$STDIN_DELETER$RM_RF_TAIL_CLUSTER" \
2919
+ || matches "$STDIN_DELETER$RM_RF_TAIL_SPLIT"; then
2920
+ block "xargs/parallel rm -rf performs a recursive forced delete on dynamic stdin input"
2921
+ fi
2922
+
2923
+ # 12. Disk destroyers, always on (exceeds upstream, which only scans these
2924
+ # inside interpreter one-liners): writing to a raw /dev node via dd,
2925
+ # formatting a device with mkfs, and shred's unrecoverable overwrite.
2926
+ # dd/mkfs against regular files (imaging) stays allowed.
2927
+ if matches '(^|[^[:alnum:]_./-])dd[[:space:]]+([^;&|]*[[:space:]])?of=/dev/'; then
2928
+ block "dd writing to a raw device (of=/dev/…) destroys it"
2929
+ fi
2930
+ if matches '(^|[^[:alnum:]_./-])mkfs(\.[[:alnum:]]+)?[[:space:]][^;&|]*/dev/'; then
2931
+ block "mkfs formatting a device erases it"
2932
+ fi
2933
+ if matches '(^|[^[:alnum:]_./-])shred([[:space:]]|$)'; then
2934
+ block "shred overwrites files unrecoverably"
2935
+ fi
2936
+
2937
+ # 13. Dropping or truncating a database / schema / table (Lisa-only guard —
2938
+ # upstream has no SQL protection; keep).
2939
+ #
2940
+ # The two arms used to be asymmetric, and only the lopsided one produced false
2941
+ # positives (issue #3530). The `drop` arm requires a following keyword —
2942
+ # `drop database|schema|table` — so `drop the ball` never matched it. The
2943
+ # `truncate` arm made that keyword OPTIONAL:
2944
+ #
2945
+ # truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+
2946
+ #
2947
+ # which reduces to "`truncate`, whitespace, any identifier-ish word", so
2948
+ # `truncate the board`, `truncate results` and `truncate output` all matched.
2949
+ # A session was refused for a bug-report TITLE describing a connection that
2950
+ # truncates results — and a title has no escape hatch, because the remedy this
2951
+ # hook prints at the wall (write the text to a file, pass it BY PATH) covers a
2952
+ # body, while the title flag of an issue-filing CLI is necessarily inline. The
2953
+ # only exit left was to reword, which is what trains people to route around a
2954
+ # guard.
2955
+ #
2956
+ # The fix keeps the coverage and drops the English, in three arms.
2957
+ #
2958
+ # ARM 1 — `drop`, byte-identical to before. It was already correct, and a
2959
+ # rewrite that unified the arms could only make it permissive.
2960
+ readonly SQL_DROP='\bdrop[[:space:]]+(database|schema|table)\b'
2961
+ # ARM 2 — `truncate` carrying its own evidence that it is a statement rather
2962
+ # than a verb: the TABLE keyword (what the drop arm demands), a
2963
+ # schema-qualified name, a quoted identifier, a trailing `;`, or one of the
2964
+ # tail keywords that only ever follow a TRUNCATE. English has none of these.
2965
+ readonly SQL_TRUNCATE_TAIL='(cascade|restrict|restart[[:space:]]+identity|continue[[:space:]]+identity)([^[:alnum:]_]|$)'
2966
+ readonly SQL_TRUNCATE_EVIDENCE='\btruncate[[:space:]]+(table[[:space:]]+[[:alnum:]_."`]+|(only[[:space:]]+)?([[:alnum:]_]+\.[[:alnum:]_."`]+|["`][[:alnum:]_. ]+["`]|[[:alnum:]_]+[[:space:]]*(;|'"$SQL_TRUNCATE_TAIL"')))'
2967
+ # ARM 3 — the permissive-dialect `TRUNCATE <name>` with none of that evidence.
2968
+ # Deleting this shape is what a lazy fix does: it passes every prose scenario
2969
+ # while silently removing the coverage a regression fixture already asserts
2970
+ # (`mysql -e 'TRUNCATE sessions'`). So it is recognized by CONTEXT instead —
2971
+ # the same command must also invoke a command-line database client. Prose in a
2972
+ # tracker filing or a commit message invokes none, which is exactly the
2973
+ # difference the text scan could not see on its own.
2974
+ readonly SQL_CLIENT_TRUNCATE='(^|[;&|()][[:space:]]*)([[:alnum:]_./-]*/)?(psql|pgcli|mysql|mysqladmin|mysqlsh|mariadb|mycli|sqlite3|sqlcmd|cqlsh|clickhouse-client|cockroach|mongosh|duckdb|usql)([[:space:]]+[^;&|()]*)?[[:space:]]+(-e|-c|--command|--eval)(=|[[:space:]]+)[^;&|()]*\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
2975
+ # Residual, accepted and stated: a bare `TRUNCATE <name>` with no terminator,
2976
+ # no qualification and no recognized client in the same command goes unmatched
2977
+ # — the same trade the `drop` arm has always made for `drop the ball`. No DROP
2978
+ # shape is weakened, and the guard still fails in the safe direction.
2979
+ if matches "$SQL_DROP" \
2980
+ || matches "$SQL_TRUNCATE_EVIDENCE" \
2981
+ || matches "$SQL_CLIENT_TRUNCATE"; then
2982
+ block "destructive SQL (DROP/TRUNCATE) detected"
2983
+ fi
2984
+
2985
+ # 14. The git control plane. A recursive forced delete of `.git` destroys every
2986
+ # commit, branch and stash that is not already pushed — the one directory whose
2987
+ # loss is not recoverable from the working tree.
2988
+ #
2989
+ # This is not a new principle, it is a hole in one the guard already holds. Rule
2990
+ # 4 above already refuses `rm -rf "$SOMEDIR"` and paths outside the project;
2991
+ # `rm -rf .git` was permitted only because nothing named it. Measured before
2992
+ # this rule existed: `rm -rf .git`, `rm -rf ./.git` and `rm -rf .git/objects`
2993
+ # were all ALLOWED while the variable-expanded case was correctly blocked.
2994
+ #
2995
+ # `.git` must be a whole path component. `.gitignore`, `.gitattributes`,
2996
+ # `.github/` and a `.git-old` backup are ordinary files a project deletes on
2997
+ # purpose, and blocking those would be the collision that gets a guard switched
2998
+ # off. The trailing `([/[:space:]'"$qc"']|$)` is what draws that line.
2999
+ readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$)'
3000
+ # This guard is the one segment walk that pairs TWO facts found in different
3001
+ # places: the protected target (`.git`) and the destructive verb (a recursive
3002
+ # forced delete). That makes its segmentation load-bearing in a way the rm and
3003
+ # push walks' is not — those two read a verb and its own operands, which are
3004
+ # always in one pipeline stage, so splitting on `|` costs them nothing.
3005
+ #
3006
+ # Here it cost everything. The old feeder was `tr '&|;' '\n'`, which cut a
3007
+ # PIPELINE into pieces before the pairing question was asked, and a pipeline is
3008
+ # exactly how a delete reaches a target named upstream of it:
3009
+ # `find .git -type f -print0 | xargs -0 rm -rf` became `find .git -type f
3010
+ # -print0` (a target, no verb) and `xargs -0 rm -rf` (a verb, no target), and
3011
+ # neither half could answer. Measured before this fix, with the blanket
3012
+ # `xargs rm -rf` rule masking the reported shape: `find .git -type d | parallel
3013
+ # rm -rf`, `… | /usr/bin/xargs rm -rf`, `… | xargs -0 -- rm --recursive
3014
+ # --force`, `… 2>&1 | parallel rm -rf`, `… |& parallel rm -rf` and
3015
+ # `cat .git/config | grep url | rm -rf refs` were ALL allowed (CodySwannGT/lisa#3320).
3016
+ #
3017
+ # So segment on STATEMENT boundaries and nothing else. A pipe joins stages of
3018
+ # one statement; `;`, `&`, `&&` and `||` end one. Three spellings have to be
3019
+ # normalized first or a naive split gets them wrong in both directions:
3020
+ #
3021
+ # `|&` bash's pipe-with-stderr. Splitting its `&` would halve a pipeline —
3022
+ # the same fail-open, wearing a different operator. Becomes `|`.
3023
+ # `2>&1` a redirection, not a boundary (also `>&2`, `<&3`, `&>file`). These
3024
+ # are where `&` appears WITHOUT ending a statement, and they are
3025
+ # common enough that ignoring them would reopen the hole on any
3026
+ # pipeline that redirects stderr. The `&` is folded away.
3027
+ # `||` a real boundary that happens to be built from pipe characters, so it
3028
+ # must be recognized BEFORE a single `|` is spared. Becomes `;`.
3029
+ #
3030
+ # Every one of those rewrites only ever REMOVES a boundary or leaves it intact,
3031
+ # so each can merge two segments but never split one — the fail-closed
3032
+ # direction. `&` and `;` still separate, so unrelated commands are not joined.
3033
+ #
3034
+ # The leading awk is the same idea one level up. A line ending in `|` is bash's
3035
+ # OTHER implicit continuation — the pipeline resumes on the next line — and the
3036
+ # per-line read below would otherwise part the stages exactly as `tr` did, so
3037
+ # `find .git -type d |`↵` parallel rm -rf` fails open through the newline
3038
+ # instead of through the pipe. This mirrors the backslash-continuation join that
3039
+ # `normalized_command_str` already performs for the same reason. It is done here
3040
+ # rather than in that shared normalization deliberately: this guard is the one
3041
+ # that pairs facts across stages, so it is the one that needs the stages kept
3042
+ # together, and widening the shared normalization would change every other
3043
+ # guard's segmentation for no defect anyone has measured.
3044
+ #
3045
+ # The status check is the second half of the fix and closes a quieter hole. This
3046
+ # feeder had none, while the rm and push walks above both have one: a segmenter
3047
+ # that could not run produced no lines, the loop body never executed, and the
3048
+ # guard was skipped with the command ALLOWED. A segmentation that cannot answer
3049
+ # must refuse, exactly like a scan that cannot answer.
3050
+ # 14b. Linked worktrees, which are the same principle one level out. `.git` is
3051
+ # protected because nothing in the working tree can rebuild it; a linked
3052
+ # worktree is protected because nothing OUTSIDE it can rebuild what it holds.
3053
+ # The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
3054
+ # the object store are shared by every worktree of a repository, while
3055
+ # `.git/index` and the working files are per-worktree. So `git commit` moves
3056
+ # work somewhere a deletion cannot reach, and `git add` does not — a staged file
3057
+ # shows green in `git status`, which reads as saved, and dies with the
3058
+ # directory.
3059
+ #
3060
+ # Measured twice in one evening (issue #3863): one worktree lost a 284-line
3061
+ # staged test file, and a second lost nothing from the identical event purely
3062
+ # because a gate had forced its work into a commit first. Neither deletion
3063
+ # announced itself. That is the second reason this is a block rather than a
3064
+ # warning — a worktree deletion destroys the only evidence its own case could
3065
+ # leave, so there is no after-the-fact detection to fall back on.
3066
+ #
3067
+ # `git worktree remove` (guard 12) already refuses a dirty tree, and
3068
+ # `--force` is already blocked. This closes the spelling that walks around both:
3069
+ # `rm -rf` on the directory, which git never sees.
3070
+ #
3071
+ # Matching is by REGISTERED path — `git worktree list` names them — so an
3072
+ # ordinary directory that merely lives near one is untouched. A repository that
3073
+ # cannot be read answers "no": this guard adds protection to a shape that was
3074
+ # fully allowed a moment ago, and failing it closed would deny every recursive
3075
+ # delete on every path outside a repository.
3076
+ #
3077
+ # The comparison is on WHOLE TOKENS, which is the only spelling that gets both
3078
+ # error directions right. Substring containment would let a worktree named
3079
+ # `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
3080
+ # hand-written boundary classes leak the other way, because `rm -rf <wt>/`
3081
+ # with one trailing slash ends on a character every such class treats as
3082
+ # "inside a path". So every non-path character — quote, space, parenthesis,
3083
+ # redirection — is folded to a space first, which makes each argument a
3084
+ # space-delimited token regardless of how it was quoted, and the match is then
3085
+ # an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
3086
+ # deliberately not this guard's business: it is not a worktree deletion, and
3087
+ # blocking routine cleanup inside a worktree is how a guard gets switched off.
3088
+ names_linked_worktree() {
3089
+ local stmt="$1" probe root listed line primary wt rel
3090
+ root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
3091
+ [ -n "$root" ] || return 1
3092
+ listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
3093
+ probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
3094
+ primary=""
3095
+ while IFS= read -r line; do
3096
+ case "$line" in
3097
+ worktree\ *) wt="${line#worktree }" ;;
3098
+ *) continue ;;
3099
+ esac
3100
+ # First entry is the primary checkout, which is not a linked worktree.
3101
+ if [ -z "$primary" ]; then
3102
+ primary="$wt"
3103
+ continue
3104
+ fi
3105
+ case "$probe" in
3106
+ *" $wt "* | *" $wt/ "*) return 0 ;;
3107
+ esac
3108
+ rel="${wt#"$primary"/}"
3109
+ [ "$rel" != "$wt" ] || continue
3110
+ # `./` is the same path spelled the way an agent in the project root types
3111
+ # it, and a token comparison is exact, so it has to be spelled here too.
3112
+ case "$probe" in
3113
+ *" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
3114
+ esac
3115
+ done <<EOF
3116
+ $listed
3117
+ EOF
3118
+ return 1
3119
+ }
3120
+
3121
+ git_segments_status=0
3122
+ git_segments="$(printf '%s' "$normalized_command_str" \
3123
+ | awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
3124
+ | sed -e 's/|&/|/g' \
3125
+ -e 's/&>/>/g' \
3126
+ -e 's/[0-9]*>&[0-9-]*/>/g' \
3127
+ -e 's/[0-9]*<&[0-9-]*/</g' \
3128
+ -e 's/||/;/g' \
3129
+ | tr '&;' '\n' \
3130
+ | grep -E -- "$RM_RF_CLUSTER|$RM_RF_SPLIT")" || git_segments_status=$?
3131
+ case "$git_segments_status" in
3132
+ 0 | 1) ;;
3133
+ *) scan_failed "$git_segments_status" ;;
3134
+ esac
3135
+ while IFS= read -r git_stmt; do
3136
+ [ -n "$git_stmt" ] || continue
3137
+ if ! scan -E "$git_stmt" "$RM_RF_CLUSTER" \
3138
+ && ! scan -E "$git_stmt" "$RM_RF_SPLIT"; then
3139
+ continue
3140
+ fi
3141
+ if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
3142
+ block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it). Delete a specific ignored artifact instead. Re-cloning is NOT a like-for-like replacement: every linked worktree stores a pointer into this .git, so discarding it discards each of them and any uncommitted work they hold — check \`git worktree list\` before treating the checkout as disposable."
3143
+ fi
3144
+ if names_linked_worktree "$git_stmt"; then
3145
+ block "recursive forced delete of a linked git worktree. A worktree's index and working files are private to it — only commits are shared — so this destroys any staged or unwritten-to-a-commit work another agent left there, and destroys the evidence that it did (CodySwannGT/lisa#3863). Use 'node scripts/lisa-worktree-guard.mjs remove <path>' instead: it deletes clean and already-committed trees without ceremony, names the files when they exist nowhere else, and records an explicit --force override."
3146
+ fi
3147
+ done <<<"$git_segments"
3148
+
3149
+ # 15. Credential stores. Reading one into an agent transcript is disclosure even
3150
+ # when nothing is copied anywhere: the value lands in a log, a context window,
3151
+ # and whatever retains either.
3152
+ #
3153
+ # Scoped deliberately rather than by the obvious pattern. Each clause below is a
3154
+ # separate judgement about a family where an agent has no legitimate read, and
3155
+ # each carries the exclusions that keep ordinary work moving — public keys,
3156
+ # known_hosts, ssh config, and `.env.example` are all still readable, because a
3157
+ # guard that blocks routine setup is a guard somebody disables.
3158
+
3159
+ # The verbs that disclose: read it, duplicate it, or send it. Writing is NOT
3160
+ # here — `echo FOO=1 > .env` during setup creates a secret rather than leaking
3161
+ # one, and refusing it would block the very workflow that produces the file.
3162
+ readonly SECRET_READ_VERB='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?(cat|bat|less|more|head|tail|strings|xxd|od|base64|cp|mv|rsync|scp|sftp|tar|zip|curl|wget|http|nc)([[:space:]]|$)'
3163
+
3164
+ # 15a. SSH PRIVATE keys. `id_rsa.pub` and friends are public by construction and
3165
+ # stay readable; so do `known_hosts`, `config` and `authorized_keys`, which
3166
+ # agents legitimately inspect when diagnosing a remote.
3167
+ readonly SSH_PRIVATE_KEY='\.ssh/(id_[[:alnum:]_-]+|[[:alnum:]_.-]*_(rsa|dsa|ecdsa|ed25519))([[:space:]'"$qc"']|$)'
3168
+ # 15b. Cloud provider credential files.
3169
+ readonly CLOUD_CREDENTIAL='(\.aws/(credentials|config)|\.config/gcloud/[[:alnum:]_./-]*credentials[[:alnum:]_.-]*|\.azure/(accessTokens|msal_token_cache)[[:alnum:]_.-]*|\.kube/config)([[:space:]'"$qc"']|$)'
3170
+ # 15c. Coding-agent credential stores — the tokens that impersonate the operator
3171
+ # to every service their agent can reach.
3172
+ readonly AGENT_CREDENTIAL='(\.(claude|codex|cursor|copilot|gemini|antigravity)/[[:alnum:]_./-]*(credentials|auth|token)[[:alnum:]_.-]*|\.netrc|\.npmrc|\.pypirc|\.docker/config\.json)([[:space:]'"$qc"']|$)'
3173
+ # 15d. Dotenv files. `.env.example`, `.sample`, `.template`, `.dist` and
3174
+ # `.schema` are checked-in documentation of which keys exist, never the values,
3175
+ # so they are excluded — that collision is the one this rule most had to avoid.
3176
+ # Decide dotenv privacy per PATH, never by subtracting one command-wide match
3177
+ # from another. A command that names `.env.example` and `.env` contains both a
3178
+ # public path and a private one; seeing the former cannot authorize the latter.
3179
+ #
3180
+ # The one write-only shape this guard has always allowed remains explicit:
3181
+ # seeding a private dotenv destination from one public template. This is safe
3182
+ # because the private path is the destination, not a value being read back.
3183
+ has_private_dotenv_path() {
3184
+ dotenv_scan_status=0
3185
+ dotenv_candidates="$(printf '%s' "$normalized_command_str" \
3186
+ | grep -Eio -- '(^|[[:space:]='"$qc"'./])\.env(\.[[:alnum:]_-]+)?')" \
3187
+ || dotenv_scan_status=$?
3188
+ if [ "$dotenv_scan_status" -gt 1 ]; then
3189
+ scan_failed "$dotenv_scan_status"
3190
+ fi
3191
+
3192
+ while IFS= read -r candidate; do
3193
+ [ -n "$candidate" ] || continue
3194
+ dotenv_normalize_status=0
3195
+ candidate="$(printf '%s' "$candidate" \
3196
+ | tr '[:upper:]' '[:lower:]' \
3197
+ | grep -Eo -- '\.env(\.[[:alnum:]_-]+)?')" \
3198
+ || dotenv_normalize_status=$?
3199
+ if [ "$dotenv_normalize_status" -gt 1 ] || [ -z "$candidate" ]; then
3200
+ scan_failed "$dotenv_normalize_status"
3201
+ fi
3202
+ case "$candidate" in
3203
+ .env.example | .env.sample | .env.template | .env.dist | .env.schema | .env.defaults) ;;
3204
+ *) return 0 ;;
3205
+ esac
3206
+ done <<EOF
3207
+ $dotenv_candidates
3208
+ EOF
3209
+ return 1
3210
+ }
3211
+
3212
+ readonly SAFE_DOTENV_SEED='^[[:space:]]*(cp|mv)[[:space:]]+\.env\.(example|sample|template|dist|schema|defaults)[[:space:]]+\.env(\.[[:alnum:]_-]+)?[[:space:]]*$'
3213
+
3214
+ if matches "$SECRET_READ_VERB"; then
3215
+ if matches "$SSH_PRIVATE_KEY"; then
3216
+ block "reading, copying or transmitting an SSH PRIVATE key. Public keys (.pub), known_hosts and ssh config are unaffected; if you need the fingerprint use \`ssh-keygen -lf <key>.pub\`."
3217
+ fi
3218
+ if matches "$CLOUD_CREDENTIAL"; then
3219
+ block "reading, copying or transmitting a cloud provider credential file. Use the provider CLI's own identity command (\`aws sts get-caller-identity\`, \`gcloud auth list\`) instead of reading the file."
3220
+ fi
3221
+ if matches "$AGENT_CREDENTIAL"; then
3222
+ block "reading, copying or transmitting a coding-agent or package-registry credential store. These tokens impersonate the operator to every service the agent can reach."
3223
+ fi
3224
+ # A public path elsewhere in the command never exempts a private path. The
3225
+ # narrow public-template -> private-destination seed is write-only and remains
3226
+ # allowed; every other command containing a private dotenv path is refused.
3227
+ if has_private_dotenv_path && ! matches "$SAFE_DOTENV_SEED"; then
3228
+ block "reading, copying or transmitting a dotenv file holding real values. \`.env.example\`, \`.env.sample\`, \`.env.template\` and \`.env.dist\` are readable, and WRITING a .env is unaffected — only reading one back is refused."
3229
+ fi
3230
+ # Fail-closed, narrowly. A read whose DIRECTORY is a known credential store
3231
+ # but whose leaf is variable-expanded cannot be classified, and #2980's lesson
3232
+ # is that a guard which cannot classify must refuse rather than permit. Scoped
3233
+ # to credential directories on purpose: refusing every \`cat "\$FILE"\` would be
3234
+ # the over-reach that gets the whole hook switched off.
3235
+ if matches_cs '(\.ssh|\.aws|\.config/gcloud|\.azure|\.kube)/[^[:space:]'"$qc"']*\$'; then
3236
+ block "reading a path inside a credential directory whose filename is variable-expanded, so the guard cannot tell which file it is. Name the file explicitly if it is genuinely not a secret."
3237
+ fi
3238
+ fi
3239
+
3240
+ # 16. Project-local custom rules. Each non-comment line is an ERE; a match blocks.
3241
+ rules_file="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
3242
+ if [ -f "$rules_file" ]; then
3243
+ # Custom rules can intentionally govern text itself, so preserve their input.
3244
+ custom_command_str="$(printf '%s\n%s' "$custom_command_for_guards" "$followed_text" \
3245
+ | awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
3246
+ while IFS= read -r rule || [ -n "$rule" ]; do
3247
+ case "$rule" in
3248
+ '' | '#'*) continue ;;
3249
+ esac
3250
+ # Normalized text, for the same reason every built-in guard uses it: a
3251
+ # project rule must not be evadable by breaking the command across lines.
3252
+ #
3253
+ # `|| rule_status=$?` rather than a bare pipeline: this script runs under
3254
+ # `set -e`, and a no-match grep exits 1, which would end the hook right
3255
+ # here — before the remaining rules ran, and with an exit status that is
3256
+ # not a refusal. The `||` puts the pipeline in a condition context, where
3257
+ # a non-zero status is expected rather than fatal.
3258
+ rule_status=0
3259
+ printf '%s' "$custom_command_str" | grep -Eiq -- "$rule" || rule_status=$?
3260
+ case "$rule_status" in
3261
+ 0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
3262
+ 1) ;; # no match
3263
+ # grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
3264
+ # in a project's rules file silently disabled that rule — a guard the
3265
+ # project believed it had. Say so instead; still non-fatal, because one
3266
+ # bad line must not take the other rules down with it.
3267
+ *)
3268
+ printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
3269
+ "$rules_file" "$rule" >&2
3270
+ ;;
3271
+ esac
3272
+ done <"$rules_file"
3273
+ fi
3274
+
3275
+ exit 0