@soleur/opencode 0.0.0-stage → 1.0.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 (812) hide show
  1. package/.claude-plugin/agents.manifest.json +476 -0
  2. package/.claude-plugin/plugin.json +42 -0
  3. package/README.md +44 -2
  4. package/agents/community/.gitkeep +0 -0
  5. package/agents/engineering/cto.md +87 -0
  6. package/agents/engineering/design/ddd-architect.md +95 -0
  7. package/agents/engineering/discovery/agent-finder.md +214 -0
  8. package/agents/engineering/discovery/functional-discovery.md +182 -0
  9. package/agents/engineering/infra/infra-security.md +151 -0
  10. package/agents/engineering/infra/platform-strategist.md +109 -0
  11. package/agents/engineering/infra/terraform-architect.md +171 -0
  12. package/agents/engineering/prompt-engineer.md +37 -0
  13. package/agents/engineering/research/best-practices-researcher.md +115 -0
  14. package/agents/engineering/research/framework-docs-researcher.md +93 -0
  15. package/agents/engineering/research/git-history-analyzer.md +53 -0
  16. package/agents/engineering/research/learnings-researcher.md +285 -0
  17. package/agents/engineering/research/repo-research-analyst.md +118 -0
  18. package/agents/engineering/review/agent-native-reviewer.md +279 -0
  19. package/agents/engineering/review/architecture-strategist.md +61 -0
  20. package/agents/engineering/review/code-quality-analyst.md +92 -0
  21. package/agents/engineering/review/code-simplicity-reviewer.md +109 -0
  22. package/agents/engineering/review/data-integrity-guardian.md +82 -0
  23. package/agents/engineering/review/data-migration-expert.md +98 -0
  24. package/agents/engineering/review/deployment-verification-agent.md +183 -0
  25. package/agents/engineering/review/dhh-rails-reviewer.md +46 -0
  26. package/agents/engineering/review/kieran-rails-reviewer.md +87 -0
  27. package/agents/engineering/review/legacy-code-expert.md +55 -0
  28. package/agents/engineering/review/observability-coverage-reviewer.md +147 -0
  29. package/agents/engineering/review/pattern-recognition-specialist.md +59 -0
  30. package/agents/engineering/review/performance-oracle.md +130 -0
  31. package/agents/engineering/review/security-sentinel.md +167 -0
  32. package/agents/engineering/review/semgrep-sast.md +36 -0
  33. package/agents/engineering/review/test-design-reviewer.md +150 -0
  34. package/agents/engineering/review/user-impact-reviewer.md +70 -0
  35. package/agents/engineering/workflow/pr-comment-resolver.md +69 -0
  36. package/agents/finance/budget-analyst.md +29 -0
  37. package/agents/finance/cfo.md +44 -0
  38. package/agents/finance/financial-reporter.md +30 -0
  39. package/agents/finance/revenue-analyst.md +30 -0
  40. package/agents/legal/clo.md +115 -0
  41. package/agents/legal/legal-compliance-auditor.md +110 -0
  42. package/agents/legal/legal-document-generator.md +134 -0
  43. package/agents/marketing/analytics-analyst.md +25 -0
  44. package/agents/marketing/brand-architect.md +181 -0
  45. package/agents/marketing/cmo.md +76 -0
  46. package/agents/marketing/conversion-optimizer.md +19 -0
  47. package/agents/marketing/copywriter.md +20 -0
  48. package/agents/marketing/fact-checker.md +70 -0
  49. package/agents/marketing/growth-strategist.md +145 -0
  50. package/agents/marketing/paid-media-strategist.md +28 -0
  51. package/agents/marketing/pricing-strategist.md +17 -0
  52. package/agents/marketing/programmatic-seo-specialist.md +25 -0
  53. package/agents/marketing/retention-strategist.md +23 -0
  54. package/agents/marketing/seo-aeo-analyst.md +164 -0
  55. package/agents/operations/coo.md +50 -0
  56. package/agents/operations/ops-advisor.md +78 -0
  57. package/agents/operations/ops-provisioner.md +101 -0
  58. package/agents/operations/ops-research.md +52 -0
  59. package/agents/operations/service-automator.md +278 -0
  60. package/agents/product/business-validator.md +206 -0
  61. package/agents/product/competitive-intelligence.md +89 -0
  62. package/agents/product/cpo.md +63 -0
  63. package/agents/product/design/ux-design-lead.md +209 -0
  64. package/agents/product/spec-flow-analyzer.md +122 -0
  65. package/agents/sales/cro.md +44 -0
  66. package/agents/sales/deal-architect.md +29 -0
  67. package/agents/sales/outbound-strategist.md +29 -0
  68. package/agents/sales/pipeline-analyst.md +29 -0
  69. package/agents/support/cco.md +48 -0
  70. package/agents/support/community-manager.md +570 -0
  71. package/agents/support/ticket-triage.md +70 -0
  72. package/bin/soleur-opencode.js +29 -0
  73. package/commands/go.md +659 -0
  74. package/commands/help.md +273 -0
  75. package/commands/sync.md +977 -0
  76. package/commands/version.md +103 -0
  77. package/hooks/antigravity-hooks.json +84 -0
  78. package/hooks/antigravity-session-start.sh +15 -0
  79. package/hooks/bare-root-cwd-guard.sh +131 -0
  80. package/hooks/browser-snapshot-credential-guard.sh +200 -0
  81. package/hooks/codex-session-start.sh +9 -0
  82. package/hooks/compaction-state.sh +525 -0
  83. package/hooks/destructive-command-guard.sh +1387 -0
  84. package/hooks/devin-session-start.sh +90 -0
  85. package/hooks/ext-filter-detect.sh +171 -0
  86. package/hooks/hooks.json +133 -0
  87. package/hooks/lib/hook-tool-kind.sh +24 -0
  88. package/hooks/lib/log-rotation.sh +176 -0
  89. package/hooks/lib/shell-argv.pl +576 -0
  90. package/hooks/memory-backstop.sh +1114 -0
  91. package/hooks/operator-stage-approval.sh +391 -0
  92. package/hooks/stop-hook.sh +367 -0
  93. package/hooks/unkept-promise-hook.sh +383 -0
  94. package/hooks/version-banner.sh +127 -0
  95. package/hooks/welcome-hook.sh +50 -0
  96. package/install.js +108 -0
  97. package/lib/agent-registry.ts +154 -0
  98. package/lib/c4-canonical-cli.mjs +34 -0
  99. package/lib/c4-canonical.mjs +46 -0
  100. package/lib/c4-from-components.ts +554 -0
  101. package/lib/go-routing.ts +81 -0
  102. package/lib/grok-inspect-contract.ts +311 -0
  103. package/lib/harness-model-map.ts +101 -0
  104. package/lib/harness-parity.ts +726 -0
  105. package/lib/harness-tool-map.ts +180 -0
  106. package/lib/harness.ts +716 -0
  107. package/lib/heartbeat-live-reconcile.ts +1153 -0
  108. package/lib/heartbeat-manifest.ts +342 -0
  109. package/lib/kb-coverage.ts +252 -0
  110. package/lib/pr-merge-poll.ts +103 -0
  111. package/lib/workflow-fidelity.ts +388 -0
  112. package/opencode/INSTRUCTIONS.md +152 -0
  113. package/opencode/agents/engineering/cto.md +10 -0
  114. package/opencode/agents/engineering/design/ddd-architect.md +10 -0
  115. package/opencode/agents/engineering/discovery/agent-finder.md +10 -0
  116. package/opencode/agents/engineering/discovery/functional-discovery.md +10 -0
  117. package/opencode/agents/engineering/infra/infra-security.md +10 -0
  118. package/opencode/agents/engineering/infra/platform-strategist.md +10 -0
  119. package/opencode/agents/engineering/infra/terraform-architect.md +10 -0
  120. package/opencode/agents/engineering/prompt-engineer.md +10 -0
  121. package/opencode/agents/engineering/research/best-practices-researcher.md +10 -0
  122. package/opencode/agents/engineering/research/framework-docs-researcher.md +10 -0
  123. package/opencode/agents/engineering/research/git-history-analyzer.md +10 -0
  124. package/opencode/agents/engineering/research/learnings-researcher.md +10 -0
  125. package/opencode/agents/engineering/research/repo-research-analyst.md +10 -0
  126. package/opencode/agents/engineering/review/agent-native-reviewer.md +10 -0
  127. package/opencode/agents/engineering/review/architecture-strategist.md +10 -0
  128. package/opencode/agents/engineering/review/code-quality-analyst.md +10 -0
  129. package/opencode/agents/engineering/review/code-simplicity-reviewer.md +10 -0
  130. package/opencode/agents/engineering/review/data-integrity-guardian.md +10 -0
  131. package/opencode/agents/engineering/review/data-migration-expert.md +10 -0
  132. package/opencode/agents/engineering/review/deployment-verification-agent.md +10 -0
  133. package/opencode/agents/engineering/review/dhh-rails-reviewer.md +10 -0
  134. package/opencode/agents/engineering/review/kieran-rails-reviewer.md +10 -0
  135. package/opencode/agents/engineering/review/legacy-code-expert.md +10 -0
  136. package/opencode/agents/engineering/review/observability-coverage-reviewer.md +10 -0
  137. package/opencode/agents/engineering/review/pattern-recognition-specialist.md +10 -0
  138. package/opencode/agents/engineering/review/performance-oracle.md +10 -0
  139. package/opencode/agents/engineering/review/security-sentinel.md +10 -0
  140. package/opencode/agents/engineering/review/semgrep-sast.md +10 -0
  141. package/opencode/agents/engineering/review/test-design-reviewer.md +10 -0
  142. package/opencode/agents/engineering/review/user-impact-reviewer.md +10 -0
  143. package/opencode/agents/engineering/workflow/pr-comment-resolver.md +10 -0
  144. package/opencode/agents/finance/budget-analyst.md +10 -0
  145. package/opencode/agents/finance/cfo.md +10 -0
  146. package/opencode/agents/finance/financial-reporter.md +10 -0
  147. package/opencode/agents/finance/revenue-analyst.md +10 -0
  148. package/opencode/agents/legal/clo.md +10 -0
  149. package/opencode/agents/legal/legal-compliance-auditor.md +10 -0
  150. package/opencode/agents/legal/legal-document-generator.md +10 -0
  151. package/opencode/agents/marketing/analytics-analyst.md +10 -0
  152. package/opencode/agents/marketing/brand-architect.md +10 -0
  153. package/opencode/agents/marketing/cmo.md +10 -0
  154. package/opencode/agents/marketing/conversion-optimizer.md +10 -0
  155. package/opencode/agents/marketing/copywriter.md +10 -0
  156. package/opencode/agents/marketing/fact-checker.md +10 -0
  157. package/opencode/agents/marketing/growth-strategist.md +10 -0
  158. package/opencode/agents/marketing/paid-media-strategist.md +10 -0
  159. package/opencode/agents/marketing/pricing-strategist.md +10 -0
  160. package/opencode/agents/marketing/programmatic-seo-specialist.md +10 -0
  161. package/opencode/agents/marketing/retention-strategist.md +10 -0
  162. package/opencode/agents/marketing/seo-aeo-analyst.md +10 -0
  163. package/opencode/agents/operations/coo.md +10 -0
  164. package/opencode/agents/operations/ops-advisor.md +10 -0
  165. package/opencode/agents/operations/ops-provisioner.md +10 -0
  166. package/opencode/agents/operations/ops-research.md +10 -0
  167. package/opencode/agents/operations/service-automator.md +10 -0
  168. package/opencode/agents/product/business-validator.md +10 -0
  169. package/opencode/agents/product/competitive-intelligence.md +10 -0
  170. package/opencode/agents/product/cpo.md +10 -0
  171. package/opencode/agents/product/design/ux-design-lead.md +10 -0
  172. package/opencode/agents/product/spec-flow-analyzer.md +10 -0
  173. package/opencode/agents/sales/cro.md +10 -0
  174. package/opencode/agents/sales/deal-architect.md +10 -0
  175. package/opencode/agents/sales/outbound-strategist.md +10 -0
  176. package/opencode/agents/sales/pipeline-analyst.md +10 -0
  177. package/opencode/agents/support/cco.md +10 -0
  178. package/opencode/agents/support/community-manager.md +10 -0
  179. package/opencode/agents/support/ticket-triage.md +10 -0
  180. package/opencode/commands/go.md +10 -0
  181. package/opencode/commands/help.md +10 -0
  182. package/opencode/commands/sync.md +10 -0
  183. package/opencode/commands/version.md +11 -0
  184. package/opencode/opencode.json +8 -0
  185. package/opencode/plugin.ts +493 -0
  186. package/package.json +36 -4
  187. package/scripts/admin-merge-ready.sh +545 -0
  188. package/scripts/admin-merge-ready.test.sh +753 -0
  189. package/scripts/affected-tests.sh +908 -0
  190. package/scripts/affected-tests.test.sh +1797 -0
  191. package/scripts/alpha-metrics.sh +78 -0
  192. package/scripts/alpha-metrics.test.sh +115 -0
  193. package/scripts/ancestor-signal-helpers.baseline.txt +16 -0
  194. package/scripts/audit-flag-flip.sh +107 -0
  195. package/scripts/check-red-on-main.sh +316 -0
  196. package/scripts/ci-head-verdict.sh +363 -0
  197. package/scripts/cloud-detect.sh +193 -0
  198. package/scripts/delegation-verify.sh +657 -0
  199. package/scripts/delegation-verify.test.sh +1424 -0
  200. package/scripts/deploy-arm.sh +475 -0
  201. package/scripts/domain-model-drift.sh +419 -0
  202. package/scripts/emit-decision.sh +144 -0
  203. package/scripts/emit-decision.test.sh +144 -0
  204. package/scripts/ext-filter-divergence-corpus.txt +15 -0
  205. package/scripts/ext-filter-divergence.sh +189 -0
  206. package/scripts/generate-c4-from-components.ts +442 -0
  207. package/scripts/grok-fidelity-gate.sh +68 -0
  208. package/scripts/grok-pre-push-gate.sh +232 -0
  209. package/scripts/harness-discovery-smoke.ts +906 -0
  210. package/scripts/harness-parity-census.ts +92 -0
  211. package/scripts/inngest-luks-alert-armed-probe.ts +29 -0
  212. package/scripts/install-opencode-adapter.ts +520 -0
  213. package/scripts/lib/affected-tests/common.sh +365 -0
  214. package/scripts/lib/affected-tests/go.sh +157 -0
  215. package/scripts/lib/affected-tests/js.sh +188 -0
  216. package/scripts/lib/affected-tests/nx-turbo.sh +98 -0
  217. package/scripts/lib/affected-tests/python.sh +185 -0
  218. package/scripts/lib/context-sanitize.sh +49 -0
  219. package/scripts/lib/domain-model-lib.sh +180 -0
  220. package/scripts/lib/ext-filter-grammar.tsv +34 -0
  221. package/scripts/lib/operator-script.sh +1236 -0
  222. package/scripts/lib/proc.sh +580 -0
  223. package/scripts/lib/session-state.sh +748 -0
  224. package/scripts/lib/tmp-classify.sh +843 -0
  225. package/scripts/lib/wrapper-boundary.sh +47 -0
  226. package/scripts/lint-ratchet.py +1547 -0
  227. package/scripts/monitor-pr-checks.sh +441 -0
  228. package/scripts/pipeline-tally.sh +756 -0
  229. package/scripts/pipeline-tally.test.sh +740 -0
  230. package/scripts/plugin-version.sh +528 -0
  231. package/scripts/precommit-guard.sh +113 -0
  232. package/scripts/precommit-guard.test.sh +254 -0
  233. package/scripts/reconcile-live-heartbeats.ts +705 -0
  234. package/scripts/render-c4-model.sh +217 -0
  235. package/scripts/resolve-git-root.sh +55 -0
  236. package/scripts/resolve-regenerable-conflicts.sh +363 -0
  237. package/scripts/resolve-regenerable-conflicts.test.sh +780 -0
  238. package/scripts/ruff-requirements.txt +22 -0
  239. package/scripts/run-in-pid-namespace.sh +98 -0
  240. package/scripts/run-in-pid-namespace.test.sh +290 -0
  241. package/scripts/scan-ancestor-signal-helpers.py +556 -0
  242. package/scripts/scan-ancestor-signal-helpers.test.sh +670 -0
  243. package/scripts/setup-ralph-loop.sh +238 -0
  244. package/scripts/sync-cursor-name-map.ts +316 -0
  245. package/scripts/sync-grok-agent-compat.ts +188 -0
  246. package/scripts/sync-npm-package.ts +178 -0
  247. package/scripts/sync-opencode-adapter.ts +479 -0
  248. package/scripts/sync-pr-behind.sh +534 -0
  249. package/scripts/sync-pr-behind.test.sh +252 -0
  250. package/scripts/taste-profile-update.sh +180 -0
  251. package/scripts/taste-profile-update.test.sh +167 -0
  252. package/scripts/write-budget-marker.sh +23 -0
  253. package/scripts/write-kb-coverage.ts +113 -0
  254. package/skills/admin-ip-refresh/SKILL.md +81 -0
  255. package/skills/admin-ip-refresh/references/admin-ip-refresh-procedure.md +201 -0
  256. package/skills/agent-browser/SKILL.md +698 -0
  257. package/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh +304 -0
  258. package/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py +964 -0
  259. package/skills/agent-browser/scripts/redact-a11y-snapshot.py +443 -0
  260. package/skills/agent-browser/test/fixtures/capture-playwright-mcp-fixtures.py +217 -0
  261. package/skills/agent-browser/test/fixtures/fake-passthrough-proxy.py +24 -0
  262. package/skills/agent-browser/test/fixtures/fake-playwright-mcp.py +131 -0
  263. package/skills/agent-browser/test/fixtures/make-proxy-mutant.py +48 -0
  264. package/skills/agent-browser/test/fixtures/odd-shapes/dialog-link-injection.json +10 -0
  265. package/skills/agent-browser/test/fixtures/odd-shapes/empty-content.json +5 -0
  266. package/skills/agent-browser/test/fixtures/odd-shapes/error-and-result.json +4 -0
  267. package/skills/agent-browser/test/fixtures/odd-shapes/error-code-bool.json +6 -0
  268. package/skills/agent-browser/test/fixtures/odd-shapes/error-code-string.json +6 -0
  269. package/skills/agent-browser/test/fixtures/odd-shapes/error-message-tree.json +6 -0
  270. package/skills/agent-browser/test/fixtures/odd-shapes/error-no-data.json +6 -0
  271. package/skills/agent-browser/test/fixtures/odd-shapes/error-string.json +4 -0
  272. package/skills/agent-browser/test/fixtures/odd-shapes/error-with-data.json +9 -0
  273. package/skills/agent-browser/test/fixtures/odd-shapes/evaluate-escaped-object.json +10 -0
  274. package/skills/agent-browser/test/fixtures/odd-shapes/invalid-utf8.json +4 -0
  275. package/skills/agent-browser/test/fixtures/odd-shapes/link-result.json +10 -0
  276. package/skills/agent-browser/test/fixtures/odd-shapes/list-two-results.json +6 -0
  277. package/skills/agent-browser/test/fixtures/odd-shapes/meta-in-result.json +13 -0
  278. package/skills/agent-browser/test/fixtures/odd-shapes/no-content.json +5 -0
  279. package/skills/agent-browser/test/fixtures/odd-shapes/quoted-key.json +10 -0
  280. package/skills/agent-browser/test/fixtures/odd-shapes/resource-block.json +18 -0
  281. package/skills/agent-browser/test/fixtures/odd-shapes/result-and-method.json +11 -0
  282. package/skills/agent-browser/test/fixtures/odd-shapes/result-string.json +3 -0
  283. package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped-one-row.json +10 -0
  284. package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped.json +10 -0
  285. package/skills/agent-browser/test/fixtures/odd-shapes/structured-content.json +13 -0
  286. package/skills/agent-browser/test/fixtures/odd-shapes/tools-list-no-tools.json +5 -0
  287. package/skills/agent-browser/test/fixtures/odd-shapes/tree-no-credential.json +10 -0
  288. package/skills/agent-browser/test/fixtures/odd-shapes/two-text-blocks.json +14 -0
  289. package/skills/agent-browser/test/fixtures/odd-shapes/unparsable-line.json +3 -0
  290. package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-brace.json +10 -0
  291. package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-link-substring.json +10 -0
  292. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/close.json +12 -0
  293. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/evaluate.json +12 -0
  294. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/find.json +12 -0
  295. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/framing.json +6 -0
  296. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/initialize.json +14 -0
  297. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate-link.json +12 -0
  298. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate.json +12 -0
  299. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/roots-list-request.json +5 -0
  300. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/screenshot.json +17 -0
  301. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-filename.json +12 -0
  302. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-json.json +12 -0
  303. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-raw.json +12 -0
  304. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot.json +12 -0
  305. package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/tools-list.json +827 -0
  306. package/skills/agent-browser/test/fixtures/proxy-session-driver.py +138 -0
  307. package/skills/agent-browser/test/fixtures/session-facts.py +102 -0
  308. package/skills/agent-browser/test/playwright-mcp-lifetime.test.sh +1620 -0
  309. package/skills/agent-browser/test/playwright-mcp-redact-proxy.test.sh +1599 -0
  310. package/skills/agent-browser/test/redact-a11y-snapshot.test.sh +527 -0
  311. package/skills/agent-native-architecture/SKILL.md +479 -0
  312. package/skills/agent-native-architecture/references/action-parity-discipline.md +430 -0
  313. package/skills/agent-native-architecture/references/agent-execution-patterns.md +483 -0
  314. package/skills/agent-native-architecture/references/agent-native-testing.md +605 -0
  315. package/skills/agent-native-architecture/references/architecture-patterns.md +493 -0
  316. package/skills/agent-native-architecture/references/dynamic-context-injection.md +358 -0
  317. package/skills/agent-native-architecture/references/files-universal-interface.md +314 -0
  318. package/skills/agent-native-architecture/references/from-primitives-to-domain-tools.md +381 -0
  319. package/skills/agent-native-architecture/references/mcp-tool-design.md +524 -0
  320. package/skills/agent-native-architecture/references/mobile-patterns.md +897 -0
  321. package/skills/agent-native-architecture/references/product-implications.md +478 -0
  322. package/skills/agent-native-architecture/references/refactoring-to-prompt-native.md +342 -0
  323. package/skills/agent-native-architecture/references/self-modification.md +294 -0
  324. package/skills/agent-native-architecture/references/shared-workspace-architecture.md +700 -0
  325. package/skills/agent-native-architecture/references/system-prompt-design.md +263 -0
  326. package/skills/agent-native-audit/SKILL.md +300 -0
  327. package/skills/agent-native-audit/workflows/agent-native-audit.workflow.js +423 -0
  328. package/skills/andrew-kane-gem-writer/SKILL.md +189 -0
  329. package/skills/andrew-kane-gem-writer/references/database-adapters.md +231 -0
  330. package/skills/andrew-kane-gem-writer/references/module-organization.md +121 -0
  331. package/skills/andrew-kane-gem-writer/references/rails-integration.md +183 -0
  332. package/skills/andrew-kane-gem-writer/references/resources.md +126 -0
  333. package/skills/andrew-kane-gem-writer/references/testing-patterns.md +261 -0
  334. package/skills/architecture/SKILL.md +436 -0
  335. package/skills/architecture/references/adr-template.md +154 -0
  336. package/skills/architecture/references/likec4-reference.md +136 -0
  337. package/skills/architecture/references/nfr-reference.md +126 -0
  338. package/skills/archive-kb/SKILL.md +86 -0
  339. package/skills/archive-kb/scripts/archive-kb.sh +300 -0
  340. package/skills/archive-kb/test/archive-kb-partial-run.test.sh +276 -0
  341. package/skills/atdd-developer/SKILL.md +65 -0
  342. package/skills/brainstorm/SKILL.md +701 -0
  343. package/skills/brainstorm/references/brainstorm-brand-workshop.md +153 -0
  344. package/skills/brainstorm/references/brainstorm-domain-config.md +63 -0
  345. package/skills/brainstorm/references/brainstorm-validation-workshop.md +65 -0
  346. package/skills/brainstorm/references/ui-surface-terms.md +42 -0
  347. package/skills/brainstorm-techniques/SKILL.md +231 -0
  348. package/skills/brainstorm-techniques/references/decision-principles.md +150 -0
  349. package/skills/brainstorm-techniques/references/phase-boundaries.md +103 -0
  350. package/skills/brainstorm-techniques/references/standing-delegation.md +407 -0
  351. package/skills/brainstorm-techniques/references/technical-ruling-delegation.md +174 -0
  352. package/skills/campaign-calendar/SKILL.md +145 -0
  353. package/skills/cf-token-scope/SKILL.md +164 -0
  354. package/skills/cf-token-scope/references/widen-playbook.md +114 -0
  355. package/skills/cf-token-scope/scripts/cf-token-scope.sh +253 -0
  356. package/skills/changelog/SKILL.md +142 -0
  357. package/skills/code-to-prd/SKILL.md +66 -0
  358. package/skills/code-to-prd/references/banner-template.md +12 -0
  359. package/skills/code-to-prd/references/prd-template.md +41 -0
  360. package/skills/code-to-prd/scripts/code-to-prd.sh +634 -0
  361. package/skills/code-to-prd/test/code-to-prd.test.sh +447 -0
  362. package/skills/code-to-prd/test/fixture/.env.example +7 -0
  363. package/skills/code-to-prd/test/fixture/app/about/page.tsx +11 -0
  364. package/skills/code-to-prd/test/fixture/app/api/health/route.ts +10 -0
  365. package/skills/code-to-prd/test/fixture/app/page.tsx +14 -0
  366. package/skills/code-to-prd/test/fixture/next.config.mjs +3 -0
  367. package/skills/code-to-prd/test/fixture/package.json +6 -0
  368. package/skills/cohort-status/SKILL.md +62 -0
  369. package/skills/community/SKILL.md +210 -0
  370. package/skills/community/scripts/bsky-community.sh +644 -0
  371. package/skills/community/scripts/bsky-setup.sh +631 -0
  372. package/skills/community/scripts/community-router.sh +93 -0
  373. package/skills/community/scripts/discord-community.sh +487 -0
  374. package/skills/community/scripts/discord-setup.sh +784 -0
  375. package/skills/community/scripts/github-community.sh +706 -0
  376. package/skills/community/scripts/hn-community.sh +233 -0
  377. package/skills/community/scripts/lib/hmac-sha1-b64.sh +126 -0
  378. package/skills/community/scripts/linkedin-community.sh +750 -0
  379. package/skills/community/scripts/linkedin-setup.sh +758 -0
  380. package/skills/community/scripts/x-community.sh +890 -0
  381. package/skills/community/scripts/x-setup.sh +713 -0
  382. package/skills/community/test/community-argv.test.sh +2922 -0
  383. package/skills/community/test/github-community.test.sh +981 -0
  384. package/skills/competitive-analysis/SKILL.md +69 -0
  385. package/skills/competitive-analysis/references/peer-plugin-audit.md +218 -0
  386. package/skills/compound/SKILL.md +673 -0
  387. package/skills/compound/scripts/token-efficiency-report.sh +334 -0
  388. package/skills/compound/test/phase-16.test.sh +379 -0
  389. package/skills/compound-capture/SKILL.md +791 -0
  390. package/skills/compound-capture/assets/critical-pattern-template.md +37 -0
  391. package/skills/compound-capture/assets/resolution-template.md +116 -0
  392. package/skills/compound-capture/references/yaml-schema.md +67 -0
  393. package/skills/compound-capture/schema.yaml +181 -0
  394. package/skills/constraint-scaffold/SKILL.md +175 -0
  395. package/skills/constraint-scaffold/references/boundary-readme.template +35 -0
  396. package/skills/constraint-scaffold/references/constraint-gates-workflow.template +69 -0
  397. package/skills/constraint-scaffold/references/depcruise-config.template +230 -0
  398. package/skills/constraint-scaffold/references/fix-constraints-stage-a.template +240 -0
  399. package/skills/constraint-scaffold/references/fix-constraints-stage-b.template +313 -0
  400. package/skills/constraint-scaffold/references/shared-runner.template +135 -0
  401. package/skills/constraint-scaffold/scripts/constraint-scaffold.sh +589 -0
  402. package/skills/constraint-scaffold/test/bite-proof.test.sh +1338 -0
  403. package/skills/constraint-scaffold/test/boundary.test.sh +734 -0
  404. package/skills/constraint-scaffold/test/emit-fix-constraints.test.sh +193 -0
  405. package/skills/constraint-scaffold/test/generator.test.sh +93 -0
  406. package/skills/constraint-scaffold/test/parity.test.sh +285 -0
  407. package/skills/content-writer/SKILL.md +242 -0
  408. package/skills/content-writer/scripts/blog-jargon-scan.sh +42 -0
  409. package/skills/cron-delete/SKILL.md +56 -0
  410. package/skills/cron-list/SKILL.md +54 -0
  411. package/skills/deepen-plan/SKILL.md +937 -0
  412. package/skills/deepen-plan/workflows/deepen-plan.workflow.js +488 -0
  413. package/skills/deploy/SKILL.md +115 -0
  414. package/skills/deploy/references/hetzner-setup.md +65 -0
  415. package/skills/deploy/scripts/deploy.sh +37 -0
  416. package/skills/deploy-docs/SKILL.md +95 -0
  417. package/skills/dhh-rails-style/SKILL.md +203 -0
  418. package/skills/dhh-rails-style/references/architecture.md +717 -0
  419. package/skills/dhh-rails-style/references/controllers.md +323 -0
  420. package/skills/dhh-rails-style/references/frontend.md +556 -0
  421. package/skills/dhh-rails-style/references/gems.md +316 -0
  422. package/skills/dhh-rails-style/references/models.md +388 -0
  423. package/skills/dhh-rails-style/references/testing.md +354 -0
  424. package/skills/discord-content/SKILL.md +126 -0
  425. package/skills/docs-site/SKILL.md +214 -0
  426. package/skills/drain-labeled-backlog/SKILL.md +203 -0
  427. package/skills/drain-labeled-backlog/scripts/group-by-area.sh +208 -0
  428. package/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js +433 -0
  429. package/skills/drain-prs/SKILL.md +142 -0
  430. package/skills/drain-prs/scripts/triage-prs.sh +192 -0
  431. package/skills/drain-prs/test/argv-ceiling.test.sh +183 -0
  432. package/skills/dspy-ruby/SKILL.md +634 -0
  433. package/skills/dspy-ruby/assets/config-template.rb +359 -0
  434. package/skills/dspy-ruby/assets/module-template.rb +326 -0
  435. package/skills/dspy-ruby/assets/signature-template.rb +143 -0
  436. package/skills/dspy-ruby/references/core-concepts.md +268 -0
  437. package/skills/dspy-ruby/references/optimization.md +623 -0
  438. package/skills/dspy-ruby/references/providers.md +335 -0
  439. package/skills/eval-harness/README.md +194 -0
  440. package/skills/eval-harness/SKILL.md +157 -0
  441. package/skills/eval-harness/enums/go-routes.json +13 -0
  442. package/skills/eval-harness/enums/incident-threshold.json +5 -0
  443. package/skills/eval-harness/enums/lane.json +5 -0
  444. package/skills/eval-harness/enums/tool-selection.json +20 -0
  445. package/skills/eval-harness/enums/triage-levels.json +5 -0
  446. package/skills/eval-harness/gated-skills.json +34 -0
  447. package/skills/eval-harness/models.generated.json +5 -0
  448. package/skills/eval-harness/promptfooconfig-go-routing.yaml +35 -0
  449. package/skills/eval-harness/promptfooconfig-incident-threshold.yaml +32 -0
  450. package/skills/eval-harness/promptfooconfig-lane-inference.yaml +33 -0
  451. package/skills/eval-harness/promptfooconfig-ticket-triage.yaml +34 -0
  452. package/skills/eval-harness/promptfooconfig-tool-selection.yaml +33 -0
  453. package/skills/eval-harness/prompts/go-baseline.txt +6 -0
  454. package/skills/eval-harness/prompts/go-skill.txt +21 -0
  455. package/skills/eval-harness/prompts/incident-threshold-baseline.txt +6 -0
  456. package/skills/eval-harness/prompts/incident-threshold-skill.txt +12 -0
  457. package/skills/eval-harness/prompts/lane-inference-baseline.txt +6 -0
  458. package/skills/eval-harness/prompts/lane-inference-skill.txt +15 -0
  459. package/skills/eval-harness/prompts/tool-selection-baseline.txt +19 -0
  460. package/skills/eval-harness/prompts/tool-selection-skill.txt +22 -0
  461. package/skills/eval-harness/prompts/triage-baseline.txt +7 -0
  462. package/skills/eval-harness/prompts/triage-skill.txt +10 -0
  463. package/skills/eval-harness/scripts/eval-gate.cjs +318 -0
  464. package/skills/eval-harness/scripts/extract-block.cjs +87 -0
  465. package/skills/eval-harness/scripts/gate-classification.cjs +28 -0
  466. package/skills/eval-harness/scripts/gen-models.sh +41 -0
  467. package/skills/eval-harness/scripts/gen-skill-prompt.cjs +177 -0
  468. package/skills/eval-harness/scripts/measure-classification.cjs +30 -0
  469. package/skills/eval-harness/scripts/parse-label.cjs +93 -0
  470. package/skills/eval-harness/scripts/verdict.cjs +132 -0
  471. package/skills/eval-harness/tasks/go-routing.jsonl +8 -0
  472. package/skills/eval-harness/tasks/incident-threshold.jsonl +7 -0
  473. package/skills/eval-harness/tasks/lane-inference.jsonl +7 -0
  474. package/skills/eval-harness/tasks/ticket-triage.jsonl +6 -0
  475. package/skills/eval-harness/tasks/tool-selection.jsonl +15 -0
  476. package/skills/eval-harness/test/eval-gate.test.sh +132 -0
  477. package/skills/eval-harness/test/extract-block.test.sh +87 -0
  478. package/skills/eval-harness/test/gate-classification.test.sh +77 -0
  479. package/skills/eval-harness/test/gen-models.test.sh +75 -0
  480. package/skills/eval-harness/test/measure-classification.test.sh +74 -0
  481. package/skills/eval-harness/test/parse-label.test.sh +129 -0
  482. package/skills/eval-harness/test/registry-completeness.test.sh +109 -0
  483. package/skills/eval-harness/test/verdict.test.sh +123 -0
  484. package/skills/every-style-editor/SKILL.md +142 -0
  485. package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +529 -0
  486. package/skills/feature-tweet/SKILL.md +199 -0
  487. package/skills/feature-video/SKILL.md +412 -0
  488. package/skills/feature-video/scripts/check_deps.sh +281 -0
  489. package/skills/file-todos/SKILL.md +267 -0
  490. package/skills/file-todos/assets/todo-template.md +166 -0
  491. package/skills/fix-issue/SKILL.md +278 -0
  492. package/skills/fix-issue/references/agent-authored-exclusion.md +118 -0
  493. package/skills/fix-issue/references/exclude-label-jq-snippet.md +96 -0
  494. package/skills/flag-bootstrap/SETUP.md +142 -0
  495. package/skills/flag-create/SKILL.md +145 -0
  496. package/skills/flag-create/scripts/create.sh +273 -0
  497. package/skills/flag-delete/SKILL.md +167 -0
  498. package/skills/flag-delete/scripts/delete.sh +288 -0
  499. package/skills/flag-list/SKILL.md +99 -0
  500. package/skills/flag-list/scripts/list.sh +275 -0
  501. package/skills/flag-set-role/SKILL.md +229 -0
  502. package/skills/flag-set-role/scripts/flip.sh +774 -0
  503. package/skills/frontend-anti-slop/SKILL.md +106 -0
  504. package/skills/frontend-anti-slop/references/anti-patterns.md +199 -0
  505. package/skills/frontend-anti-slop/references/slop-rules.md +65 -0
  506. package/skills/frontend-anti-slop/scripts/tier1-scan.ts +525 -0
  507. package/skills/frontend-design/SKILL.md +77 -0
  508. package/skills/gdpr-gate/NOTICE +136 -0
  509. package/skills/gdpr-gate/SKILL.md +341 -0
  510. package/skills/gdpr-gate/references/fields.md +160 -0
  511. package/skills/gdpr-gate/references/layers/api-layer.md +355 -0
  512. package/skills/gdpr-gate/references/layers/auth-sessions.md +290 -0
  513. package/skills/gdpr-gate/references/layers/data-in-transit.md +331 -0
  514. package/skills/gdpr-gate/references/layers/data-lifecycle.md +341 -0
  515. package/skills/gdpr-gate/references/layers/frontend.md +249 -0
  516. package/skills/gdpr-gate/references/layers/testing-seeding.md +256 -0
  517. package/skills/gdpr-gate/references/leakage-vectors.md +248 -0
  518. package/skills/gdpr-gate/references/legacy/legal-consent-v1-prose.md +48 -0
  519. package/skills/gdpr-gate/references/legal-consent.md +140 -0
  520. package/skills/gdpr-gate/references/non-negotiables.md +60 -0
  521. package/skills/gdpr-gate/scripts/gdpr-gate.sh +271 -0
  522. package/skills/gdpr-gate/scripts/notice-frontmatter.sh +337 -0
  523. package/skills/gdpr-gate/scripts/path-denylist.txt +39 -0
  524. package/skills/gdpr-gate/scripts/repo-scan.sh +158 -0
  525. package/skills/gdpr-gate/scripts/vendor-drift-classify.sh +120 -0
  526. package/skills/gdpr-gate/scripts/vendor-pin-integrity.sh +237 -0
  527. package/skills/gemini-imagegen/SKILL.md +283 -0
  528. package/skills/gemini-imagegen/requirements.txt +5 -0
  529. package/skills/gemini-imagegen/scripts/_error_handling.py +146 -0
  530. package/skills/gemini-imagegen/scripts/compose_images.py +150 -0
  531. package/skills/gemini-imagegen/scripts/edit_image.py +137 -0
  532. package/skills/gemini-imagegen/scripts/gemini_images.py +256 -0
  533. package/skills/gemini-imagegen/scripts/generate_image.py +147 -0
  534. package/skills/gemini-imagegen/scripts/multi_turn_chat.py +223 -0
  535. package/skills/gemini-imagegen/scripts/test_error_handling.py +179 -0
  536. package/skills/git-worktree/SKILL.md +534 -0
  537. package/skills/git-worktree/scripts/git-repo-readiness-diag.sh +98 -0
  538. package/skills/git-worktree/scripts/worktree-manager.sh +4773 -0
  539. package/skills/git-worktree/test/create-from-origin-main.test.sh +116 -0
  540. package/skills/git-worktree/test/lease-protects-active.test.sh +1056 -0
  541. package/skills/git-worktree/test/no-repo-fail-loud.test.sh +88 -0
  542. package/skills/git-worktree/test/orphan-reaper-honest-count.test.sh +379 -0
  543. package/skills/git-worktree/test/reap-archive-persistence.test.sh +725 -0
  544. package/skills/git-worktree/test/stale-lock-sweep.test.sh +176 -0
  545. package/skills/go/SKILL.md +54 -0
  546. package/skills/growth/SKILL.md +164 -0
  547. package/skills/harvest-debt/SKILL.md +78 -0
  548. package/skills/harvest-debt/scripts/harvest-debt.sh +114 -0
  549. package/skills/heal-skill/SKILL.md +173 -0
  550. package/skills/help/SKILL.md +46 -0
  551. package/skills/incident/NOTICE +39 -0
  552. package/skills/incident/SKILL.md +395 -0
  553. package/skills/incident/scripts/dry-run.sh +448 -0
  554. package/skills/incident/scripts/redact-engine.py +412 -0
  555. package/skills/incident/scripts/redact-sentinel.sh +44 -0
  556. package/skills/incident/templates/pir.md +139 -0
  557. package/skills/incident/test/fixtures/dry-run-incident.json +26 -0
  558. package/skills/incident/test/fixtures/dry-run-secret-leak.json +26 -0
  559. package/skills/incident/test/fixtures/legacy-bash-scanner.sh +82 -0
  560. package/skills/incident/test/fixtures/positive-corpus.md +87 -0
  561. package/skills/incident/test/redact-sentinel.test.sh +1420 -0
  562. package/skills/invoice/SKILL.md +206 -0
  563. package/skills/kb-glossary/SKILL.md +71 -0
  564. package/skills/kb-glossary/references/glossary-format.md +111 -0
  565. package/skills/kb-glossary/references/rejected-request-register.md +115 -0
  566. package/skills/kb-search/SKILL.md +276 -0
  567. package/skills/kb-search/scripts/kb-search-cache.sh +123 -0
  568. package/skills/legal-audit/SKILL.md +161 -0
  569. package/skills/legal-generate/NOTICE +242 -0
  570. package/skills/legal-generate/SKILL.md +244 -0
  571. package/skills/legal-generate/references/templates/advisor-agreement/template.md +12 -0
  572. package/skills/legal-generate/references/templates/business-associate-agreement/template.md +112 -0
  573. package/skills/legal-generate/references/templates/cookie-notice/template.md +82 -0
  574. package/skills/legal-generate/references/templates/dpa-global/template.md +439 -0
  575. package/skills/legal-generate/references/templates/dpa-us/template.md +222 -0
  576. package/skills/legal-generate/references/templates/employee-offer-letter/template.md +75 -0
  577. package/skills/legal-generate/references/templates/master-services-agreement/template.md +252 -0
  578. package/skills/legal-generate/references/templates/mutual-nda/template.md +66 -0
  579. package/skills/legal-generate/references/templates/one-way-nda/template.md +38 -0
  580. package/skills/legal-generate/references/templates/privacy-policy-gdpr/template.md +427 -0
  581. package/skills/legal-generate/references/templates/privacy-policy-us/template.md +313 -0
  582. package/skills/legal-generate/references/templates/terms-of-use/template.md +75 -0
  583. package/skills/legal-generate/scripts/strip-vendor-credit.sh +96 -0
  584. package/skills/linear-fetch/SKILL.md +247 -0
  585. package/skills/linear-fetch/scripts/assert-no-linear-telemetry.sh +66 -0
  586. package/skills/linear-fetch/scripts/redact-linear-urls.sh +51 -0
  587. package/skills/linear-fetch/scripts/render-caller-template.sh +36 -0
  588. package/skills/linear-fetch/test/assert-no-linear-telemetry.test.sh +113 -0
  589. package/skills/linear-fetch/test/parity.test.sh +112 -0
  590. package/skills/linear-fetch/test/persist-safe-integration.test.sh +191 -0
  591. package/skills/linear-fetch/test/redact-linear-urls.test.sh +226 -0
  592. package/skills/merge-pr/SKILL.md +668 -0
  593. package/skills/model-launch-review/SKILL.md +255 -0
  594. package/skills/model-launch-review/scripts/audit-models.sh +501 -0
  595. package/skills/one-shot/SKILL.md +316 -0
  596. package/skills/one-shot/references/token-discipline.md +74 -0
  597. package/skills/operator-bootstrap/SKILL.md +412 -0
  598. package/skills/operator-bootstrap/template.sh +439 -0
  599. package/skills/operator-digest/SKILL.md +287 -0
  600. package/skills/operator-digest/assets/operator-digest.workflow.yml +175 -0
  601. package/skills/operator-digest/scripts/digest-scrub.sh +119 -0
  602. package/skills/operator-digest/scripts/provision-operator-digest-repo.sh +147 -0
  603. package/skills/operator-rephrase/SKILL.md +98 -0
  604. package/skills/pencil-setup/SKILL.md +201 -0
  605. package/skills/pencil-setup/scripts/check_deps.sh +556 -0
  606. package/skills/pencil-setup/scripts/copy_adapter.sh +80 -0
  607. package/skills/pencil-setup/scripts/package-lock.json +1172 -0
  608. package/skills/pencil-setup/scripts/package.json +14 -0
  609. package/skills/pencil-setup/scripts/pencil-error-enrichment.mjs +21 -0
  610. package/skills/pencil-setup/scripts/pencil-mcp-adapter.mjs +733 -0
  611. package/skills/pencil-setup/scripts/pencil-response-classify.mjs +38 -0
  612. package/skills/pencil-setup/scripts/pencil-save-gate.mjs +26 -0
  613. package/skills/pencil-setup/scripts/sanitize-filename.mjs +25 -0
  614. package/skills/plan/SKILL.md +1140 -0
  615. package/skills/plan/references/differential-migration-testing.md +185 -0
  616. package/skills/plan/references/plan-community-discovery.md +42 -0
  617. package/skills/plan/references/plan-founder-check.md +137 -0
  618. package/skills/plan/references/plan-functional-overlap.md +25 -0
  619. package/skills/plan/references/plan-host-fanout.md +51 -0
  620. package/skills/plan/references/plan-issue-templates.md +779 -0
  621. package/skills/plan/references/plan-network-outage-checklist.md +131 -0
  622. package/skills/plan/references/plan-scope-check.md +126 -0
  623. package/skills/plan/references/plan-sharp-edges.md +325 -0
  624. package/skills/plan/references/plan-signoff-lifecycle.md +12 -0
  625. package/skills/plan/references/webapp-cwv-observability.md +143 -0
  626. package/skills/plan-review/SKILL.md +91 -0
  627. package/skills/plan-review/lib/named-panel.mjs +58 -0
  628. package/skills/plan-review/workflows/plan-review.workflow.js +477 -0
  629. package/skills/postmerge/SKILL.md +642 -0
  630. package/skills/postmerge/references/deploy-status-debugging.md +138 -0
  631. package/skills/postmerge/references/graceful-degradation.md +20 -0
  632. package/skills/postmerge/references/sentry-error-count-delta.md +137 -0
  633. package/skills/preflight/SKILL.md +1553 -0
  634. package/skills/preflight/references/check-13-founder-check.md +286 -0
  635. package/skills/preflight/scripts/founder-check.py +1640 -0
  636. package/skills/preflight/scripts/parse-form-a.awk +107 -0
  637. package/skills/preflight/scripts/probe-verb-gate.sh +108 -0
  638. package/skills/product-roadmap/SKILL.md +357 -0
  639. package/skills/product-roadmap/scripts/roadmap-reconcile.sh +327 -0
  640. package/skills/provision-cloudflare/SKILL.md +67 -0
  641. package/skills/provision-cloudflare/scripts/provision-cloudflare.sh +271 -0
  642. package/skills/provision-doppler/SKILL.md +58 -0
  643. package/skills/provision-doppler/scripts/provision-doppler.sh +358 -0
  644. package/skills/provision-github/SKILL.md +59 -0
  645. package/skills/provision-github/scripts/provision-github.sh +300 -0
  646. package/skills/provision-hetzner/SKILL.md +76 -0
  647. package/skills/provision-hetzner/scripts/provision-hetzner.sh +262 -0
  648. package/skills/provision-hetzner/test/fixture/knowledge-base/legal/tenant-dpa-register.md +26 -0
  649. package/skills/provision-hetzner/test/fixture/provision-hetzner-dry-run.golden +34 -0
  650. package/skills/provision-hetzner/test/provision-hetzner-characterization.test.sh +227 -0
  651. package/skills/qa/SKILL.md +263 -0
  652. package/skills/qa/references/browser-preflight.md +22 -0
  653. package/skills/questionnaire-generate/SKILL.md +328 -0
  654. package/skills/questionnaire-generate/references/questionnaire.template +60 -0
  655. package/skills/rclone/SKILL.md +162 -0
  656. package/skills/rclone/scripts/check_setup.sh +63 -0
  657. package/skills/release-announce/SKILL.md +65 -0
  658. package/skills/release-docs/SKILL.md +92 -0
  659. package/skills/reproduce-bug/SKILL.md +358 -0
  660. package/skills/resolve-debt/SKILL.md +85 -0
  661. package/skills/resolve-debt/scripts/resolve-debt.py +584 -0
  662. package/skills/resolve-parallel/SKILL.md +45 -0
  663. package/skills/resolve-parallel/workflows/resolve-parallel.workflow.js +415 -0
  664. package/skills/resolve-pr-parallel/SKILL.md +68 -0
  665. package/skills/resolve-pr-parallel/workflows/resolve-pr-parallel.workflow.js +419 -0
  666. package/skills/resolve-todo-parallel/SKILL.md +57 -0
  667. package/skills/resolve-todo-parallel/workflows/resolve-todo-parallel.workflow.js +398 -0
  668. package/skills/review/SKILL.md +1621 -0
  669. package/skills/review/references/anti-slop-scanner-hook.md +40 -0
  670. package/skills/review/references/defect-classes-migrations.md +9 -0
  671. package/skills/review/references/generated-input-oracle-surfaces.md +15 -0
  672. package/skills/review/references/review-e2e-testing.md +116 -0
  673. package/skills/review/references/review-todo-structure.md +165 -0
  674. package/skills/review/references/risk-tier-and-fix-rounds.md +95 -0
  675. package/skills/review/references/semgrep-custom-rules.yaml +174 -0
  676. package/skills/review/references/ultrathink-phases.md +98 -0
  677. package/skills/review/references/wfs.md +6 -0
  678. package/skills/review/scripts/emit-review-trailer.sh +499 -0
  679. package/skills/review/scripts/ensure-semgrep.sh +99 -0
  680. package/skills/review/scripts/fix-round-seats.sh +197 -0
  681. package/skills/review/test/emit-review-trailer.test.sh +387 -0
  682. package/skills/review/test/ensure-semgrep.test.sh +111 -0
  683. package/skills/review/test/fix-round-seats.test.sh +237 -0
  684. package/skills/review/workflows/README.md +147 -0
  685. package/skills/review/workflows/review.workflow.js +667 -0
  686. package/skills/schedule/SKILL.md +796 -0
  687. package/skills/seo-aeo/SKILL.md +139 -0
  688. package/skills/seo-aeo/scripts/validate-csp.sh +233 -0
  689. package/skills/seo-aeo/scripts/validate-seo.sh +262 -0
  690. package/skills/ship/SKILL.md +3022 -0
  691. package/skills/ship/references/adr-ordinal-collision.md +47 -0
  692. package/skills/ship/references/affected-gate-contract.md +115 -0
  693. package/skills/ship/references/ci-run-diagnosis.md +29 -0
  694. package/skills/ship/references/ci-workflow-authoring.md +68 -0
  695. package/skills/ship/references/counsel-review-clo-attestation.md +38 -0
  696. package/skills/ship/references/cto-ruling-armed-push.md +5 -0
  697. package/skills/ship/references/decision-challenges-render.md +71 -0
  698. package/skills/ship/references/detached-battery-verdict.md +13 -0
  699. package/skills/ship/references/feature-tweet-draft.md +42 -0
  700. package/skills/ship/references/followthrough-issue-body-template.md +47 -0
  701. package/skills/ship/references/followthrough-stub-template.sh +92 -0
  702. package/skills/ship/references/merge-queue-dequeue.md +39 -0
  703. package/skills/ship/references/operator-holds.md +149 -0
  704. package/skills/ship/references/pipeline-tally-render.md +43 -0
  705. package/skills/ship/references/pr-overlap.md +153 -0
  706. package/skills/ship/references/queue-mode.md +7 -0
  707. package/skills/ship/references/ready-run-wait.md +36 -0
  708. package/skills/ship/references/red-on-main-quarantine.md +110 -0
  709. package/skills/ship/references/register-update-pr-pattern.md +88 -0
  710. package/skills/ship/references/settle-then-admin-merge.md +130 -0
  711. package/skills/ship/references/supabase-column-probe.md +13 -0
  712. package/skills/ship/scripts/auto-close-scan.sh +76 -0
  713. package/skills/ship/scripts/battery-owed.sh +403 -0
  714. package/skills/ship/scripts/hold-gate-eval.sh +410 -0
  715. package/skills/ship/scripts/net-issue-flow.sh +665 -0
  716. package/skills/ship/scripts/pr-overlap-preflight.sh +483 -0
  717. package/skills/ship/scripts/ship-pir-action-items-gate.sh +246 -0
  718. package/skills/ship/scripts/technical-ruling-gate.sh +444 -0
  719. package/skills/skill-creator/SKILL.md +299 -0
  720. package/skills/skill-creator/references/api-security.md +248 -0
  721. package/skills/skill-creator/references/authoring-levers.md +110 -0
  722. package/skills/skill-creator/references/be-clear-and-direct.md +587 -0
  723. package/skills/skill-creator/references/best-practices.md +431 -0
  724. package/skills/skill-creator/references/common-patterns.md +639 -0
  725. package/skills/skill-creator/references/core-principles.md +193 -0
  726. package/skills/skill-creator/references/executable-code.md +188 -0
  727. package/skills/skill-creator/references/iteration-and-testing.md +509 -0
  728. package/skills/skill-creator/references/official-spec.md +193 -0
  729. package/skills/skill-creator/references/recommended-structure.md +180 -0
  730. package/skills/skill-creator/references/skill-structure.md +396 -0
  731. package/skills/skill-creator/references/using-scripts.md +122 -0
  732. package/skills/skill-creator/references/using-templates.md +120 -0
  733. package/skills/skill-creator/references/workflows-and-validation.md +541 -0
  734. package/skills/skill-creator/scripts/init_skill.py +322 -0
  735. package/skills/skill-creator/scripts/package_skill.py +110 -0
  736. package/skills/skill-creator/scripts/quick_validate.py +64 -0
  737. package/skills/skill-creator/templates/router-skill.md +73 -0
  738. package/skills/skill-creator/templates/simple-skill.md +34 -0
  739. package/skills/skill-creator/workflows/add-reference.md +96 -0
  740. package/skills/skill-creator/workflows/add-script.md +104 -0
  741. package/skills/skill-creator/workflows/add-template.md +83 -0
  742. package/skills/skill-creator/workflows/add-workflow.md +128 -0
  743. package/skills/skill-creator/workflows/audit-skill.md +159 -0
  744. package/skills/skill-creator/workflows/create-domain-expertise-skill.md +623 -0
  745. package/skills/skill-creator/workflows/create-new-skill.md +207 -0
  746. package/skills/skill-creator/workflows/get-guidance.md +134 -0
  747. package/skills/skill-creator/workflows/upgrade-to-router.md +170 -0
  748. package/skills/skill-creator/workflows/verify-skill.md +226 -0
  749. package/skills/skill-security-scan/SKILL.md +145 -0
  750. package/skills/skill-security-scan/references/disclaimer.md +21 -0
  751. package/skills/skill-security-scan/references/first-party-allowlist.yaml +38 -0
  752. package/skills/skill-security-scan/references/override-artifact-schema.json +68 -0
  753. package/skills/skill-security-scan/references/override-mechanism.md +171 -0
  754. package/skills/skill-security-scan/references/redirect-domains.yaml +27 -0
  755. package/skills/skill-security-scan/references/regex-patterns.md +136 -0
  756. package/skills/skill-security-scan/references/rules/code-exec.yaml +70 -0
  757. package/skills/skill-security-scan/references/rules/filesystem-boundary.yaml +29 -0
  758. package/skills/skill-security-scan/references/rules/manifest.yaml +44 -0
  759. package/skills/skill-security-scan/references/rules/supply-chain.yaml +36 -0
  760. package/skills/skill-security-scan/references/scan-meta-schema.json +63 -0
  761. package/skills/skill-security-scan/references/test-fixtures/clean-soleur-style.skill.md +23 -0
  762. package/skills/skill-security-scan/references/test-fixtures/clean-third-party.skill.md +26 -0
  763. package/skills/skill-security-scan/references/test-fixtures/malicious-codeexec.skill.md +23 -0
  764. package/skills/skill-security-scan/references/test-fixtures/malicious-curl-pipe-bash.skill.md +69 -0
  765. package/skills/skill-security-scan/references/test-fixtures/malicious-prompt-injection.skill.md +17 -0
  766. package/skills/skill-security-scan/references/test-fixtures/malicious-telemetry-beacon.skill.md +23 -0
  767. package/skills/skill-security-scan/references/typosquat-targets.yaml +65 -0
  768. package/skills/skill-security-scan/scripts/check-codeexec.sh +41 -0
  769. package/skills/skill-security-scan/scripts/check-filesystem-boundary.sh +19 -0
  770. package/skills/skill-security-scan/scripts/check-prompt-injection.sh +95 -0
  771. package/skills/skill-security-scan/scripts/check-supply-chain.sh +219 -0
  772. package/skills/skill-security-scan/scripts/check-telemetry-surface.sh +126 -0
  773. package/skills/skill-security-scan/scripts/lib.sh +197 -0
  774. package/skills/skill-security-scan/scripts/parse-override.sh +231 -0
  775. package/skills/skill-security-scan/scripts/run-scan.sh +342 -0
  776. package/skills/skill-security-scan/scripts/run-self-test.sh +95 -0
  777. package/skills/skill-security-scan/test/argv-ceiling.test.sh +180 -0
  778. package/skills/skill-security-scan/test/meta-dir-reap.test.sh +411 -0
  779. package/skills/skill-security-scan/test/parse-override.test.sh +607 -0
  780. package/skills/skill-security-scan/test/redact-pii-ghs-shape.test.sh +251 -0
  781. package/skills/social-distribute/SKILL.md +485 -0
  782. package/skills/spec-templates/SKILL.md +274 -0
  783. package/skills/sync/SKILL.md +49 -0
  784. package/skills/test-browser/SKILL.md +464 -0
  785. package/skills/test-browser/references/feature-map.md +161 -0
  786. package/skills/test-fix-loop/SKILL.md +159 -0
  787. package/skills/test-fix-loop/references/determinism-checklist.md +154 -0
  788. package/skills/triage/SKILL.md +402 -0
  789. package/skills/trigger-cron/SKILL.md +136 -0
  790. package/skills/trigger-cron/scripts/trigger.sh +198 -0
  791. package/skills/user-set-role/SKILL.md +147 -0
  792. package/skills/user-set-role/scripts/set-role.sh +218 -0
  793. package/skills/user-story-writer/SKILL.md +77 -0
  794. package/skills/ux-audit/SKILL.md +251 -0
  795. package/skills/ux-audit/references/finding.schema.json +62 -0
  796. package/skills/ux-audit/references/route-list.yaml +95 -0
  797. package/skills/ux-audit/scripts/bot-fixture.ts +270 -0
  798. package/skills/ux-audit/scripts/bot-signin.ts +135 -0
  799. package/skills/ux-audit/scripts/dedup-hash.ts +46 -0
  800. package/skills/work/SKILL.md +1479 -0
  801. package/skills/work/references/end-of-work-resume.md +7 -0
  802. package/skills/work/references/hook-test-main-cwd.md +24 -0
  803. package/skills/work/references/key-principles.md +83 -0
  804. package/skills/work/references/property-testing.md +122 -0
  805. package/skills/work/references/sql-security-definer-rpc-scaffold.sql +78 -0
  806. package/skills/work/references/work-agent-teams.md +97 -0
  807. package/skills/work/references/work-lifecycle-parallel.md +147 -0
  808. package/skills/work/references/work-lockfile-bumps.md +97 -0
  809. package/skills/work/references/work-scratch-sandboxes.md +212 -0
  810. package/skills/work/references/work-subagent-fanout.md +61 -0
  811. package/skills/work/references/work-test-harness-sharp-edges.md +73 -0
  812. package/skills/xcode-test/SKILL.md +360 -0
@@ -0,0 +1,1387 @@
1
+ #!/usr/bin/env bash
2
+ # shellcheck disable=SC2016,SC2088 # the single-quoted `$HOME`/`~` strings are LITERAL patterns matched against lexer text, never expanded
3
+ # PreToolUse hook on Bash: a narrow destructive-command guard for the Soleur plugin (W2, #9601, ADR-277).
4
+ # The kill switch below is the first executable statement; the rest follows the order under MECHANISM.
5
+ [[ "${SOLEUR_DISABLE_DESTRUCTIVE_GUARD-}" == "1" ]] && exit 0
6
+ #
7
+ # PROPERTY. A Bash tool call whose command, after lexing and wrapper unwrapping, runs a command in the
8
+ # decision set below receives `ask` (the harness shows the person the reason and waits) or `deny`, never an
9
+ # implicit allow, in every spelling bash reads as that command (within the scope stated under NOT DECIDED);
10
+ # a command in the same family outside the set receives no decision (no output, exit 0); an envelope the
11
+ # hook cannot read receives `ask`. The hook is a seatbelt against an unambiguous infrastructure destroy, a
12
+ # rewrite or deletion of a default branch on a remote, and a recursive delete of / or the home directory.
13
+ # It is not a security boundary.
14
+ #
15
+ # WHAT IT DECIDES (Bash tool calls only). Each simple command is judged after lexing: a command inside
16
+ # `bash|sh|zsh|dash|ksh -c`, `eval`, `$(...)`, backticks, `<(...)` and every list operator counts as its own
17
+ # simple command, and the recorded argv, never the raw text, is what is judged (so `echo "terraform
18
+ # destroy"`, a heredoc body and `git commit -m "rm -rf /"` are not commands).
19
+ # deny rm with a recursive flag (-r, -R, -rf, --recursive, any cluster or spelling, flags before or after
20
+ # the targets) whose target is /, an ancestor of the home directory, the home directory (~, $HOME,
21
+ # ${HOME}, a relative path resolving there) or the contents of those: /*, ~/*, and any target whose
22
+ # every component after the glob-free root is only glob syntax (/** /*/* /*/ /? /[a-z]* ~/**), and a
23
+ # bare * ** or ./* when the working directory is home or an ancestor. A component that mixes literal
24
+ # text with a glob (~/*/node_modules, /home/*/x, /*.log, ~/.*) is another set of files and is not
25
+ # decided. `~+` is the working directory; with HOME unset or empty `~` is the passwd entry's home
26
+ # (the same lookup bash makes) and `$HOME/` is `/`. A trailing slash or a glob suffix follows a
27
+ # symlink; a bare symlink name does not (`rm -rf link` only unlinks it).
28
+ # ask rm with a recursive flag whose target is the working directory or an ancestor of it (.. , ../.. ,
29
+ # an absolute path above it; `.` alone is not asked because rm refuses it). The working directory is
30
+ # BOTH the envelope's cwd and the one a literal cd/pushd earlier in the command moved to.
31
+ # ask terraform|tofu destroy, and terraform|tofu apply -destroy (global options such as -chdir= skipped;
32
+ # -destroy=<value> is a destroy unless the value is one of 0 f F false FALSE False, the Go bool "off").
33
+ # ask git push that force-pushes (-f, --force, --force-with-lease[=..], --force-if-includes, a +refspec,
34
+ # in any short-flag cluster, --force with --all/--mirror or their unique abbreviations --al, --m...) or
35
+ # deletes (--delete, -d, :ref) a default branch; a destination spelled heads/main or refs/heads/main, a
36
+ # glob destination (refs/heads/*:refs/heads/*) and the matching refspec `:` count under a force flag.
37
+ # Default branches are the union of `git symbolic-ref --short
38
+ # refs/remotes/<named remote>/HEAD` (read LOCALLY, no network), `main` and `master`; the value-taking
39
+ # flags -o, --push-option, --repo, --receive-pack, `git -C <dir>` and the global options that take a
40
+ # separate word (-c, --config-env, --git-dir, --work-tree, --namespace, --attr-source) are parsed so
41
+ # positions and the repository path are right. A `git -c alias.p=push p ...` alias is NOT DECIDED.
42
+ # ask an unresolvable `cd`/`pushd` (a variable, `-`) followed in the same command by a recursive rm or
43
+ # by a force/delete git push: the working directory the later command sees is unknown.
44
+ # A literal `cd`/`pushd` earlier in the same command (also behind `command` or `builtin`) moves the
45
+ # simulated working directory for the later commands (`cd ~ && rm -rf ./*` is a delete of home).
46
+ # Wrappers are unwrapped with a small option table: sudo doas env command (not -v/-V) nohup time timeout
47
+ # nice rtk; xargs is NOT unwrapped. `rtk` (#10230) is a wrapper on every invocation shape: a filtered
48
+ # subcommand (`rtk git status` runs git), an exec subcommand (run|err|test|proxy|bypass|summary, matched
49
+ # case-sensitively — clap rejects `rtk RUN`, which then lands as argv0), or the run_fallback raw exec of
50
+ # any word not in rtk's meta-command set (`rtk rm -rf ~` runs rm; rtk task/pipeline names that resolve to
51
+ # TOML-defined commands land as their name, the same unanalyzable-script class every wrapper table
52
+ # accepts). Globals -v/-vv/-vvv/--verbose and --skip-env (legal before AND after the exec word) are
53
+ # consumed; any other option word, and -c/--command/--shell or a payload word carrying whitespace or
54
+ # shell metacharacters (err|test|summary may join it to `sh -c`), asks unparsed-wrapper — never a silent
55
+ # allow. Grammar verified 2026-10-13 against src/lib.rs + src/core/constants.rs on develop; the exec set
56
+ # is version-pinned here, so a new rtk subcommand is a one-line checklist edit and an unrecognized word
57
+ # simply lands as argv0 in the fail-open direction that run_fallback also picks. `time` is a wrapper as a command word (/usr/bin/time, `command time`, `env
58
+ # time`); as a reserved word it is the lexer's, which drops it, and the first word `-p` then recovers
59
+ # `time -p rm ...` (a pseudo-wrapper). A short-option cluster whose last letter takes a value shifts one more
60
+ # word (sudo -nu root, env -iu X, timeout -vk 5 10). The rule table is retried on the words after a `--`,
61
+ # which covers `doppler run --`, `aws-vault exec <profile> --` and `op run --`. An absolute-path binary
62
+ # matches by basename (/bin/rm) and command names are compared in lower case (RM, Git, Terraform; the fold
63
+ # starts no process). A wrapper's chdir option (env -C DIR, env --chdir=DIR, sudo -D DIR, sudo --chdir=DIR, and the abbreviations getopt_long accepts: --chd)
64
+ # moves the simulated working directory for the command it runs, and for that command only; a directory the
65
+ # guard cannot resolve (a variable) is an unresolved cd. A chdir wrapper around a shell or eval (`env -C D bash
66
+ # -c '...'`) makes the directory unresolved for the commands after it, because the inner string is a record of
67
+ # its own that the guard cannot place in D (a later recursive delete asks; it never under-asks).
68
+ # ask env -S, --split-string (any abbreviation) or a cluster with S (rule id `unparsed-wrapper`): the string
69
+ # it splits into a command is not analysed.
70
+ # ask more than 8 nested wrappers or `--` separators (rule id `wrapper-depth`): what they run cannot be checked.
71
+ # ask a command too large to check (rule id `bound`): a tool call over 256 KiB (checked before the prefilter or
72
+ # any parser reads it), more than MAX_RECORDS simple commands, more than MAX_WORDS words, a word over
73
+ # MAX_WORD_BYTES where the rule table would expand it (a command name, a path, a wrapper's option, a dash word of git or
74
+ # terraform: bash's expansions on one word are quadratic in its length; long text such as a PR body is not asked), a target path
75
+ # with more than MAX_PATH_COMPONENTS components, a segment over 16 KiB (or 64 KiB of segments in all) in a degraded scan, or the DEADLINE_S wall
76
+ # clock reached.
77
+ # What was read before the limit is still judged: a deny wins; an ask-class match keeps its own reason
78
+ # with the bound sentence appended; with nothing matched the ask is the bare `bound` one.
79
+ # ask the lexer returned no command for text that names something the guard decides on (rule id `lexer-empty`:
80
+ # a lexer that silently dropped a command it should have judged). Judged on the text, full-line comments
81
+ # (first non-blank character #) skipped and a later # not treated as a comment: (1) a whole-word token,
82
+ # with the line split at blanks and shell metacharacters, that is rm, destroy, push, terraform, tofu, git
83
+ # or eval in any case; (2) when the line has a quote, backslash, backtick or a $ that is not a plain
84
+ # variable name, the line with those characters removed (r""m, 'r'm, ev""al) has such a token. Both read the
85
+ # last path component of a token (/bin/rm is rm). A blank or comment-only command, and a bare redirect to a
86
+ # file that merely contains one (`> terraform.log`, `> "out/terraform.log"`, `> out.log`), is allowed.
87
+ # A command that is only a heredoc or a here-string (`<<EOF ... EOF`, `<<<"rm -rf /"`) has no command word, so
88
+ # the lexer returns no record; its body names a keyword, so it asks lexer-empty (a body is not a command).
89
+ #
90
+ # NOT DECIDED (stated, not implied). Obfuscation: a variable-built command name, glob or brace expansion of
91
+ # a command name (r[m], r{m,}), brace expansion of a target (`/{bin,usr}`), `xargs rm`, `find -delete` and
92
+ # `find -exec rm`, `rsync --delete`, other interpreters and tools (python shutil.rmtree), zsh-only expansions
93
+ # (=rm); wrappers outside the table (exec, builtin, setsid, ionice, stdbuf, flock, nsenter, chroot, `su -c`,
94
+ # `sudo -s '...'`, coproc); strings run later (trap strings, function bodies, aliases including `git -c
95
+ # alias.x=push`); shells fed on stdin (heredocs, here-strings, a pipe into bash or sh) and `source <(...)`; a
96
+ # runner name in another case (`BASH -c ...`, the lexer is case-sensitive there); a script written and then run;
97
+ # a piped SQL string; `drop database`/`dropdb`; MCP delete tools; any non-Bash tool (including Devin's
98
+ # `exec`: the raw tool_name must be Bash); Doppler secret writes and deletes; a plain `terraform apply`;
99
+ # `kubectl delete`; `pulumi destroy`; `terragrunt destroy`; `git push --mirror` without --force; and an
100
+ # unresolvable $VAR target.
101
+ #
102
+ # USER-FACING STATEMENT (the plugin README carries this sentence, word for word; the suite pins both copies):
103
+ # User-facing statement: The guard does not cover a plain `terraform apply`, secret writes, SQL, non-Bash tools,
104
+ # `terragrunt` or `pulumi` destroy, or indirect command forms (scripts or heredocs fed to a shell, wrappers it
105
+ # does not unwrap, obfuscated command names), and is not a substitute for scoped credentials.
106
+ #
107
+ # RESIDUALS of the shared lexer: it lexes an identical `bash -c`/`eval` string once, so the working-directory
108
+ # simulation cannot tell two identical inner strings under different `cd`s apart; a `cd` inside a subshell or a $(...) is treated as
109
+ # sticky for the later commands (over-asks, never under-asks); a quoted fragment inside a tilde word
110
+ # (`~/"x"`) carries the quoted flag, so the word is read as literal unless it is exactly `~/` or `~/*`
111
+ # (those two are read as home even when quoted: a directory literally named `~` is the cost).
112
+ #
113
+ # DECISION PRECEDENCE across hooks (ADR-264, measured): deny > defer > ask > allow, so another hook's allow
114
+ # cannot turn this hook's ask into a silent run. The guard judges the ORIGINAL command it receives, not
115
+ # another hook's `updatedInput` (PreToolUse hooks of one event run independently on the same input).
116
+ #
117
+ # ASK AND DENY SEMANTICS (measured, phase-0-measurements.md 1.2): `ask` holds under bypassPermissions and
118
+ # against an allow rule; under `claude -p` it blocks the call with the reason shown, so a headless run
119
+ # degrades to a block, never an allow. On a `deny` the person sees only a collapsed "Ran 1 shell command"
120
+ # unless a top-level `systemMessage` is set, so every deny also carries `systemMessage` with the same full
121
+ # reason (the escape hatch and the issues URL included); an ask does not need it. The prompt of an ask shows the
122
+ # whole reason to the person, so an ask opens with a sentence written for that person (see REASONS below).
123
+ #
124
+ # FAILURE POSTURE (D6; a stated narrowing of ADR-157's `.claude` row, with reasons). An envelope jq rejects, a
125
+ # non-string .tool_input.command, empty stdin, a lexer parse failure (exit 2: unbalanced quote, unterminated
126
+ # substitution, NUL byte) and a lexer bound trip (exit 3: depth, budget, alarm, crash) all ASK; the parse
127
+ # reasons say "could not parse this command; it was not recognised as destructive" and never quote a command
128
+ # the hook did not match; a command too large to check (`bound`) and a lexer that returns nothing for real text
129
+ # (`lexer-empty`) ask too.
130
+ #
131
+ # REASONS. An ask opens with a sentence for the person at the prompt ("The guard paused this command and is
132
+ # asking you. It has not run yet and runs only if you approve."), then the rule id, a one-sentence lead and the
133
+ # quoted command, then the agent's instructions under "If you are the agent: This command was NOT run." A deny
134
+ # opens with "This command was NOT run." (the person reads a block, not a prompt). The agent's tail follows the
135
+ # cause: a matched destructive command says stop, do not retry, do not rephrase; a lexer parse failure (exit 2)
136
+ # says fix the quoting or heredoc and send it again; a lexer that gave out (depth, budget, alarm, crash) and a
137
+ # command too large to check say simplify or split; an unreadable envelope says the fault is in the tool call, so
138
+ # stop and tell the person; a lexer that produced no result or malformed output says the parser is not working on this machine, so stop
139
+ # and tell the person (splitting cannot help); env -S and too many wrappers say write the command out so the guard can check it; an
140
+ # unresolvable cd says put a literal directory in the cd.
141
+ # Every ask and deny carries the escape hatch and the issues URL. The quoted command has credentials masked:
142
+ # NAME=value, --name=value and -var name=value with a key, tok, secret, pass, pw, cred, auth or bearer name; the
143
+ # word after --token, --password, --passwd, --secret, --api-key, --auth or --bearer (not a command word, a path starting with / or ~ or
144
+ # $HOME, or origin/main/master: the quote must still say what was about to run); the text after
145
+ # `Authorization:` or `Bearer `; URL userinfo (not the `git@` of an ssh remote). That is a coverage choice, not a boundary: a
146
+ # name that merely contains a cue (AUTHOR=, KEYBOARD=, -var key_name=, passenger_count=) is masked too, and the person reads the
147
+ # original command at the Bash prompt. When jq cannot build the
148
+ # output (emit_fallback) the decision and the rule id are kept: a plain body goes out as it is, a body that quotes
149
+ # the command gets a fixed `guard-output-fallback` text, and a deny stays a deny.
150
+ #
151
+ # DEGRADED MODES. A missing or unusable `jq` does not ask on every call (that would make the plugin
152
+ # unusable without jq): the hook scans the RAW envelope for its own narrow patterns (recursive rm of / ~ or
153
+ # $HOME, `destroy`, `push` with a force flag or +), tolerating JSON-escaped whitespace; a hit asks (output
154
+ # hand-built with a fixed reason naming jq, plus a stderr notice naming jq), a miss exits 0. A missing or
155
+ # broken `perl` with a working jq scans the jq-decoded command the same way (stderr notice naming perl). Both
156
+ # scans stop with a `bound` ask at a segment over 16 KiB, at 64 KiB of segments in all, or at the deadline. It never DENIES on a dependency
157
+ # failure (the repair is itself a Bash call). A miss on the raw scan is a stated, tested fail-open (the
158
+ # raw-scan-miss residual ADR-165 accepted for its `.openhands` row, a mirror ADR-245 has since retired; ADR-277
159
+ # records why this hook asks on a hit where that row denied, and why its `.claude` row, which asks on every
160
+ # call, is narrowed).
161
+ #
162
+ # KILL SWITCH (D5). SOLEUR_DISABLE_DESTRUCTIVE_GUARD=1 (exactly 1; empty, 0 and anything else leave the
163
+ # guard on) exits 0 with no output, before any dependency probe. It is read from the harness process
164
+ # environment, so setting it needs a session restart. It can ALSO be set from a settings-level `env` block
165
+ # (settings.json "env"), which no Bash guard sees and which an agent that can edit a settings file can use:
166
+ # this is a seatbelt, not a boundary. Hosted sessions disable it through AGENT_ENV_OVERRIDES (D8).
167
+ #
168
+ # MECHANISM AND ORDER. Kill switch; read stdin; a tool call over 256 KiB asks `bound` before the prefilter or any parser; the zero-spawn
169
+ # prefilter; the raw tool_name (it must be exactly Bash, so Devin's `exec` is not decided); dependency probes by
170
+ # RESULT, not by `command -v` (a jq that is present and exits non-zero fails like an absent one; probed only on
171
+ # failure, so the happy path pays nothing); jq extraction (the command goes through `jq -j` straight into the lexer so
172
+ # NULs and newlines survive; the small fields use a separate jq call); lib/shell-argv.pl; the rule table; the
173
+ # decision. The prefilter skips the lexer only when the envelope holds exactly one `"command"` text, no backslash
174
+ # followed by u (a key spelled `"\u0063ommand"` is the key command to jq and not the text `"command"`), and that
175
+ # command's raw JSON text has NONE of the keywords rm (also as RM, Rm, rM), destroy, push, eval and none of
176
+ # backslash, single quote, double quote, $, a backtick, `<(`, `>(` or `<<`. It reads the command string only (the
177
+ # text after "command":" up to the first double quote), because the envelope's other fields can spell a keyword
178
+ # (permission_mode, a cwd of .../web-platform), and a double quote inside the command is always JSON-escaped with a
179
+ # backslash, so the backslash test is what sees it. It also requires a `{...}`-shaped envelope with a string
180
+ # "command" so garbage and a non-string command reach the jq path and ask. WHAT THE SKIP IS AND IS NOT: it is not
181
+ # "everything skipped is also allowed by the lexer path" (an unterminated `<(` or backtick is skipped by a
182
+ # keyword-only test yet asks on the lexer path, which is why those are boundary characters and why `<(`/`>(`
183
+ # were added after a differential fuzz found 79 such commands, all unterminated process substitutions). It is
184
+ # "nothing skipped can be a D1 command": every command in the decision set spells rm, destroy or push as a plain
185
+ # substring, and every other spelling of those (a quote, escape, expansion or runner) carries a boundary
186
+ # character; so the lexer path could only ever add a parse-failure or wrapper-depth ask to a skipped command, never a
187
+ # destructive-command decision.
188
+ # Every boundary character has a must-ASK row.
189
+ #
190
+ # DEPENDENCIES. bash (3.2 or later), jq, perl >= 5.10 with core pragmas only (the lexer), git (read-only,
191
+ # local `symbolic-ref`, only for a force/delete push), POSIX utilities. No `eval`, ever (ADR-156: hook stdin
192
+ # is model-controlled). The hook reads stdin and writes stdout and stderr; it writes no file (no here-string,
193
+ # which bash before 5.1 backs with a temporary file) and sends nothing off the machine.
194
+ #
195
+ # PORTABILITY (bash 3.2 and POSIX only). No bash-4 builtin, associative array or case-conversion expansion,
196
+ # and no GNU-only flag of the path, stream-edit, date or stat utilities. Lexer frames are read with
197
+ # `read -d ''` (a $(...) would strip the NULs); the decision is made from the `OK\0` terminator, never from
198
+ # an exit status lost across a process substitution. Empty-array expansions under `set -u` are guarded
199
+ # (`${x[@]+"${x[@]}"}`) wherever an array can be empty; the unguarded ones (`"${AV[@]}"`, `"${DA_T[@]}"`,
200
+ # `"${keep_t[@]}"`, `"${t[@]:...}"`, `"${defaults[@]}"`) are non-empty by invariant: a lexer record has argc >= 1
201
+ # and every slice or copy is taken only after an index < n check. What was run on bash 3.2.57 itself (docker bash:3.2
202
+ # with the host's jq, perl and git): the whole hook over a 30-command corpus (wrapper chains and case folding, env -C and
203
+ # sudo -D, lexer-empty redirects, redaction shapes, env -S, wrapper depth, a bound ask, a deep path), and the decisions and
204
+ # reason text were identical to bash 5.3. Not run on 3.2: the clock-trip and partial-record paths, the degraded (jq- or
205
+ # perl-less) scans and the output fallback. The suites run under the host bash.
206
+ # Path resolution has no canonicalising utility: one subshell finds the longest existing prefix of a directory
207
+ # (`cd -P`, then `pwd -P`) and the rest is normalized lexically, with the literal basename; HOME is compared in both its literal and physical forms. Inherited GIT_* variables are stripped
208
+ # BY PREFIX before any git call (a lefthook-exported GIT_DIR must not redirect it). The working directory is
209
+ # the envelope's .cwd, then CLAUDE_PROJECT_DIR, then PWD.
210
+ set -uo pipefail
211
+ # No pathname expansion: unquoted splits below must never glob.
212
+ set -f
213
+ export LC_ALL=C
214
+ unset CDPATH
215
+
216
+ # BOUNDS on the bash side (the harness kills this hook at hooks.json `timeout: 10`, and a killed hook is not a
217
+ # decision). The wall clock is `SECONDS`, reset here: it ticks on whole-second boundaries, so DEADLINE_S=6 trips
218
+ # between 5 and 6 s after this line, comfortably inside the 10 s budget. MAX_RECORDS caps the simple commands the
219
+ # rule table judges. MAX_WORDS caps the words of the whole command; its job is to bound the TIME one record takes to read
220
+ # (the frame-read loop costs about 120 us a word under load), not what the rule table can judge. MAX_WORD_BYTES caps one word
221
+ # (the frame reader swaps a longer one for a placeholder): bash's own expansions on a word, ${p##*/} and a per-character loop,
222
+ # are quadratic in its length and no clock check can interrupt one, while the 256 KiB and 128-component caps bound neither.
223
+ # MAX_PATH_COMPONENTS caps the components of a path the guard resolves, MAX_ENVELOPE the tool call, SCAN_MAX_SEG a degraded-scan segment and
224
+ # SCAN_MAX_TOTAL all the segments of one degraded scan. A command
225
+ # beyond any of them, or one that runs out of time, asks with rule id `bound` unless a deny was found in what was read
226
+ # (BOUND_SOFT: a trip while reading or resolving does not stop the judging of the rest). The lexer has its own 2 s alarm;
227
+ # these bound everything after it.
228
+ SECONDS=0
229
+ DEADLINE_S=6
230
+ LIMIT_S="$DEADLINE_S" # the limit the messages name: DEADLINE_S itself moves (a read-time trip grants the judging 2 more seconds)
231
+ MAX_RECORDS=2000
232
+ MAX_WORDS=20000
233
+ MAX_WORD_BYTES=4096 # PATH_MAX: a longer word is not judged. bash's own expansions on one word (${p##*/}, a per-character loop over a dash word) are quadratic in its length
234
+ MAX_PATH_COMPONENTS=128
235
+ MAX_WRAP=8 # nested wrappers (and -- hops) the rule table unwraps; the ninth asks `wrapper-depth`
236
+ QUOTE_MAX=200 # characters of the matched command a reason quotes
237
+ MAX_ENVELOPE=262144 # 256 KiB: a larger tool call asks `bound` before the prefilter or any parser reads it (the prefilter and the raw scans are not linear)
238
+ SCAN_MAX_SEG=16384 # 16 KiB: the longest segment the raw/decoded scans will try their patterns on (their cost is quadratic in it: about 0.3 s at the cap)
239
+ SCAN_MAX_TOTAL=65536 # 64 KiB: the bytes of all the segments of one command the scans will try (the clock is read only between segments)
240
+ BOUND_WHY=""
241
+ BOUND_SOFT=""
242
+
243
+ ISSUES_URL='https://github.com/jikig-ai/soleur/issues'
244
+ # A reason has two readers: the person at the prompt (an ask) and the agent. A body written as `<head><AMARK><tail>` is composed by
245
+ # compose() below: the tail is the agent's instructions. An ask opens with a sentence for the person and puts the agent's part under
246
+ # "If you are the agent:"; a deny (and its systemMessage) opens with the not-run sentence, as the person only reads a block.
247
+ AMARK=$'\001'
248
+ ASK_LEAD="The guard paused this command and is asking you. It has not run yet and runs only if you approve."
249
+ PERSON_TAIL=" The person can run the command themselves in their own terminal, outside the agent, or start the session with SOLEUR_DISABLE_DESTRUCTIVE_GUARD=1 set in their own shell. If this was flagged wrongly, report it at ${ISSUES_URL}"
250
+ # a matched destructive command: stop, do not retry, do not rephrase
251
+ REASON_TAIL="${AMARK} Stop and tell the person what you were about to run and why. Do not retry this command and do not rephrase it to get around the guard. If no person is available to answer, end the task and report it as blocked.${PERSON_TAIL}"
252
+ # a command too large to check, or a lexer that gave out (depth, budget, alarm, crash): simplify or split
253
+ BOUND_TAIL="${AMARK} Split it into smaller commands and send them one at a time; if you cannot, stop and report the task as blocked.${PERSON_TAIL}"
254
+ # a quoting or heredoc problem in the command (lexer exit 2) is repaired and resent
255
+ PARSE_TAIL="${AMARK} If this is a valid command you meant to run, fix its quoting or heredoc and send it again; if you cannot, stop and report the task as blocked.${PERSON_TAIL}"
256
+ # an envelope the hook cannot read is a fault in the tool call, not in the command
257
+ ENVELOPE_TAIL="${AMARK} This is a fault in the tool call the harness sent, not a problem with the command: stop and tell the person what happened instead of rewriting the command.${PERSON_TAIL}"
258
+ # env -S and too many wrappers hide the command: write it out so it can be checked
259
+ WRAPPER_TAIL="${AMARK} If this is a command you meant to run, write it out without env -S (or with fewer nested wrappers) so the guard can check it, and send it again; if you cannot, stop and tell the person what you were about to run.${PERSON_TAIL}"
260
+ # a lexer that produced nothing or garbage is a fault on this machine: no piece of the command can be checked either, so splitting does not help
261
+ LEXER_TAIL="${AMARK} The guard's parser is not working on this machine, so no command can be checked here and splitting this one will not help: stop and tell the person what happened instead of rewriting the command.${PERSON_TAIL}"
262
+ # an unresolvable cd: the repair is a literal directory
263
+ CD_TAIL="${AMARK} If this is a command you meant to run, put a literal directory in the cd (not a variable or -) so the guard can check where the command runs (for a directory option such as env -C DIR in front of sh -c, do the cd inside the inner command instead), and send it again; if you cannot, stop and tell the person what you were about to run.${PERSON_TAIL}"
264
+
265
+ # ---- output ---------------------------------------------------------------------------------------
266
+ # compose <ask|deny> <body> -> COMPOSED: the reason text (see AMARK).
267
+ COMPOSED=""
268
+ compose() {
269
+ local head="${2%%"$AMARK"*}" tail=""
270
+ [[ "$2" == *"$AMARK"* ]] && tail="${2#*"$AMARK"}"
271
+ if [[ "$1" == deny ]]; then COMPOSED="This command was NOT run. ${head}${tail}"
272
+ else COMPOSED="${ASK_LEAD} ${head} If you are the agent: This command was NOT run.${tail}"; fi
273
+ }
274
+ # emit <ask|deny> <body>: the full envelope (a bare decision without hookEventName is silently ignored), built with jq; when jq
275
+ # cannot build it, emit_fallback. A deny also carries systemMessage.
276
+ emit() {
277
+ local out="" reason
278
+ compose "$1" "$2"; reason="$COMPOSED"
279
+ out="$(jq -nc --arg d "$1" --arg r "$reason" \
280
+ '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: $d, permissionDecisionReason: $r}}
281
+ + (if $d == "deny" then {systemMessage: $r} else {} end)' 2>/dev/null)" || out=""
282
+ if [[ -z "$out" ]]; then emit_fallback "$1" "$2"; return; fi
283
+ printf '%s\n' "$out"
284
+ }
285
+ # emit_fixed <ask|deny> <body without quotes, backslashes or control characters>: no jq, no interpolation of input text.
286
+ emit_fixed() {
287
+ compose "$1" "$2"
288
+ if [[ "$1" == deny ]]; then
289
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"},"systemMessage":"%s"}\n' "$COMPOSED" "$COMPOSED"
290
+ else
291
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$COMPOSED"
292
+ fi
293
+ }
294
+ # emit_fallback <ask|deny> <body>: jq could not build the output. A body of our own text (printable ASCII, no quote or backslash) goes
295
+ # out as it is, so the rule id, the lead and the tail survive. A body that quotes the command (it holds a quote, a backslash or a
296
+ # control character) gets a fixed text that keeps the rule id and the decision: a deny stays a deny and says it is blocked.
297
+ emit_fallback() {
298
+ local fid="${2%%:*}"
299
+ case "$fid" in ""|*[!a-z0-9-]*) fid=unknown ;; esac
300
+ compose "$1" "$2"
301
+ case "$COMPOSED" in
302
+ *[![:print:]]*|*[\"\\]*)
303
+ if [[ "$1" == deny ]]; then
304
+ emit_fixed deny "guard-output-fallback: the destructive-command guard matched rule ${fid} and blocked this command, but jq could not build the full message that quotes it.${AMARK} Stop and tell the person. Do not retry this command. If no person is available, end the task and report it as blocked. The person can run it in their own terminal or start the session with SOLEUR_DISABLE_DESTRUCTIVE_GUARD=1. Report a wrong flag at ${ISSUES_URL}"
305
+ else
306
+ emit_fixed ask "guard-output-fallback: the destructive-command guard matched rule ${fid}, but jq could not build the full message that quotes the command, so it is asking instead of allowing.${AMARK} Stop and tell the person. Do not retry this command. If no person is available, end the task and report it as blocked. The person can run it in their own terminal or start the session with SOLEUR_DISABLE_DESTRUCTIVE_GUARD=1. Report a wrong flag at ${ISSUES_URL}"
307
+ fi ;;
308
+ *) emit_fixed "$1" "$2" ;;
309
+ esac
310
+ }
311
+
312
+ # lead_for <rule id>: the one-sentence statement of what the rule matched.
313
+ lead_for() {
314
+ case "$1" in
315
+ recursive-delete-home) LEAD="this command recursively deletes the filesystem root, an ancestor of the home directory, the home directory, or everything inside one of them." ;;
316
+ recursive-delete-workdir) LEAD="this command recursively deletes the working directory or one of its ancestors." ;;
317
+ infra-destroy) LEAD="this command runs terraform or tofu destroy (or apply -destroy), which tears down infrastructure." ;;
318
+ default-branch-force-push) LEAD="this command force-pushes over, or deletes, a default branch on a remote." ;;
319
+ wrapper-depth) LEAD="this command nests more than ${MAX_WRAP} wrappers (sudo, env, timeout, ...) or -- separators, more than the guard unwraps, so the command it finally runs cannot be checked." ;;
320
+ unparsed-wrapper) LEAD="this command runs env -S (--split-string), which splits a string into the command to run, and the guard does not analyse that string." ;;
321
+ unresolved-cd-before-destructive) LEAD="a cd, pushd or wrapper directory option (env -C, sudo -D) that cannot be resolved (a variable, -, a path too deep to resolve, or a directory option in front of a shell command, whose inner commands the guard cannot place in it) comes before a recursive delete or a force push in the same command, so the guard cannot tell which directory the later command runs in." ;;
322
+ *) LEAD="this command matched a destructive-command rule." ;;
323
+ esac
324
+ }
325
+
326
+ # ---- redaction: the matched command goes into the reason, the transcript and (on a deny) the systemMessage -------
327
+ # A word that carries a credential is masked BEFORE the text is joined and cut to 200 characters (so a cut can never
328
+ # leave half of one). Masked: (1) NAME=value, --name=value and -var name=value where NAME holds key, tok, secret, pass, pw (so pwd
329
+ # too), cred, auth or bearer in any case (the whole value, spaces included, because the lexer's word is one argument); (2) the word
330
+ # AFTER --token, --password, --passwd, --secret, --api-key, --auth or --bearer (unless it is itself a flag); (3) the text after
331
+ # `Authorization:` or `Bearer ` inside a word (an -H header, `-c http.extraheader=Authorization: Basic x`); (4) URL userinfo
332
+ # (`://user:pass@` keeps the user, a password may hold / or @; `://token@` masks it). Only the emit path calls this, one word at a
333
+ # time with bash regexes under nocasematch, so it costs no process. The names are a coverage choice, not a boundary: a
334
+ # credential in a word with none of these names (or a bare value) is not masked.
335
+ RE_SECRET_ASSIGN='^(([A-Za-z0-9_.-]*=)?-{0,2}[A-Za-z0-9_.-]*(key|tok|secret|pass|pw|cred|auth|bearer)[A-Za-z0-9_.-]*=)'
336
+ RE_URL_PW='^(.*://[^/@[:space:]:]*:)[^[:space:]]*(@[^@[:space:]]*)$'
337
+ RE_URL_USER='^(.*://)[^/@[:space:]:]+(@.*)$'
338
+ RE_AUTH_HDR='^(.*authorization:[[:space:]]*)(.+)$'
339
+ RE_BEARER='^(.*bearer[[:space:]]+)(.+)$'
340
+ RE_FLAG_NEXT='^--(token|password|passwd|secret|api-key|auth|bearer)$'
341
+ RW=""; RW_NEXT=0
342
+ redact_word() { # <word> -> RW; RW_NEXT (1 = the word after a credential flag) carries across words: the caller resets it per command
343
+ local w="${1//"$AMARK"/}" had=0 mask=0 # (the marker byte never reaches the quote: compose() splits the reason at it)
344
+ if (( RW_NEXT )) && [[ "$w" != -* ]]; then
345
+ case "$w" in
346
+ # a command word or a destructive operand is not a credential: the person reads the quote to see WHAT was about to run
347
+ /|'/*'|'~'|'~/'|'~/*'|'$HOME'|'${HOME}'|'$HOME/*'|'${HOME}/*'|rm|git|terraform|tofu|sudo|doas|env|command|nohup|time|timeout|nice|rtk|origin|main|master) : ;;
348
+ *) RW="<redacted>"; RW_NEXT=0; return ;;
349
+ esac
350
+ fi
351
+ shopt -q nocasematch && had=1
352
+ shopt -s nocasematch
353
+ if [[ "$w" =~ $RE_SECRET_ASSIGN ]]; then w="${BASH_REMATCH[1]}<redacted>"
354
+ elif [[ "$w" =~ $RE_URL_PW ]]; then w="${BASH_REMATCH[1]}<redacted>${BASH_REMATCH[2]}"
355
+ elif [[ "$w" == *://git@* ]]; then : # the service account of an ssh remote (git@github.com) is not a credential
356
+ elif [[ "$w" =~ $RE_URL_USER ]]; then w="${BASH_REMATCH[1]}<redacted>${BASH_REMATCH[2]}"
357
+ elif [[ "$w" =~ $RE_AUTH_HDR ]]; then w="${BASH_REMATCH[1]}<redacted>"
358
+ elif [[ "$w" =~ $RE_BEARER ]]; then w="${BASH_REMATCH[1]}<redacted>"
359
+ elif [[ "$w" =~ $RE_FLAG_NEXT ]]; then mask=1
360
+ fi
361
+ (( had )) || shopt -u nocasematch
362
+ RW_NEXT="$mask"
363
+ RW="$w"
364
+ }
365
+ # redact_text <text> -> RTXT: the same, over the blank-separated words of raw text (the perl-less scan has text, not words). A word
366
+ # that opens with a quote is taken with the words after it up to the one that closes it, and the quotes are dropped, so a quoted
367
+ # name=value is read as the word the shell would have made of it. Whole words only, and it stops after the 200 shown characters.
368
+ RTXT=""
369
+ redact_text() {
370
+ local IFS=$' \t\n' w q i=0 n out=""
371
+ local -a ws
372
+ # shellcheck disable=SC2206 # the split on blanks is the point (no pathname expansion: set -f)
373
+ ws=($1); n=${#ws[@]}
374
+ while (( i < n )); do
375
+ (( ${#out} > QUOTE_MAX )) && break
376
+ w="${ws[$i]}"; i=$((i + 1)); q=""
377
+ case "$w" in \'*|\"*) q="${w:0:1}"; w="${w#"$q"}" ;; esac
378
+ if [[ -n "$q" ]]; then
379
+ while [[ "$w" != *"$q" ]] && (( i < n && ${#w} <= QUOTE_MAX )); do w="$w ${ws[$i]}"; i=$((i + 1)); done
380
+ w="${w%"$q"}"
381
+ fi
382
+ redact_word "$w"; out="${out:+$out }$RW"
383
+ done
384
+ RTXT="$out"
385
+ }
386
+
387
+ ask_parse() { # <cause text>: a command the lexer could not read. Only a parse failure (exit 2) is repaired by fixing the quoting;
388
+ # a lexer that gave out (depth, budget, alarm, crash, no result, malformed output) wants a simpler or smaller command.
389
+ local tail="$BOUND_TAIL"
390
+ case "$1" in
391
+ "lexer exit2") tail="$PARSE_TAIL" ;;
392
+ "lexer depth"|"lexer budget"|"lexer alarm"|"lexer crash") : ;;
393
+ *) tail="$LEXER_TAIL" ;; # no result, malformed output, a cause the hook does not know
394
+ esac
395
+ emit ask "command-not-parsed: the destructive-command guard could not parse this command; it was not recognised as destructive, and the guard asks rather than guess (${1}).${tail}"
396
+ exit 0
397
+ }
398
+ ask_lexer_empty() {
399
+ emit ask "lexer-empty: the destructive-command guard's lexer returned no command for this input, which mentions a word the guard decides on (rm, destroy, push, terraform, tofu, git or eval); it was not recognised as destructive, and the guard asks rather than guess.${PARSE_TAIL}"
400
+ exit 0
401
+ }
402
+ ask_bound() { # <why>: a command too large to check in full
403
+ emit ask "bound: the destructive-command guard stopped checking this command because it is too large to check in full (${1}); it was not recognised as destructive, and the guard asks rather than guess.${BOUND_TAIL}"
404
+ exit 0
405
+ }
406
+ ask_envelope() { # <cause text>
407
+ emit ask "envelope-unreadable: the destructive-command guard could not read this tool call (${1}), and it asks rather than allow what it cannot read.${ENVELOPE_TAIL}"
408
+ exit 0
409
+ }
410
+
411
+ # ---- the narrow raw/decoded scan used by the degraded paths (D6) -----------------------------------
412
+ # One anchored pattern per case, each tested on ONE segment (a list is split on ; & | and newlines first),
413
+ # never independent greps ANDed over the whole text. A blank between two words of terraform, tofu or git is a RUN of blanks
414
+ # (`[[:space:]]+`): `terraform destroy` is `terraform destroy` to a shell; the scan already reads a JSON tab as a blank.
415
+ SC_B='(^|[^[:alnum:]_.-])'
416
+ RE_RM_WORD="${SC_B}rm[[:space:]]"
417
+ RE_RM_REC='[[:space:]](-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)([^[:alnum:]_-]|$)'
418
+ RE_RM_TGT='(^|[^[:alnum:]_./~$-])((/+\*?)|((~|\$HOME|\$\{HOME\})(/+\*?)?))([^[:alnum:]_./~$*-]|$)'
419
+ RE_TF_DESTROY="${SC_B}(terraform|tofu)[[:space:]]+([^[:space:]].*[[:space:]])?destroy([^[:alnum:]_.-]|\$)"
420
+ RE_TF_APPLY="${SC_B}(terraform|tofu)[[:space:]]+([^[:space:]].*[[:space:]])?apply[[:space:]]+([^[:space:]].*[[:space:]])?-{1,2}destroy([^[:alnum:]_.-]|\$)"
421
+ RE_GIT_PUSH="${SC_B}git[[:space:]]+([^[:space:]].*[[:space:]])?push[[:space:]]+([^[:space:]].*[[:space:]])?(-[a-zA-Z]*f[a-zA-Z]*|--force[^[:space:]]*|\\+[^[:space:]+])"
422
+ SCAN_SEG=""
423
+ # scan_narrow <text>: 0 on a hit (SCAN_SEG = the matched segment), 1 on a miss, 2 when a bound tripped (SCAN_WHY): a segment
424
+ # longer than SCAN_MAX_SEG or more than SCAN_MAX_TOTAL bytes of segments (the patterns are not linear in a segment's length, and
425
+ # the clock is read only between segments) or the clock reached DEADLINE_S.
426
+ SCAN_WHY=""
427
+ scan_narrow() {
428
+ local seg IFS=$';&|\n' total=0
429
+ for seg in $1; do
430
+ total=$((total + ${#seg}))
431
+ if (( ${#seg} > SCAN_MAX_SEG )); then SCAN_WHY="a command segment longer than $((SCAN_MAX_SEG / 1024)) KiB"; return 2; fi
432
+ if (( total > SCAN_MAX_TOTAL )); then SCAN_WHY="more than $((SCAN_MAX_TOTAL / 1024)) KiB of command text to scan"; return 2; fi
433
+ if (( SECONDS >= DEADLINE_S )); then SCAN_WHY="the ${LIMIT_S} s time limit was reached while scanning"; return 2; fi
434
+ if [[ "$seg" =~ $RE_RM_WORD && "$seg" =~ $RE_RM_REC && "$seg" =~ $RE_RM_TGT ]]; then SCAN_SEG="$seg"; return 0; fi
435
+ if [[ "$seg" =~ $RE_TF_DESTROY || "$seg" =~ $RE_TF_APPLY ]]; then SCAN_SEG="$seg"; return 0; fi
436
+ if [[ "$seg" =~ $RE_GIT_PUSH ]]; then SCAN_SEG="$seg"; return 0; fi
437
+ done
438
+ return 1
439
+ }
440
+
441
+ # ---- 1. read stdin ---------------------------------------------------------------------------------
442
+ INPUT="$(cat 2>/dev/null)" || INPUT=""
443
+ case "$INPUT" in
444
+ *[![:space:]]*) : ;;
445
+ *) ask_envelope "stdin was empty" ;;
446
+ esac
447
+
448
+ # An oversize tool call asks before anything reads it (the prefilter below and the raw scans are not linear in its size).
449
+ if (( ${#INPUT} > MAX_ENVELOPE )); then ask_bound "the tool call is larger than $((MAX_ENVELOPE / 1024)) KiB"; fi
450
+
451
+ # ---- 2. the zero-spawn prefilter (D7) --------------------------------------------------------------
452
+ # Skip the lexer only for a `{...}` envelope with a STRING command that has no keyword and no boundary
453
+ # character. See the header: only the command's own raw JSON text is scanned.
454
+ PF_LEAD="${INPUT#"${INPUT%%[![:space:]]*}"}"
455
+ PF_TAIL="${INPUT%"${INPUT##*[![:space:]]}"}"
456
+ PF_CMD_RE='"command"[[:space:]]*:[[:space:]]*"'
457
+ # Exactly one `"command"` text in the whole envelope, or the prefilter does not decide: it reads the FIRST key
458
+ # while jq reads .tool_input.command, so a decoy key before the real one (nested, in an array, at the top level, or
459
+ # a duplicate) must go to jq. Nine characters are the text `"command"`. A key can also be spelled with a \u escape
460
+ # (`"\u0063ommand"` is the key command to jq and not the text `"command"` to the count), so an envelope that holds a
461
+ # backslash followed by u anywhere goes to jq.
462
+ PF_ONCE="${INPUT//\"command\"/}"
463
+ if [[ "${PF_LEAD:0:1}" == "{" && "${PF_TAIL: -1}" == "}" && $(( ${#INPUT} - ${#PF_ONCE} )) -eq 9 && "$INPUT" != *'\u'* && "$INPUT" =~ $PF_CMD_RE ]]; then
464
+ PF_MARK="${BASH_REMATCH[0]}"
465
+ PF_REST="${INPUT#*"$PF_MARK"}"
466
+ PF_CMD="${PF_REST%%\"*}"
467
+ case "$PF_CMD" in
468
+ *rm*|*destroy*|*push*|*eval*|*Rm*|*RM*|*rM*|*'<('*|*'>('*|*'<<'*|*[\\\'\"\$\`]*) : ;;
469
+ *) exit 0 ;;
470
+ esac
471
+ fi
472
+
473
+ # ---- 3. the lexer path -----------------------------------------------------------------------------
474
+ HOOK_SRC="${BASH_SOURCE[0]}"
475
+ HOOK_DIR="${HOOK_SRC%/*}"; [[ "$HOOK_DIR" == "$HOOK_SRC" ]] && HOOK_DIR=.
476
+ LEXER="$HOOK_DIR/lib/shell-argv.pl"
477
+
478
+ # (no regex function: a jq built without regex support must still read the envelope; newlines and carriage returns become blanks so the
479
+ # three fields stay three lines)
480
+ JQ_FIELDS='if type != "object" then "invalid" else
481
+ ((.tool_name // "" | if type == "string" then . else "" end | split("\n") | join(" ") | split("\r") | join(" ")),
482
+ (.cwd // "" | if type == "string" then . else "" end | split("\n") | join(" ") | split("\r") | join(" ")),
483
+ (try (.tool_input.command | type) catch "invalid"))
484
+ end'
485
+
486
+ degrade_jq() {
487
+ echo "soleur destructive-command-guard: jq is missing or unusable on this machine; scanning the raw tool input with the guard's own narrow patterns instead of parsing it (install jq for full coverage)" >&2
488
+ local raw="$INPUT"
489
+ raw="${raw//\\n/;}"; raw="${raw//\\u000[aA]/;}"; raw="${raw//\\u000[dD]/;}"
490
+ raw="${raw//\\t/ }"; raw="${raw//\\r/ }"; raw="${raw//\\u0009/ }"; raw="${raw//\\u0020/ }"
491
+ scan_narrow "$raw"; SCAN_RC=$?
492
+ [[ "$SCAN_RC" -eq 2 ]] && ask_bound "$SCAN_WHY"
493
+ if [[ "$SCAN_RC" -eq 0 ]]; then
494
+ emit_fixed ask "guard-degraded-jq-missing: jq is missing or unusable on this machine, so the destructive-command guard scanned the raw tool input instead of parsing it, and this call looks destructive (a recursive delete of / or home, a destroy, or a force push).${AMARK} Stop and tell the person. Do not retry this command or rephrase it. If no person is available, end the task and report it as blocked. The person can run it in their own terminal, or start the session with SOLEUR_DISABLE_DESTRUCTIVE_GUARD=1, or install jq. Report a wrong flag at ${ISSUES_URL}"
495
+ fi
496
+ exit 0
497
+ }
498
+
499
+ JQ_RC=0
500
+ FIELDS="$(printf '%s' "$INPUT" | jq -r "$JQ_FIELDS" 2>/dev/null)" || JQ_RC=$?
501
+ if [[ "$JQ_RC" -ne 0 ]]; then
502
+ # Probe by RESULT: a working jq that rejected this envelope is a bad envelope (ask); anything else is a
503
+ # missing or unusable jq (raw scan).
504
+ JQ_PROBE="$(printf '{"a":1}' | jq -r '.a' 2>/dev/null)" || JQ_PROBE=""
505
+ [[ "$JQ_PROBE" == 1 ]] && ask_envelope "the envelope is not valid JSON"
506
+ degrade_jq
507
+ fi
508
+ [[ "$FIELDS" == invalid ]] && ask_envelope "the envelope is not a JSON object"
509
+ RAW_TOOL="${FIELDS%%$'\n'*}"; FIELDS_REST="${FIELDS#*$'\n'}"
510
+ CWD_IN="${FIELDS_REST%%$'\n'*}"; CMD_TYPE="${FIELDS_REST#*$'\n'}"
511
+ # Only a Bash call is decided, by the RAW name (Devin's `exec` is not decided: D2). A missing tool_name is read as Bash (fail toward deciding); the registered
512
+ # matcher is ^Bash$, so a real call always carries it.
513
+ if [[ -n "$RAW_TOOL" ]]; then
514
+ [[ "$RAW_TOOL" == Bash ]] || exit 0
515
+ fi
516
+ [[ "$CMD_TYPE" == string ]] || ask_envelope "tool_input.command is not a string"
517
+
518
+ # long_word_judged <word>: 0 when a word over MAX_WORD_BYTES sits where the rule table would EXPAND it, so it is a bound ask. Those are
519
+ # the command-name word itself, any word of a record whose command is rm, cd, pushd, popd, builtin or a wrapper (their words are paths and
520
+ # options), and a dash word of git, terraform or tofu (the option loops). A long argument that is only text (a PR body, a commit message,
521
+ # an echo argument, an assignment value) is never expanded by anything, so it is neither judged nor asked about. Reads the words of the
522
+ # current record already read (W_TXT from REC_OFF); the word being tested is not yet in W_TXT.
523
+ long_word_judged() {
524
+ local w="$1" k name had=0 hit=1
525
+ for ((k = REC_OFF[${#REC_OFF[@]} - 1]; k < NW; k++)); do
526
+ [[ "${W_TXT[$k]}" =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] || break
527
+ done
528
+ if (( k >= NW )); then # nothing but assignments so far: this word is another assignment (not judged) or the command name (judged)
529
+ [[ "$w" =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]] && return 1
530
+ return 0
531
+ fi
532
+ name="${W_TXT[$k]##*/}"
533
+ shopt -q nocasematch && had=1
534
+ shopt -s nocasematch
535
+ case "$name" in
536
+ rm|cd|pushd|popd|builtin|sudo|doas|env|command|nohup|time|timeout|nice|rtk|-p) hit=0 ;;
537
+ git|terraform|tofu) [[ "$w" == -* ]] && hit=0 ;;
538
+ esac
539
+ (( had )) || shopt -u nocasematch
540
+ return "$hit"
541
+ }
542
+
543
+ # ---- 4. lex ----------------------------------------------------------------------------------------
544
+ # Frames: C \0 ctx \0 argc \0 (flags \0 arg \0){argc} ... then OK \0 (or E \0 cause \0 on failure).
545
+ W_TXT=(); W_FLG=(); REC_OFF=(); REC_N=()
546
+ SAW_OK=0; SAW_E=""; LEX_BAD=0; BOUND_READ=0
547
+ LEX_ST=0; LEX_LEFT=0; LEX_KIND=0; NW=0; FRN=0
548
+ while IFS= read -r -d '' F; do
549
+ # a clock check every 1024 frames, so one huge record is bounded too (the per-record check below sees only record starts)
550
+ FRN=$((FRN + 1))
551
+ if (( (FRN & 1023) == 0 && SECONDS >= DEADLINE_S )); then
552
+ BOUND_SOFT="the ${LIMIT_S} s time limit was reached while reading the lexer output"; BOUND_READ=1
553
+ if (( LEX_ST == 3 )); then
554
+ # a record cut mid-way keeps the words it has: they are judged; with none complete it is dropped (its words are not all there)
555
+ LEX_DONE=$((NW - REC_OFF[${#REC_OFF[@]} - 1]))
556
+ if (( LEX_DONE > 0 )); then REC_N[${#REC_N[@]} - 1]="$LEX_DONE"
557
+ else unset 'REC_OFF[${#REC_OFF[@]} - 1]' 'REC_N[${#REC_N[@]} - 1]'; fi
558
+ fi
559
+ break
560
+ fi
561
+ case "$LEX_ST" in
562
+ 0) case "$F" in C) LEX_ST=1 ;; OK) SAW_OK=1 ;; E) LEX_ST=9 ;; *) LEX_BAD=1 ;; esac ;;
563
+ 1) LEX_ST=2 ;; # the ctx field
564
+ 2) if [[ "$F" =~ ^[1-9][0-9]{0,6}$ ]]; then
565
+ if (( ${#REC_N[@]} >= MAX_RECORDS )); then BOUND_SOFT="more than ${MAX_RECORDS} simple commands"; break; fi
566
+ if (( NW + F > MAX_WORDS )); then BOUND_SOFT="more than ${MAX_WORDS} words"; break; fi
567
+ if (( SECONDS >= DEADLINE_S )); then BOUND_SOFT="the ${LIMIT_S} s time limit was reached while reading the lexer output"; BOUND_READ=1; break; fi
568
+ REC_OFF[${#REC_OFF[@]}]="$NW"; REC_N[${#REC_N[@]}]="$F"; LEX_LEFT=$((F * 2)); LEX_KIND=0; LEX_ST=3
569
+ else LEX_BAD=1; LEX_ST=0; fi ;;
570
+ 3) if [[ "$LEX_KIND" -eq 0 ]]; then W_FLG[${#W_FLG[@]}]="$F"; LEX_KIND=1
571
+ else
572
+ # a word over MAX_WORD_BYTES is replaced by a placeholder and the command asks `bound` unless something denies: the rule table's
573
+ # expansions on it are quadratic, the lexer's 2 s alarm does not cover them, and the clock is read only between records
574
+ if (( ${#F} > MAX_WORD_BYTES )); then
575
+ long_word_judged "$F" && BOUND_SOFT="a word longer than ${MAX_WORD_BYTES} bytes"
576
+ F="(overlong word)"
577
+ fi
578
+ W_TXT[${#W_TXT[@]}]="$F"; LEX_KIND=0; NW=$((NW + 1)); fi
579
+ LEX_LEFT=$((LEX_LEFT - 1)); [[ "$LEX_LEFT" -le 0 ]] && LEX_ST=0 ;;
580
+ 9) SAW_E="$F"; LEX_ST=0 ;;
581
+ esac
582
+ done < <({ printf '%s' "$INPUT" | jq -j '.tool_input.command' | perl "$LEXER"; } 2>/dev/null)
583
+
584
+ # A read-time trip (a record or word cap, or the clock: BOUND_SOFT) does not discard the records already read: the rule table
585
+ # judges them, and the decision below is a deny if one matched, else a bound ask. After a clock trip the judging gets 2 more
586
+ # seconds counted from the trip (the harness kills the hook at 10 s; a trip detected late must not leave the judging no time at all).
587
+ if [[ "$BOUND_READ" -eq 1 ]]; then
588
+ DEADLINE_S=$((SECONDS + 2))
589
+ (( DEADLINE_S > 9 )) && DEADLINE_S=9 # inside the harness's 10 s kill: a trip detected at 8 s or later leaves the judging one second or none
590
+ fi
591
+ if [[ "$SAW_OK" -ne 1 && -z "$BOUND_SOFT" ]]; then
592
+ if [[ -n "$SAW_E" ]]; then ask_parse "lexer ${SAW_E}"; fi
593
+ # No OK and no E: the lexer was killed, crashed, or perl is missing/unusable. Probe by RESULT.
594
+ PERL_PROBE="$(perl -e 'print "ok"' 2>/dev/null)" || PERL_PROBE=""
595
+ if [[ "$PERL_PROBE" != ok || ! -r "$LEXER" ]]; then
596
+ echo "soleur destructive-command-guard: perl (or its lexer) is missing or unusable on this machine; scanning the decoded command with the guard's own narrow patterns instead of lexing it (install perl for full coverage)" >&2
597
+ DEC_CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command' 2>/dev/null)" || DEC_CMD=""
598
+ scan_narrow "$DEC_CMD"; SCAN_RC=$?
599
+ [[ "$SCAN_RC" -eq 2 ]] && ask_bound "$SCAN_WHY"
600
+ if [[ "$SCAN_RC" -eq 0 ]]; then
601
+ redact_text "$SCAN_SEG"
602
+ emit ask "guard-degraded-perl-missing: perl is missing or unusable on this machine, so the destructive-command guard scanned the decoded command with its narrow patterns instead of lexing it, and this call looks destructive. Matched segment: ${RTXT:0:QUOTE_MAX}.${REASON_TAIL}"
603
+ fi
604
+ exit 0
605
+ fi
606
+ ask_parse "the lexer produced no result"
607
+ fi
608
+ [[ "$LEX_BAD" -eq 1 ]] && ask_parse "the lexer output was malformed"
609
+ # OK with no record is the right answer for a blank or comment-only command and for a command whose text names nothing the
610
+ # guard decides on (a bare redirect, `> out.log`): there is nothing here for the guard to judge. A lexer that returned nothing
611
+ # for text that DOES name something the guard decides on has silently dropped its input, and asks. The test is on the text, in
612
+ # two parts, both a WORD test on the last path component of a token (so /bin/rm and ./rm count, `git/err` does not): (1) a token (the
613
+ # line split on blanks and shell metacharacters) that is rm, destroy, push, terraform, tofu, git or eval in any case, so a file NAME
614
+ # that merely contains one (terraform.log, format.log, rm.txt), quoted or not, is not a hit; and (2) when the line has a quote, a
615
+ # backslash, a backtick or a $ that is not a plain variable name, the line read again with those characters removed (r""m, 'r'm,
616
+ # r\m, $'r''m', ev""al) has such a token. A line whose first non-blank character is # is a comment and is skipped; a # later in a
617
+ # line is not a comment ("a #b" is a quoted string), so nothing after it is dropped. (Pass 1 does not split at a backslash, pass 2
618
+ # also splits at { and }: ${IFS}rm is IFS and rm.) The text is split into lines once (a loop that
619
+ # re-sliced the rest per line was quadratic) and the clock is read every 1024 lines. No process and no here-string (bash before 5.1
620
+ # writes a temporary file for <<<).
621
+ RE_DOLLAR_X='\$([^A-Za-z0-9_]|$)'
622
+ # le_words <text>: 0 when a blank-separated token of the text, taken by its last path component, is a keyword (the rule table
623
+ # compares the basename too, so /bin/rm and ./rm are rm). Reads under set -f and the caller's nocasematch; a token over MAX_WORD_BYTES is
624
+ # compared whole (a basename strip is quadratic in its length).
625
+ le_words() {
626
+ local w
627
+ for w in $1; do
628
+ (( ${#w} > MAX_WORD_BYTES )) || w="${w##*/}"
629
+ case "$w" in rm|destroy|push|terraform|tofu|git|eval) return 0 ;; esac
630
+ done
631
+ return 1
632
+ }
633
+ LE_CLOCK=0
634
+ lexer_empty_hit() { # <command text>: 0 when the text mentions what the guard decides on; LE_CLOCK=1 when the clock ran out first
635
+ local IFS=$'\n' ln j had=0 hit=1 nl=0
636
+ local -a lines
637
+ LE_CLOCK=0
638
+ shopt -q nocasematch && had=1
639
+ shopt -s nocasematch
640
+ # shellcheck disable=SC2206 # the split on newlines is the point (IFS is a newline, no pathname expansion: set -f)
641
+ lines=($1) # the text is split ONCE (set -f is on; blank lines vanish, which is harmless: a blank line is skipped anyway)
642
+ IFS=$' \t\n'
643
+ for ln in ${lines[@]+"${lines[@]}"}; do
644
+ nl=$((nl + 1))
645
+ if (( (nl & 1023) == 0 && SECONDS >= DEADLINE_S )); then LE_CLOCK=1; hit=0; break; fi
646
+ ln="${ln#"${ln%%[![:space:]]*}"}"
647
+ [[ -n "$ln" && "$ln" != '#'* ]] || continue
648
+ j="${ln//[;&|()<>\$\"\'\`]/ }" # (not the backslash: terraform\.log is a file name; pass 2 removes the backslash)
649
+ if le_words "$j"; then hit=0; break; fi
650
+ if [[ "$ln" == *[\'\"\\\`]* || "$ln" =~ $RE_DOLLAR_X ]]; then
651
+ j="${ln//[\'\"\\\`\$]/}"; j="${j//[;&|()<>{\}]/ }"
652
+ if le_words "$j"; then hit=0; break; fi
653
+ fi
654
+ done
655
+ (( had )) || shopt -u nocasematch
656
+ return "$hit"
657
+ }
658
+ if [[ "${#REC_N[@]}" -eq 0 && -z "$BOUND_SOFT" ]]; then
659
+ LX_CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command' 2>/dev/null)" || ask_lexer_empty
660
+ if lexer_empty_hit "$LX_CMD"; then
661
+ [[ "$LE_CLOCK" -eq 1 ]] && ask_bound "the ${LIMIT_S} s time limit was reached while checking whether the lexer dropped a command"
662
+ ask_lexer_empty
663
+ fi
664
+ fi
665
+
666
+ # ---- 5. the rule table -----------------------------------------------------------------------------
667
+ BEST_RANK=0; BEST_RULE=""; BEST_QUOTE=""
668
+ REC_RANK=0; REC_RULE=""
669
+ note() { # <rank 1|2> <rule id>
670
+ if (( $1 > REC_RANK )); then REC_RANK="$1"; REC_RULE="$2"; fi
671
+ }
672
+
673
+ SIMCWD=""; ORIGCWD=""; CWD_READY=0; UNRES=0; EH=""; EH_READY=0
674
+ HL=""; HP=""; HOME_READY=0
675
+ GIT_SCRUBBED=0
676
+ AV=(); AF=(); DA_T=(); DA_F=()
677
+ EW=""; EW_Q=0; RP=""; LN=""; WJ=-1
678
+
679
+ # lex_norm <abs path> -> LN: collapse // . .. lexically.
680
+ lex_norm() {
681
+ local IFS=/ part i n=0
682
+ local -a stack=()
683
+ for part in $1; do
684
+ case "$part" in
685
+ ''|.) : ;;
686
+ ..) if (( n > 0 )); then n=$((n - 1)); unset "stack[$n]"; fi ;;
687
+ *) stack[n]="$part"; n=$((n + 1)) ;;
688
+ esac
689
+ done
690
+ LN=""
691
+ for ((i = 0; i < n; i++)); do LN="$LN/${stack[$i]}"; done
692
+ [[ -n "$LN" ]] || LN=/
693
+ }
694
+
695
+ # resolve_phys <abs path> <follow 0|1> -> RP (empty = no decision: rm refuses `.`)
696
+ # follow=1: the path itself is resolved through symlinks (a trailing slash or a glob suffix); follow=0: a
697
+ # bare final name stays literal (`rm -rf link` only unlinks the link). A path with more than MAX_PATH_COMPONENTS components
698
+ # sets BOUND_SOFT and returns 1 (nothing about that path was resolved); the rest of the command is still judged, so a
699
+ # deny elsewhere in it wins, and the decision asks `bound` when nothing denied.
700
+ #
701
+ # phys_walk <abs dir> -> WK WP: ONE subshell finds the longest existing prefix of the directory (WP, physical) and how
702
+ # many trailing components it had to drop to get there (WK; 0 = the directory itself exists). The answer is cached per
703
+ # directory (a long target list shares its parents) in a table capped at PW_MAX entries, so a lookup is a short scan;
704
+ # only this final per-directory answer is cached, never a probe of a prefix.
705
+ PW_K=(); PW_V=()
706
+ PW_MAX=256
707
+ phys_walk() {
708
+ local k n=${#PW_K[@]} out
709
+ for ((k = 0; k < n; k++)); do
710
+ if [[ "${PW_K[$k]}" == "$1" ]]; then out="${PW_V[$k]}"; WK="${out%%$'\n'*}"; WP="${out#*$'\n'}"; return 0; fi
711
+ done
712
+ out="$( cd / && q="$1" && c=0 && while ! cd -P -- "$q" 2>/dev/null; do c=$((c + 1)); q="${q%/*}"; [[ -z "$q" ]] && q=/; (( c > MAX_PATH_COMPONENTS + 2 )) && break; done; printf '%s\n' "$c"; pwd -P )" || out=""
713
+ WK="${out%%$'\n'*}"; WP="${out#*$'\n'}"
714
+ [[ "$WK" =~ ^[0-9]+$ ]] || { WK=0; WP=""; }
715
+ if (( n < PW_MAX )); then PW_K[n]="$1"; PW_V[n]="$WK"$'\n'"$WP"; fi
716
+ }
717
+
718
+ resolve_phys() {
719
+ local p="$1" follow="$2" last dir rest probe slashes k
720
+ RP=""
721
+ if (( ${#p} > MAX_WORD_BYTES )); then BOUND_SOFT="a path longer than ${MAX_WORD_BYTES} bytes"; return 1; fi
722
+ while [[ "$p" == */ && "$p" != / ]]; do p="${p%/}"; done
723
+ if [[ "$p" == / ]]; then RP=/; return 0; fi
724
+ slashes="${p//[!\/]/}"
725
+ if (( ${#slashes} > MAX_PATH_COMPONENTS )); then BOUND_SOFT="a path with more than ${MAX_PATH_COMPONENTS} components"; return 1; fi
726
+ last="${p##*/}"
727
+ if [[ "$follow" == 0 && "$last" == . ]]; then return 1; fi
728
+ dir="${p%/*}"; [[ -z "$dir" ]] && dir=/
729
+ if [[ "$follow" == 1 || "$last" == .. ]]; then
730
+ # the path itself first; when it is missing the same walk already knows its parent (one component fewer)
731
+ phys_walk "$p"
732
+ if (( WK == 0 )) && [[ -n "$WP" ]]; then RP="$WP"; return 0; fi
733
+ k=$((WK - 1)); [[ "$k" -lt 0 ]] && k=0
734
+ else
735
+ phys_walk "$dir"
736
+ k="$WK"
737
+ fi
738
+ [[ -n "$WP" ]] || { WP=/; }
739
+ rest="$last"; probe="$dir"
740
+ while (( k > 0 )); do
741
+ rest="${probe##*/}/$rest"; probe="${probe%/*}"; [[ -z "$probe" ]] && probe=/
742
+ k=$((k - 1))
743
+ done
744
+ lex_norm "${WP%/}/$rest"; RP="$LN"
745
+ return 0
746
+ }
747
+
748
+ anc_or_eq() { # <a> <b>: a is /, equals b, or is an ancestor of b
749
+ local a="$1" b="$2"
750
+ while [[ "$a" == */ && "$a" != / ]]; do a="${a%/}"; done
751
+ while [[ "$b" == */ && "$b" != / ]]; do b="${b%/}"; done
752
+ [[ -z "$a" ]] && a=/
753
+ [[ -z "$b" ]] && b=/
754
+ [[ "$a" == / || "$b" == "$a" || "$b" == "$a"/* ]]
755
+ }
756
+
757
+ cwd_ready() {
758
+ [[ "$CWD_READY" -eq 1 ]] && return 0
759
+ CWD_READY=1
760
+ local base="${CWD_IN:-${CLAUDE_PROJECT_DIR:-${PWD:-/}}}"
761
+ [[ "$base" == /* ]] || base="${PWD:-/}/$base"
762
+ resolve_phys "$base" 1 || RP="$base"
763
+ SIMCWD="$RP"; ORIGCWD="$RP"
764
+ }
765
+
766
+ # eff_home -> EH: HOME when it is set, else the directory bash itself would use for ~ (the passwd entry), else
767
+ # empty. The lookup is one subshell, and only when HOME is empty or unset.
768
+ eff_home() {
769
+ [[ "$EH_READY" -eq 1 ]] && return 0
770
+ EH_READY=1
771
+ if [[ -n "${HOME:-}" ]]; then EH="$HOME"; else EH="$( ( unset HOME; cd ~ 2>/dev/null && pwd -P ) 2>/dev/null )"; fi
772
+ }
773
+
774
+ home_ready() {
775
+ [[ "$HOME_READY" -eq 1 ]] && return 0
776
+ HOME_READY=1
777
+ eff_home
778
+ local h="$EH"
779
+ if [[ "$h" != /* ]]; then HL="/nonexistent-home-unset"; HP="$HL"; return 0; fi
780
+ lex_norm "$h"; HL="$LN"
781
+ resolve_phys "$h" 1 || RP="$HL"
782
+ HP="$RP"
783
+ }
784
+
785
+ scrub_git_env() {
786
+ [[ "$GIT_SCRUBBED" -eq 1 ]] && return 0
787
+ GIT_SCRUBBED=1
788
+ local v
789
+ while IFS= read -r v; do
790
+ case "$v" in GIT_*) unset "$v" ;; esac
791
+ done < <(compgen -e)
792
+ export GIT_TERMINAL_PROMPT=0
793
+ }
794
+ git_ro() { # <repo dir> <args...>: read-only, local only
795
+ local repo="$1"; shift
796
+ scrub_git_env
797
+ git -C "$repo" "$@" 2>/dev/null </dev/null
798
+ }
799
+
800
+ is_assign() { [[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*\+?= ]]; }
801
+
802
+ # expand_word <text> <flags> -> EW, EW_Q; returns 1 when the word cannot be resolved (a variable target is
803
+ # no decision). Tilde: only an unquoted leading ~ or ~/ (plus the quoted `~/` and `~/*` forms, see header);
804
+ # $HOME ${HOME} $PWD ${PWD} when expanded; any other expansion is unresolvable.
805
+ expand_word() {
806
+ local t="$1" fl="$2" xd=0 qd=0 P
807
+ EW=""; EW_Q=0
808
+ case "$fl" in *q*) qd=1 ;; esac
809
+ case "$fl" in *x*) xd=1 ;; esac
810
+ EW_Q="$qd"
811
+ if (( qd == 0 )) || [[ "$t" == '~/' || "$t" == '~/*' ]]; then
812
+ case "$t" in
813
+ '~'|'~/'*) eff_home; [[ -n "$EH" ]] || return 1; P='~'; t="$EH${t#"$P"}" ;;
814
+ '~+'|'~+/'*) cwd_ready; P='~+'; t="$SIMCWD${t#"$P"}" ;;
815
+ '~'*) return 1 ;;
816
+ esac
817
+ fi
818
+ if (( xd )); then
819
+ case "$t" in
820
+ '$HOME'|'$HOME/'*) P='$HOME'; t="${HOME:-}${t#"$P"}" ;; # an unset HOME expands to nothing: $HOME/* is /*
821
+ '${HOME}'|'${HOME}/'*) P='${HOME}'; t="${HOME:-}${t#"$P"}" ;;
822
+ '$PWD'|'$PWD/'*) cwd_ready; P='$PWD'; t="$SIMCWD${t#"$P"}" ;;
823
+ '${PWD}'|'${PWD}/'*) cwd_ready; P='${PWD}'; t="$SIMCWD${t#"$P"}" ;;
824
+ esac
825
+ case "$t" in *'$'*|*'`'*|*'<('*|*'>('*) return 1 ;; esac
826
+ fi
827
+ [[ -n "$t" ]] || return 1
828
+ EW="$t"
829
+ return 0
830
+ }
831
+
832
+ # glob_contents <text> <quoted 0|1> -> GD: 0 when the target names the CONTENTS of a directory, i.e. every path
833
+ # component after the glob-free root is only glob syntax (`*`, `**`, `?`, `[a-z]`, in any run): /** /*/* /*/ /?
834
+ # /[a-z]* ~/** and a bare * or ./*. A component that mixes literal text with a glob (`*.log`, `.*`, `node_modules`
835
+ # after a `*`) is a different set of files and is not matched. A quoted target is a glob only in the old
836
+ # trailing-/* spelling (see the header: `~/*` is read as home even when quoted).
837
+ RE_GLOB_ONLY='^(\*|\?|\[[^]]+\])+$'
838
+ glob_contents() {
839
+ local t="$1" qd="$2" comp="" root="" seen=0 IFS=/ lead=""
840
+ GD=""
841
+ if (( qd )); then
842
+ if [[ "$t" == */'*' ]]; then GD="${t%/\*}"; [[ -z "$GD" ]] && GD=/; return 0; fi
843
+ return 1
844
+ fi
845
+ [[ "$t" == /* ]] && lead=/
846
+ for comp in $t; do
847
+ if (( seen )); then
848
+ [[ -z "$comp" ]] && continue
849
+ [[ "$comp" =~ $RE_GLOB_ONLY ]] || return 1
850
+ elif [[ "$comp" =~ $RE_GLOB_ONLY ]]; then seen=1
851
+ else
852
+ case "$comp" in *'*'*|*'?'*|*'['*) return 1 ;; esac
853
+ [[ -n "$comp" ]] && root="${root:+$root/}$comp"
854
+ fi
855
+ done
856
+ (( seen )) || return 1
857
+ if [[ -n "$lead" ]]; then GD="/$root"; else GD="${root:-.}"; fi
858
+ return 0
859
+ }
860
+
861
+ rule_rm() {
862
+ local -a a=("${AV[@]}") fl=("${AF[@]}") tix=()
863
+ local n=${#a[@]} k x name rec=0 end=0 glob follow T D
864
+ for ((k = 1; k < n; k++)); do
865
+ x="${a[$k]}"
866
+ if (( end )); then tix[${#tix[@]}]="$k"
867
+ elif [[ "$x" == -- ]]; then end=1
868
+ elif [[ "$x" == --* ]]; then
869
+ name="${x#--}"
870
+ if [[ -n "$name" && "recursive" == "$name"* ]]; then rec=1; fi
871
+ elif [[ "$x" == -?* ]]; then
872
+ case "${x:1}" in *[rR]*) rec=1 ;; esac
873
+ else tix[${#tix[@]}]="$k"; fi
874
+ done
875
+ (( rec )) || return 0
876
+ cwd_ready; home_ready
877
+ if (( UNRES )); then note 1 unresolved-cd-before-destructive; fi
878
+ for k in ${tix[@]+"${tix[@]}"}; do
879
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking the targets of rm"; return 0; fi
880
+ expand_word "${a[$k]}" "${fl[$k]}" || continue
881
+ T="$EW"; glob=0; follow=0
882
+ if glob_contents "$T" "$EW_Q"; then glob=1; follow=1; D="$GD"
883
+ elif [[ "$T" == */'*' ]]; then glob=1; follow=1; D="${T%/\*}"; [[ -z "$D" ]] && D=/
884
+ elif [[ "$T" == */ ]]; then
885
+ follow=1; D="$T"
886
+ x="$D"; while [[ "$x" == */ && "$x" != / ]]; do x="${x%/}"; done
887
+ [[ "$x" != / && "${x##*/}" == . ]] && continue
888
+ else D="$T"; fi
889
+ [[ "$D" == /* ]] || D="$SIMCWD/$D"
890
+ resolve_phys "$D" "$follow" || continue
891
+ [[ -n "$RP" ]] || continue
892
+ if anc_or_eq "$RP" "$HL" || anc_or_eq "$RP" "$HP"; then note 2 recursive-delete-home
893
+ elif (( glob == 0 )) && { anc_or_eq "$RP" "$SIMCWD" || anc_or_eq "$RP" "$ORIGCWD"; }; then note 1 recursive-delete-workdir; fi
894
+ done
895
+ }
896
+
897
+ rule_tf() {
898
+ local -a a=("${AV[@]}")
899
+ local n=${#a[@]} i=1 sub x
900
+ while (( i < n )) && [[ "${a[$i]}" == -* ]]; do i=$((i + 1)); done
901
+ (( i < n )) || return 0
902
+ sub="${a[$i]}"
903
+ if [[ "$sub" == destroy ]]; then note 1 infra-destroy; return 0; fi
904
+ if [[ "$sub" == apply ]]; then
905
+ for ((i = i + 1; i < n; i++)); do
906
+ x="${a[$i]}"
907
+ case "$x" in
908
+ -destroy|--destroy) note 1 infra-destroy; return 0 ;;
909
+ -destroy=*|--destroy=*) # a Go bool: 0 f F false FALSE False turn it off, every other value is on (or an error)
910
+ case "${x#*=}" in 0|f|F|false|FALSE|False) : ;; *) note 1 infra-destroy; return 0 ;; esac ;;
911
+ esac
912
+ done
913
+ fi
914
+ }
915
+
916
+ rule_git() {
917
+ local -a a=("${AV[@]}") pos=() refs=() D_DST=() D_F=() D_DEL=()
918
+ local n=${#a[@]} i=1 j k c x name end=0 repo cur head remote named
919
+ local force=0 del=0 allf=0 mirror=0 repoopt=0 risk=0 r f dele src dst mt=0 mtf=0
920
+ cwd_ready; repo="$SIMCWD"
921
+ while (( i < n )); do
922
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking the options of git"; return 0; fi
923
+ case "${a[$i]}" in
924
+ -C) if (( i + 1 < n )); then
925
+ case "${a[$((i + 1))]}" in /*) repo="${a[$((i + 1))]}" ;; *) repo="$repo/${a[$((i + 1))]}" ;; esac
926
+ fi
927
+ i=$((i + 2)) ;;
928
+ # the separate-argument forms take the next word; `--opt=value` and every other flag take none
929
+ -c|--config-env|--git-dir|--work-tree|--namespace|--super-prefix|--attr-source) i=$((i + 2)) ;;
930
+ -*) i=$((i + 1)) ;;
931
+ *) break ;;
932
+ esac
933
+ done
934
+ [[ "${a[$i]:-}" == push ]] || return 0
935
+ j=$((i + 1))
936
+ while (( j < n )); do
937
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking the flags of git push"; return 0; fi
938
+ x="${a[$j]}"
939
+ if (( end )); then pos[${#pos[@]}]="$x"
940
+ elif [[ "$x" == -- ]]; then end=1
941
+ elif [[ "$x" == --* ]]; then
942
+ name="${x%%=*}"
943
+ case "$name" in
944
+ --force*) force=1 ;;
945
+ --de|--del|--dele|--delet|--delete) del=1 ;;
946
+ --al|--all) allf=1 ;; # --al is the shortest unique abbreviation (--a is ambiguous)
947
+ --m|--mi|--mir|--mirr|--mirro|--mirror) mirror=1 ;;
948
+ --repo) repoopt=1; [[ "$x" == *=* ]] || j=$((j + 1)) ;;
949
+ --push-option|--receive-pack|--exec) [[ "$x" == *=* ]] || j=$((j + 1)) ;;
950
+ esac
951
+ elif [[ "$x" == -?* ]]; then
952
+ for ((k = 1; k < ${#x}; k++)); do
953
+ c="${x:$k:1}"
954
+ case "$c" in
955
+ f) force=1 ;;
956
+ d) del=1 ;;
957
+ o) [[ "$k" -eq $((${#x} - 1)) ]] && j=$((j + 1)); break ;;
958
+ esac
959
+ done
960
+ else pos[${#pos[@]}]="$x"; fi
961
+ j=$((j + 1))
962
+ done
963
+ remote=""
964
+ if (( repoopt )); then
965
+ for x in ${pos[@]+"${pos[@]}"}; do refs[${#refs[@]}]="$x"; done
966
+ else
967
+ if (( ${#pos[@]} > 0 )); then remote="${pos[0]}"; fi
968
+ for ((k = 1; k < ${#pos[@]}; k++)); do refs[${#refs[@]}]="${pos[$k]}"; done
969
+ fi
970
+ named="${remote:-origin}"
971
+ # The destinations, flagged: +refspec / +dst (force) and :dst (delete) are risky on their own.
972
+ for r in ${refs[@]+"${refs[@]}"}; do
973
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking the refs of git push"; return 0; fi
974
+ f=0; dele=0
975
+ if [[ "$r" == +* ]]; then f=1; r="${r#+}"; fi
976
+ if [[ "$r" == : ]]; then
977
+ mt=1; (( f )) && mtf=1 # the matching refspec: every branch both sides have
978
+ dst=""
979
+ elif [[ "$r" == *:* ]]; then
980
+ src="${r%%:*}"; dst="${r#*:}"
981
+ if [[ "$dst" == +* ]]; then f=1; dst="${dst#+}"; fi
982
+ [[ -z "$src" ]] && dele=1
983
+ else
984
+ dst="$r"
985
+ fi
986
+ (( f || dele )) && risk=1
987
+ D_DST[${#D_DST[@]}]="$dst"; D_F[${#D_F[@]}]="$f"; D_DEL[${#D_DEL[@]}]="$dele"
988
+ done
989
+ (( force || del )) && risk=1
990
+ (( risk )) || return 0
991
+ # The directory the push acts on depends on the working directory: an unresolvable cd decides it here.
992
+ if (( UNRES )); then note 1 unresolved-cd-before-destructive; return 0; fi
993
+ if (( (allf || mirror) && force )); then note 1 default-branch-force-push; return 0; fi
994
+ if (( mt && (force || mtf) )); then note 1 default-branch-force-push; return 0; fi
995
+ head=""; cur=""; local have_head=0 have_cur=0
996
+ local -a defaults=(main master)
997
+ for ((k = 0; k < ${#D_DST[@]}; k++)); do
998
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking the destinations of git push"; return 0; fi
999
+ dst="${D_DST[$k]}"
1000
+ if [[ "$dst" == HEAD ]]; then
1001
+ if (( ! have_cur )); then cur="$(git_ro "$repo" symbolic-ref --short HEAD)"; have_cur=1; fi
1002
+ dst="$cur"
1003
+ fi
1004
+ dst="${dst#refs/heads/}"; dst="${dst#heads/}"
1005
+ [[ -n "$dst" ]] || continue
1006
+ if (( force || D_F[k] || del || D_DEL[k] )); then
1007
+ # a destination with glob syntax can name a default branch (refs/heads/*:refs/heads/*)
1008
+ case "$dst" in *'*'*|*'?'*|*'['*) note 1 default-branch-force-push; return 0 ;; esac
1009
+ if [[ "$dst" == main || "$dst" == master ]]; then note 1 default-branch-force-push; return 0; fi
1010
+ if (( ! have_head )); then
1011
+ head="$(git_ro "$repo" symbolic-ref --short "refs/remotes/$named/HEAD")"; have_head=1
1012
+ head="${head#"$named"/}"
1013
+ [[ -n "$head" ]] && defaults[${#defaults[@]}]="$head"
1014
+ fi
1015
+ for x in "${defaults[@]}"; do
1016
+ if [[ "$dst" == "$x" ]]; then note 1 default-branch-force-push; return 0; fi
1017
+ done
1018
+ fi
1019
+ done
1020
+ # No refspec at all: the push goes to the current branch (the upstream default).
1021
+ if (( ${#D_DST[@]} == 0 && allf == 0 && mirror == 0 && force )); then
1022
+ cur="$(git_ro "$repo" symbolic-ref --short HEAD)"
1023
+ if [[ -n "$cur" ]]; then
1024
+ head="$(git_ro "$repo" symbolic-ref --short "refs/remotes/$named/HEAD")"; head="${head#"$named"/}"
1025
+ if [[ "$cur" == main || "$cur" == master || ( -n "$head" && "$cur" == "$head" ) ]]; then
1026
+ note 1 default-branch-force-push; return 0
1027
+ fi
1028
+ fi
1029
+ fi
1030
+ }
1031
+
1032
+ # short_val <value-taking letters> <option word>: 0 when a short-option cluster (-nHu) ENDS in a value-taking
1033
+ # letter, so that option's value is the NEXT word. A value-taking letter earlier in the cluster takes the rest of
1034
+ # the word as its value (-uroot), which needs no extra word.
1035
+ short_val() {
1036
+ local letters="$1" w="$2" k c
1037
+ for ((k = 1; k < ${#w}; k++)); do
1038
+ c="${w:$k:1}"
1039
+ case "$letters" in *"$c"*) (( k == ${#w} - 1 )); return ;; esac
1040
+ done
1041
+ return 1
1042
+ }
1043
+
1044
+ # short_first_val <value-taking letters> <option word> -> SFV SFV_REST: the first value-taking letter of a short-option cluster and
1045
+ # what follows it in the word (empty = the value is the NEXT word; non-empty = the value is attached: -D/tmp).
1046
+ SFV=""; SFV_REST=""
1047
+ short_first_val() {
1048
+ local letters="$1" w="$2" k c
1049
+ SFV=""; SFV_REST=""
1050
+ for ((k = 1; k < ${#w}; k++)); do
1051
+ c="${w:$k:1}"
1052
+ case "$letters" in *"$c"*) SFV="$c"; SFV_REST="${w:$((k + 1))}"; return 0 ;; esac
1053
+ done
1054
+ return 1
1055
+ }
1056
+
1057
+ # A wrapper's chdir option (env -C DIR, --chdir DIR; sudo -D DIR, --chdir DIR) is reported in WCD (the directory word), WCDF (its
1058
+ # lexer flags) and WCD_SET: the wrapped command runs there, and decide_walk judges it with the simulated working directory moved.
1059
+ WCD=""; WCDF="-"; WCD_SET=0
1060
+ WCD_RUNNER=0
1061
+
1062
+ # wrap_skip <name> <index of name in t> -> WJ: the index of the wrapped command's first word, or -1 when
1063
+ # the wrapper is a look-up only (`command -v`) or its payload is a string the guard does not analyse (env -S).
1064
+ # Reads t[] and n of the caller (dynamic scope, by design).
1065
+ wrap_skip() {
1066
+ local name="$1" j=$(($2 + 1)) a lname k c eat split opt
1067
+ WJ=-1
1068
+ case "$name" in
1069
+ sudo)
1070
+ while (( j < n )) && [[ "${t[$j]}" == -* && "${t[$j]}" != - ]]; do
1071
+ a="${t[$j]}"; k=$j; j=$((j + 1))
1072
+ [[ "$a" == -- ]] && break
1073
+ case "$a" in
1074
+ --*) # sudo (getopt_long) takes any unique abbreviation of a long option: --us root is --user root, --chd D is --chdir D
1075
+ lname="${a%%=*}"; lname="${lname#--}"
1076
+ if [[ -n "$lname" && "chdir" == "$lname"* ]]; then
1077
+ if [[ "$a" == *=* ]]; then WCD="${a#*=}"; WCDF="${f[$k]:--}"; WCD_SET=1
1078
+ else WCD="${t[$j]:-}"; WCDF="${f[$j]:--}"; WCD_SET=1; j=$((j + 1)); fi
1079
+ elif [[ "$a" != *=* && -n "$lname" ]]; then
1080
+ for opt in user group host prompt chroot role type close-from command-timeout other-user; do
1081
+ if [[ "$opt" == "$lname"* ]]; then j=$((j + 1)); break; fi
1082
+ done
1083
+ fi ;;
1084
+ *) if short_first_val ughpCTUDRrt "$a"; then
1085
+ if [[ -z "$SFV_REST" ]]; then
1086
+ [[ "$SFV" == D ]] && { WCD="${t[$j]:-}"; WCDF="${f[$j]:--}"; WCD_SET=1; }
1087
+ j=$((j + 1))
1088
+ else
1089
+ [[ "$SFV" == D ]] && { WCD="$SFV_REST"; WCDF="${f[$k]:--}"; WCD_SET=1; }
1090
+ fi
1091
+ fi ;;
1092
+ esac
1093
+ done
1094
+ while (( j < n )) && is_assign "${t[$j]}"; do j=$((j + 1)); done ;;
1095
+ doas)
1096
+ while (( j < n )) && [[ "${t[$j]}" == -* && "${t[$j]}" != - ]]; do
1097
+ a="${t[$j]}"; j=$((j + 1))
1098
+ [[ "$a" == -- ]] && break
1099
+ if short_val uC "$a"; then j=$((j + 1)); fi
1100
+ done ;;
1101
+ env)
1102
+ while (( j < n )); do
1103
+ a="${t[$j]}"
1104
+ if [[ "$a" == -- ]]; then j=$((j + 1)); break
1105
+ elif [[ "$a" == - ]]; then j=$((j + 1))
1106
+ elif [[ "$a" == --* ]]; then
1107
+ lname="${a%%=*}"; lname="${lname#--}"
1108
+ if [[ -n "$lname" && "split-string" == "$lname"* ]]; then note 1 unparsed-wrapper; return 0; fi
1109
+ if [[ -n "$lname" && "chdir" == "$lname"* ]]; then
1110
+ if [[ "$a" == *=* ]]; then WCD="${a#*=}"; WCDF="${f[$j]:--}"; WCD_SET=1
1111
+ else WCD="${t[$((j + 1))]:-}"; WCDF="${f[$((j + 1))]:--}"; WCD_SET=1; fi
1112
+ fi
1113
+ j=$((j + 1))
1114
+ # a value-taking long option with its value in the next word: --unset X, --chdir D, --argv0 A
1115
+ if [[ "$a" != *=* && -n "$lname" ]] && { [[ "unset" == "$lname"* ]] || [[ "chdir" == "$lname"* ]] || [[ "argv0" == "$lname"* ]]; }; then j=$((j + 1)); fi
1116
+ elif [[ "$a" == -?* ]]; then
1117
+ eat=0; split=0
1118
+ for ((k = 1; k < ${#a}; k++)); do
1119
+ c="${a:$k:1}"
1120
+ case "$c" in
1121
+ S) split=1; break ;;
1122
+ u|C|P|a)
1123
+ if (( k == ${#a} - 1 )); then
1124
+ eat=1
1125
+ [[ "$c" == C ]] && { WCD="${t[$((j + 1))]:-}"; WCDF="${f[$((j + 1))]:--}"; WCD_SET=1; }
1126
+ else
1127
+ [[ "$c" == C ]] && { WCD="${a:$((k + 1))}"; WCDF="${f[$j]:--}"; WCD_SET=1; }
1128
+ fi
1129
+ break ;;
1130
+ esac
1131
+ done
1132
+ if (( split )); then note 1 unparsed-wrapper; return 0; fi
1133
+ j=$((j + 1 + eat))
1134
+ elif is_assign "$a"; then j=$((j + 1))
1135
+ else break; fi
1136
+ done ;;
1137
+ timeout)
1138
+ while (( j < n )) && [[ "${t[$j]}" == -* ]]; do
1139
+ a="${t[$j]}"; j=$((j + 1))
1140
+ case "$a" in
1141
+ --*) # getopt_long takes a unique abbreviation: --sig KILL is --signal KILL
1142
+ lname="${a%%=*}"; lname="${lname#--}"
1143
+ if [[ "$a" != *=* && -n "$lname" ]] && { [[ signal == "$lname"* ]] || [[ kill-after == "$lname"* ]]; }; then j=$((j + 1)); fi ;;
1144
+ *) if short_val sk "$a"; then j=$((j + 1)); fi ;;
1145
+ esac
1146
+ done
1147
+ j=$((j + 1)) ;;
1148
+ nice)
1149
+ while (( j < n )) && [[ "${t[$j]}" == -* ]]; do
1150
+ a="${t[$j]}"; j=$((j + 1))
1151
+ case "$a" in
1152
+ --*)
1153
+ lname="${a%%=*}"; lname="${lname#--}"
1154
+ if [[ "$a" != *=* && -n "$lname" && adjustment == "$lname"* ]]; then j=$((j + 1)); fi ;;
1155
+ *) if short_val n "$a"; then j=$((j + 1)); fi ;;
1156
+ esac
1157
+ done ;;
1158
+ time) # the external time(1) (/usr/bin/time, `command time`, `env time`); the reserved word is the lexer's
1159
+ while (( j < n )) && [[ "${t[$j]}" == -* && "${t[$j]}" != - ]]; do
1160
+ a="${t[$j]}"; j=$((j + 1))
1161
+ [[ "$a" == -- ]] && break
1162
+ case "$a" in
1163
+ --*)
1164
+ lname="${a%%=*}"; lname="${lname#--}"
1165
+ if [[ "$a" != *=* && -n "$lname" ]] && { [[ format == "$lname"* ]] || [[ output == "$lname"* ]]; }; then j=$((j + 1)); fi ;;
1166
+ *) if short_val fo "$a"; then j=$((j + 1)); fi ;;
1167
+ esac
1168
+ done ;;
1169
+ command)
1170
+ while (( j < n )) && [[ "${t[$j]}" == -* ]]; do
1171
+ a="${t[$j]}"; j=$((j + 1))
1172
+ case "$a" in --) break ;; -v|-V|-[a-zA-Z]*[vV]*) return 0 ;; esac
1173
+ done ;;
1174
+ rtk) # grammar per the header prose; an ask here is unparsed-wrapper, never a silent allow
1175
+ while (( j < n )) && [[ "${t[$j]}" == -* && "${t[$j]}" != - ]]; do
1176
+ a="${t[$j]}"
1177
+ case "$a" in
1178
+ --) j=$((j + 1)); break ;;
1179
+ -v|-vv|-vvv|--verbose|--skip-env|--skip-env=*) j=$((j + 1)) ;;
1180
+ *) note 1 unparsed-wrapper; return 0 ;;
1181
+ esac
1182
+ done
1183
+ if (( j < n )); then
1184
+ case "${t[$j]}" in
1185
+ run|err|test|proxy|bypass|summary)
1186
+ j=$((j + 1))
1187
+ while (( j < n )) && [[ "${t[$j]}" == -* && "${t[$j]}" != - ]]; do
1188
+ a="${t[$j]}"
1189
+ case "$a" in
1190
+ --) j=$((j + 1)); break ;;
1191
+ # globals are legal post-subcommand too (clap global=true, verified in
1192
+ # lib.rs): -v/-vv/-vvv/--verbose + --skip-env
1193
+ -v|-vv|-vvv|--verbose|--skip-env|--skip-env=*) j=$((j + 1)) ;;
1194
+ *) note 1 unparsed-wrapper; return 0 ;;
1195
+ esac
1196
+ done
1197
+ # any payload word carrying whitespace or a shell metacharacter is a string payload:
1198
+ # err|test|summary may join it to `sh -c` (-c/--command/--shell were already asked on
1199
+ # the -* sweep above, so only word content remains to check)
1200
+ for ((k = j; k < n; k++)); do
1201
+ case "${t[$k]}" in
1202
+ *[[:space:]]*|*\;*|*\|*|*\&*|*\(*|*\)*|*\{*|*\}*|*\$*|*\`*|*"'"*|*'"'*|*\<*|*\>*)
1203
+ note 1 unparsed-wrapper; return 0 ;;
1204
+ esac
1205
+ done ;;
1206
+ esac
1207
+ fi ;;
1208
+ nohup|-p) : ;; # `-p` is `time -p`: the lexer drops the reserved word `time` and leaves -p as the first word
1209
+ esac
1210
+ WJ="$j"
1211
+ }
1212
+
1213
+ # fold_name <name> -> FN: the command name in lower case when it is one the table knows, else the name as it is. No process is
1214
+ # started (a `tr` per record cost 3 ms each and, with no `tr` on the PATH, silently allowed RM): the case statement runs under
1215
+ # nocasematch, which is restored.
1216
+ FN=""
1217
+ fold_name() {
1218
+ local had=0
1219
+ FN="$1"
1220
+ shopt -q nocasematch && had=1
1221
+ shopt -s nocasematch
1222
+ case "$1" in
1223
+ rm) FN="rm" ;; terraform) FN="terraform" ;; tofu) FN="tofu" ;; git) FN="git" ;;
1224
+ sudo) FN="sudo" ;; doas) FN="doas" ;; env) FN="env" ;; command) FN="command" ;; nohup) FN="nohup" ;;
1225
+ time) FN="time" ;; timeout) FN="timeout" ;; nice) FN="nice" ;; rtk) FN="rtk" ;;
1226
+ esac
1227
+ (( had )) || shopt -u nocasematch
1228
+ }
1229
+
1230
+ # walk_in_dir <dir> <flags> <depth>: decide_walk with the simulated working directory moved to a wrapper's chdir directory, for the
1231
+ # wrapped command only (the directory and the unresolved-cd state are put back). A directory that cannot be resolved (a variable, an
1232
+ # empty word) is an unresolved cd, as for a literal `cd "$X"`.
1233
+ walk_in_dir() {
1234
+ local d="$1" dflag="$2" saved_cwd saved_unres="$UNRES" ok=0 abs=0
1235
+ cwd_ready; saved_cwd="$SIMCWD"
1236
+ if [[ -n "$d" ]] && expand_word "$d" "$dflag"; then
1237
+ d="$EW"; if [[ "$d" == /* ]]; then abs=1; else d="$SIMCWD/$d"; fi
1238
+ if resolve_phys "$d" 1 && [[ -n "$RP" ]]; then SIMCWD="$RP"; ok=1; fi
1239
+ fi
1240
+ # an ABSOLUTE directory that resolved makes the working directory known again, whatever an earlier unresolvable cd left (a relative one
1241
+ # is joined to the unknown directory and stays unknown); the unresolved state is put back after the wrapped command
1242
+ if (( ok )); then (( abs )) && UNRES=0; else UNRES=1; fi
1243
+ decide_walk "$3"
1244
+ SIMCWD="$saved_cwd"; UNRES="$saved_unres"
1245
+ }
1246
+
1247
+ # decide_walk <depth>: the rule table over DA_T/DA_F (the words of one simple command), retried on the
1248
+ # command a wrapper hides and on the words after the first `--`. It hands the words it recurses on to itself
1249
+ # through DA_T/DA_F, so it CLOBBERS them: callers use decide_argv, which puts them back.
1250
+ decide_walk() {
1251
+ local depth="$1" n i k name wcd wcdf wset
1252
+ if (( depth > MAX_WRAP )); then note 1 wrapper-depth; return 0; fi
1253
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking a command"; return 0; fi
1254
+ local -a t=("${DA_T[@]}") f=("${DA_F[@]}")
1255
+ n=${#t[@]}; i=0
1256
+ while (( i < n )) && is_assign "${t[$i]}"; do i=$((i + 1)); done
1257
+ if (( i < n )); then
1258
+ name="${t[$i]##*/}"
1259
+ # command names are compared in lower case: a case-insensitive filesystem runs RM as rm
1260
+ case "$name" in *[A-Z]*) fold_name "$name"; name="$FN" ;; esac
1261
+ AV=("${t[@]:$i}"); AF=("${f[@]:$i}")
1262
+ case "$name" in
1263
+ rm) rule_rm ;;
1264
+ terraform|tofu) rule_tf ;;
1265
+ git) rule_git ;;
1266
+ sudo|doas|env|command|nohup|time|timeout|nice|rtk|-p) # `-p`: see wrap_skip
1267
+ WCD_SET=0; WCD=""
1268
+ wrap_skip "$name" "$i"
1269
+ wcd="$WCD"; wcdf="$WCDF"; wset="$WCD_SET"
1270
+ if (( WJ >= 0 && WJ < n )); then
1271
+ DA_T=("${t[@]:$WJ}"); DA_F=("${f[@]:$WJ}")
1272
+ if (( wset )); then
1273
+ # a chdir wrapper around a shell or eval: the lexer makes the inner string its OWN record, which the main loop judges with the
1274
+ # unmoved directory, so the loop treats the directory as unresolved from here on (WCD_RUNNER; an over-ask, never an under-ask)
1275
+ # (a shell can sit behind any wrapper: env -C D sudo bash -c, env -C D nice -n 5 sh -c, busybox sh -c, so every wrapped word is read)
1276
+ for ((k = WJ; k < n; k++)); do
1277
+ case "${t[$k]##*/}" in sh|bash|zsh|dash|ksh|eval|busybox) WCD_RUNNER=1; break ;; esac
1278
+ done
1279
+ walk_in_dir "$wcd" "$wcdf" $((depth + 1))
1280
+ else decide_walk $((depth + 1)); fi
1281
+ fi ;;
1282
+ esac
1283
+ fi
1284
+ for ((k = 0; k + 1 < n; k++)); do
1285
+ if [[ "${t[$k]}" == -- ]]; then
1286
+ DA_T=("${t[@]:$((k + 1))}"); DA_F=("${f[@]:$((k + 1))}")
1287
+ decide_walk $((depth + 1))
1288
+ break # the recursion retries every later `--` itself; looping on would repeat that work exponentially
1289
+ fi
1290
+ done
1291
+ }
1292
+
1293
+ # decide_argv: judge the words in DA_T/DA_F and leave both exactly as found: the main loop reads them again for
1294
+ # the quoted text and for the cd effect. A record always has at least one word (the lexer's argc is >= 1), so the
1295
+ # copies below are never empty-array expansions.
1296
+ decide_argv() {
1297
+ local -a keep_t=("${DA_T[@]}") keep_f=("${DA_F[@]}")
1298
+ decide_walk "$1"
1299
+ DA_T=("${keep_t[@]}"); DA_F=("${keep_f[@]}")
1300
+ }
1301
+
1302
+ # cd_index: CI = the index in DA_T of a cd/pushd/popd that IS the command (after assignments and a leading
1303
+ # `command` or `builtin`, which do not change what cd does), else -1.
1304
+ cd_index() {
1305
+ local n=${#DA_T[@]}
1306
+ CI=0
1307
+ while (( CI < n )) && is_assign "${DA_T[$CI]}"; do CI=$((CI + 1)); done
1308
+ while (( CI < n )); do
1309
+ case "${DA_T[$CI]}" in
1310
+ command|builtin) CI=$((CI + 1)); while [[ "${DA_T[$CI]:-}" == -p || "${DA_T[$CI]:-}" == -- ]]; do CI=$((CI + 1)); done ;;
1311
+ *) break ;;
1312
+ esac
1313
+ done
1314
+ if (( CI < n )); then
1315
+ case "${DA_T[$CI]}" in cd|pushd|popd) return 0 ;; esac
1316
+ fi
1317
+ CI=-1
1318
+ return 1
1319
+ }
1320
+
1321
+ # apply_cd: a literal cd/pushd moves the simulated working directory; an unresolvable one sets UNRES.
1322
+ apply_cd() { # reads DA_T/DA_F of the current record; CI (from cd_index) is the index of its cd/pushd/popd word
1323
+ local -a t=("${DA_T[@]}") f=("${DA_F[@]}")
1324
+ local n=${#t[@]} i=$CI j target="" tf="-" have=0
1325
+ cwd_ready
1326
+ if [[ "${t[$i]}" == popd ]]; then UNRES=1; return 0; fi
1327
+ j=$((i + 1))
1328
+ while (( j < n )); do
1329
+ if [[ "${t[$j]}" == -- ]]; then j=$((j + 1)); break; fi
1330
+ [[ "${t[$j]}" == -* && "${t[$j]}" != - ]] || break
1331
+ j=$((j + 1))
1332
+ done
1333
+ if (( j < n )); then target="${t[$j]}"; tf="${f[$j]}"; have=1; fi
1334
+ if (( ! have )); then
1335
+ if [[ -n "${HOME:-}" ]]; then target="$HOME"; else UNRES=1; return 0; fi
1336
+ tf="-"
1337
+ fi
1338
+ if [[ "$target" == - ]]; then UNRES=1; return 0; fi
1339
+ if ! expand_word "$target" "$tf"; then UNRES=1; return 0; fi
1340
+ target="$EW"
1341
+ [[ "$target" == /* ]] || target="$SIMCWD/$target"
1342
+ if resolve_phys "$target" 1 && [[ -n "$RP" ]]; then SIMCWD="$RP"; UNRES=0; else UNRES=1; fi
1343
+ }
1344
+
1345
+ NREC=${#REC_N[@]}
1346
+ for ((r = 0; r < NREC; r++)); do
1347
+ if (( SECONDS >= DEADLINE_S )); then BOUND_WHY="the ${LIMIT_S} s time limit was reached while checking the commands"; break; fi
1348
+ # one record's words by index: an array slice is O(offset) in bash and made the loop quadratic
1349
+ RO="${REC_OFF[$r]}"; RC="${REC_N[$r]}"; DA_T=(); DA_F=()
1350
+ for ((RQ = 0; RQ < RC; RQ++)); do DA_T[RQ]="${W_TXT[RO + RQ]}"; DA_F[RQ]="${W_FLG[RO + RQ]}"; done
1351
+ REC_RANK=0; REC_RULE=""
1352
+ decide_argv 0
1353
+ if (( WCD_RUNNER )); then UNRES=1; WCD_RUNNER=0; fi
1354
+ if (( REC_RANK > BEST_RANK )); then
1355
+ BEST_RANK="$REC_RANK"; BEST_RULE="$REC_RULE"
1356
+ REC_TXT=""; RW_NEXT=0
1357
+ for ((RQ = 0; RQ < ${#DA_T[@]} && ${#REC_TXT} <= QUOTE_MAX; RQ++)); do redact_word "${DA_T[RQ]}"; REC_TXT="${REC_TXT:+$REC_TXT }$RW"; done
1358
+ BEST_QUOTE="${REC_TXT:0:QUOTE_MAX}"
1359
+ (( ${#REC_TXT} > QUOTE_MAX )) && BEST_QUOTE="${BEST_QUOTE}..."
1360
+ (( BEST_RANK == 2 )) && break
1361
+ fi
1362
+ [[ -n "$BOUND_WHY" ]] && break
1363
+ # the cd effect of this record, for the commands after it
1364
+ if cd_index; then apply_cd; fi
1365
+ done
1366
+
1367
+ # ---- 6. the decision -------------------------------------------------------------------------------
1368
+ # a bound trip (record or word cap, or the deadline) is an ask unless a deny was already found
1369
+ [[ -z "$BOUND_WHY" ]] && BOUND_WHY="$BOUND_SOFT"
1370
+ # A bound with nothing matched is a bare `bound` ask. A bound AFTER an ask-class match keeps that rule's reason (the command WAS
1371
+ # recognised) with the bound sentence appended: the person sees what matched and that the rest was not checked.
1372
+ BOUND_NOTE=""
1373
+ if [[ -n "$BOUND_WHY" && "$BEST_RANK" -lt 2 ]]; then
1374
+ (( BEST_RANK == 0 )) && ask_bound "$BOUND_WHY"
1375
+ BOUND_NOTE=" The guard also stopped checking the rest of this command because it is too large to check in full (${BOUND_WHY}), so other parts of it were not checked."
1376
+ fi
1377
+ (( BEST_RANK == 0 )) && exit 0
1378
+ lead_for "$BEST_RULE"
1379
+ # env -S and too many wrappers hide the command (write it out) and an unresolvable cd hides the directory (use a literal one); every other match is a stop
1380
+ TAIL="$REASON_TAIL"
1381
+ case "$BEST_RULE" in
1382
+ unparsed-wrapper|wrapper-depth) TAIL="$WRAPPER_TAIL" ;;
1383
+ unresolved-cd-before-destructive) TAIL="$CD_TAIL" ;;
1384
+ esac
1385
+ REASON="${BEST_RULE}: ${LEAD} Matched command: [${BEST_QUOTE}].${BOUND_NOTE}${TAIL}"
1386
+ if (( BEST_RANK == 2 )); then emit deny "$REASON"; else emit ask "$REASON"; fi
1387
+ exit 0