bash-classify 0.9.1__tar.gz → 0.11.0__tar.gz

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 (367) hide show
  1. {bash_classify-0.9.1 → bash_classify-0.11.0}/.claude-plugin/marketplace.json +1 -1
  2. {bash_classify-0.9.1 → bash_classify-0.11.0}/PKG-INFO +98 -2
  3. {bash_classify-0.9.1 → bash_classify-0.11.0}/README.md +96 -0
  4. {bash_classify-0.9.1 → bash_classify-0.11.0}/SPEC.md +166 -15
  5. {bash_classify-0.9.1 → bash_classify-0.11.0}/coding-agent-plugins/claude-code/.claude-plugin/plugin.json +1 -1
  6. {bash_classify-0.9.1 → bash_classify-0.11.0}/docs/classification-guidance.md +52 -5
  7. {bash_classify-0.9.1 → bash_classify-0.11.0}/schemas/command.schema.json +14 -1
  8. bash_classify-0.11.0/schemas/match-rules.schema.json +57 -0
  9. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/__init__.py +9 -1
  10. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/classifier.py +30 -2
  11. bash_classify-0.11.0/src/bash_classify/cli.py +250 -0
  12. bash_classify-0.11.0/src/bash_classify/commands/eval.yaml +8 -0
  13. bash_classify-0.11.0/src/bash_classify/commands/exec.yaml +12 -0
  14. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/glab.yaml +67 -0
  15. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/database.py +50 -4
  16. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/matcher.py +103 -16
  17. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/models.py +13 -1
  18. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/parser.py +118 -2
  19. bash_classify-0.11.0/src/bash_classify/rules.py +248 -0
  20. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-award-emoji.sh +1 -0
  21. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-include-diverged.sh +1 -0
  22. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-itself.sh +1 -0
  23. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-pipelines-list.sh +1 -0
  24. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-update-description.sh +1 -0
  25. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/api-project-lookup.sh +1 -0
  26. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/ci-list.sh +1 -0
  27. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/comment-only-mention.sh +2 -0
  28. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/echo-mentions-api-endpoint.sh +1 -0
  29. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/echo-mentions-command.sh +1 -0
  30. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/git-commit-heredoc-message.sh +8 -0
  31. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/git-commit-inline-message.sh +3 -0
  32. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/glab-discussion-read-and-api-mr.sh +1 -0
  33. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/glab-discussion-read.sh +1 -0
  34. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/glab-pipeline-inspect.sh +1 -0
  35. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/grep-pattern-mentions-command.sh +1 -0
  36. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/grep-recursive-for-command.sh +1 -0
  37. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/heredoc-brief-2026-09-04.sh +88 -0
  38. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/heredoc-brief-mentions-blocked-commands.sh +14 -0
  39. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/heredoc-report-mentions-blocked.sh +10 -0
  40. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/heredoc-skill-custom-delimiter.sh +11 -0
  41. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/heredoc-skill-phrase-split-across-lines.sh +10 -0
  42. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/hook-self-test-json-payload.sh +1 -0
  43. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/loop-over-quoted-command-strings.sh +1 -0
  44. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-note-list-output-json.sh +1 -0
  45. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-note-list.sh +1 -0
  46. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-update-then-view.sh +3 -0
  47. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-view-bare.sh +1 -0
  48. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-view-grep-pipeline.sh +2 -0
  49. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-view-output-equals-json.sh +1 -0
  50. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-view-output-json.sh +1 -0
  51. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/mr-view-output-short-flag.sh +1 -0
  52. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/printf-mentions-command.sh +1 -0
  53. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/python-heredoc-triple-quoted-mention.sh +15 -0
  54. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/allow/ripgrep-pattern-args.sh +1 -0
  55. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-absolute-binary-path.sh +1 -0
  56. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-delete-note-nested-api.sh +1 -0
  57. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-cd-and-prefix.sh +1 -0
  58. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-export-host-loop.sh +8 -0
  59. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-for-loop.sh +1 -0
  60. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-heredoc-then-call.sh +8 -0
  61. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-hostname-python-consumer.sh +9 -0
  62. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-line-continuation.sh +2 -0
  63. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-method-get-field.sh +1 -0
  64. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-output-ndjson.sh +1 -0
  65. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-paginate-before-path.sh +3 -0
  66. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-paginate.sh +1 -0
  67. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-per-page-query.sh +1 -0
  68. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-quoted-paginate-jq.sh +1 -0
  69. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-redirect-to-file.sh +1 -0
  70. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-repo-flag-first.sh +1 -0
  71. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-discussions-var-capture.sh +1 -0
  72. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-edit-note-put-heredoc.sh +7 -0
  73. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-new-diff-note-input-stdin.sh +1 -0
  74. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-new-diff-note-position-fields.sh +6 -0
  75. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-note-award-emoji.sh +1 -0
  76. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-note-by-id-or-retry.sh +1 -0
  77. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-notes-after-allowed-mr-view.sh +1 -0
  78. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-notes-query-sort.sh +1 -0
  79. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-notes-timeout-prefix.sh +1 -0
  80. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-reply-method-post-after-path.sh +6 -0
  81. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-reply-post-body-from-file.sh +1 -0
  82. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-reply-post-heredoc-body.sh +8 -0
  83. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-resolve-loop.sh +1 -0
  84. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-resolve-put-field.sh +1 -0
  85. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/api-single-discussion-by-id.sh +1 -0
  86. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/heredoc-then-and-chain-to-blocked.sh +3 -0
  87. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/heredoc-then-pipe-to-blocked.sh +3 -0
  88. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-custom-heredoc-delimiter.sh +8 -0
  89. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-help.sh +1 -0
  90. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-heredoc-body.sh +7 -0
  91. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-inline-after-mr-update.sh +1 -0
  92. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-long-message-flag.sh +1 -0
  93. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-repo-before-message.sh +1 -0
  94. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-second-in-pipeline.sh +1 -0
  95. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-note-then-close.sh +1 -0
  96. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-bare.sh +1 -0
  97. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-loop.sh +1 -0
  98. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-no-iid.sh +1 -0
  99. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-page-flag.sh +1 -0
  100. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-repo-flag.sh +1 -0
  101. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-short-cluster.sh +1 -0
  102. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-short-flag.sh +1 -0
  103. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/wrapper-bash-c.sh +1 -0
  104. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/block/wrapper-sudo.sh +1 -0
  105. bash_classify-0.11.0/tests/fixtures/match/glab-discussion/rules.yaml +17 -0
  106. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-job-metadata-without-trace.sh +1 -0
  107. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-job-metadata-xargs.sh +1 -0
  108. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-merge-request-itself.sh +1 -0
  109. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-merge-request-pipelines-list-per-page.sh +5 -0
  110. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-merge-request-pipelines-list.sh +1 -0
  111. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-pipelines-list-bash-c.sh +1 -0
  112. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-pipelines-list-ref.sh +1 -0
  113. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/api-pipelines-list-single-quoted.sh +1 -0
  114. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/ci-lint-local-file.sh +1 -0
  115. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/ci-list-per-page.sh +1 -0
  116. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/ci-list.sh +1 -0
  117. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/ci-status-compact.sh +1 -0
  118. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/ci-status-pipeline-id.sh +1 -0
  119. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/comment-only-mention.sh +2 -0
  120. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/echo-mentions-ci-command.sh +1 -0
  121. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/git-commit-heredoc-message.sh +9 -0
  122. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/git-commit-inline-message.sh +1 -0
  123. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/glab-discussion-read.sh +1 -0
  124. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/glab-pipeline-inspect.sh +1 -0
  125. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/grep-pattern-mentions-ci.sh +1 -0
  126. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/heredoc-brief-mentions-ci-commands.sh +16 -0
  127. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/heredoc-skill-mentions-ci.sh +10 -0
  128. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/loop-over-quoted-command-strings.sh +1 -0
  129. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/printf-mentions-ci-command.sh +1 -0
  130. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/allow/ripgrep-pattern-args.sh +1 -0
  131. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-continuation-lines.sh +5 -0
  132. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-input-and-header.sh +1 -0
  133. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-method-post-field.sh +1 -0
  134. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-raw-field-cat.sh +1 -0
  135. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-after-allowed-metadata.sh +1 -0
  136. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-env-assignment.sh +1 -0
  137. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-redirect-to-file.sh +1 -0
  138. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-retry-loop.sh +1 -0
  139. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-xargs.sh +1 -0
  140. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-detail.sh +1 -0
  141. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-env-assignment.sh +1 -0
  142. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-hostname-after-path.sh +1 -0
  143. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-comment-and-loop.sh +5 -0
  144. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-list.sh +1 -0
  145. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-nested-loop-timeout.sh +6 -0
  146. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-paginate-ndjson.sh +2 -0
  147. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-python-consumer.sh +6 -0
  148. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-scope-failed-loop.sh +1 -0
  149. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-status-poll-loop.sh +6 -0
  150. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-test-report.sh +3 -0
  151. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-after-allowed-status.sh +2 -0
  152. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-branch-in-echo-chain.sh +3 -0
  153. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-branch-ref-sleep-prefix.sh +1 -0
  154. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-json-jq-sort-uniq.sh +1 -0
  155. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-then-ci-trace.sh +4 -0
  156. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-var-capture-poll-loop.sh +9 -0
  157. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-get-with-job-details-repo.sh +1 -0
  158. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-trace-job-name-with-pipeline.sh +1 -0
  159. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-trace-pipeline-id-before-job-name.sh +1 -0
  160. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-trace-redirect-to-file.sh +1 -0
  161. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-trace-repo-before-job-id.sh +1 -0
  162. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-view-help.sh +1 -0
  163. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-view-job-web-or-api.sh +1 -0
  164. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/ci-view-pipeline-id.sh +1 -0
  165. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/heredoc-then-pipe-to-blocked.sh +3 -0
  166. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/pipe-get-alias.sh +1 -0
  167. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/pipeline-view-alias.sh +1 -0
  168. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/wrapper-bash-c.sh +1 -0
  169. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/block/wrapper-sudo.sh +1 -0
  170. bash_classify-0.11.0/tests/fixtures/match/glab-pipeline/rules.yaml +26 -0
  171. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_classifier.py +130 -1
  172. bash_classify-0.11.0/tests/test_cli.py +255 -0
  173. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_cli_serialization.py +64 -0
  174. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_database.py +67 -0
  175. bash_classify-0.11.0/tests/test_match_fixtures.py +176 -0
  176. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_matcher.py +285 -0
  177. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_parser.py +98 -0
  178. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_real_world.py +166 -0
  179. bash_classify-0.11.0/tests/test_rules.py +443 -0
  180. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_schema.py +19 -0
  181. bash_classify-0.9.1/src/bash_classify/cli.py +0 -149
  182. bash_classify-0.9.1/tests/test_cli.py +0 -82
  183. {bash_classify-0.9.1 → bash_classify-0.11.0}/.claude/rules/command-database.md +0 -0
  184. {bash_classify-0.9.1 → bash_classify-0.11.0}/.editorconfig +0 -0
  185. {bash_classify-0.9.1 → bash_classify-0.11.0}/.github/workflows/ci.yml +0 -0
  186. {bash_classify-0.9.1 → bash_classify-0.11.0}/.github/workflows/publish.yml +0 -0
  187. {bash_classify-0.9.1 → bash_classify-0.11.0}/.gitignore +0 -0
  188. {bash_classify-0.9.1 → bash_classify-0.11.0}/.python-version +0 -0
  189. {bash_classify-0.9.1 → bash_classify-0.11.0}/CLAUDE.md +0 -0
  190. {bash_classify-0.9.1 → bash_classify-0.11.0}/LICENSE +0 -0
  191. {bash_classify-0.9.1 → bash_classify-0.11.0}/coding-agent-plugins/claude-code/hooks/hook-permission-request.sh +0 -0
  192. {bash_classify-0.9.1 → bash_classify-0.11.0}/coding-agent-plugins/claude-code/hooks/hook-pre-tool-use.sh +0 -0
  193. {bash_classify-0.9.1 → bash_classify-0.11.0}/coding-agent-plugins/claude-code/hooks/hooks.json +0 -0
  194. {bash_classify-0.9.1 → bash_classify-0.11.0}/pyproject.toml +0 -0
  195. {bash_classify-0.9.1 → bash_classify-0.11.0}/ruff.toml +0 -0
  196. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/__main__.py +0 -0
  197. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/agent-browser.yaml +0 -0
  198. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/apt.yaml +0 -0
  199. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/awk.yaml +0 -0
  200. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/basename.yaml +0 -0
  201. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/bash-classify.yaml +0 -0
  202. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/bash.yaml +0 -0
  203. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/black.yaml +0 -0
  204. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/brew.yaml +0 -0
  205. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/builtin.yaml +0 -0
  206. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/cargo.yaml +0 -0
  207. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/cat.yaml +0 -0
  208. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/chgrp.yaml +0 -0
  209. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/chmod.yaml +0 -0
  210. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/chown.yaml +0 -0
  211. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/column.yaml +0 -0
  212. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/comm.yaml +0 -0
  213. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/command.yaml +0 -0
  214. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/cp.yaml +0 -0
  215. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/curl.yaml +0 -0
  216. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/cut.yaml +0 -0
  217. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/date.yaml +0 -0
  218. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/df.yaml +0 -0
  219. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/diff.yaml +0 -0
  220. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/dig.yaml +0 -0
  221. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/dirname.yaml +0 -0
  222. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/docker.yaml +0 -0
  223. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/du.yaml +0 -0
  224. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/echo.yaml +0 -0
  225. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/env.yaml +0 -0
  226. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/eslint.yaml +0 -0
  227. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/expand.yaml +0 -0
  228. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/false.yaml +0 -0
  229. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/file.yaml +0 -0
  230. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/find.yaml +0 -0
  231. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/fmt.yaml +0 -0
  232. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/fold.yaml +0 -0
  233. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/free.yaml +0 -0
  234. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gawk.yaml +0 -0
  235. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gcloud.yaml +0 -0
  236. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gh.yaml +0 -0
  237. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/git.yaml +0 -0
  238. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/glab-discussion.yaml +0 -0
  239. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/go.yaml +0 -0
  240. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gog.yaml +0 -0
  241. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gradle.yaml +0 -0
  242. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gradlew.yaml +0 -0
  243. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/grep.yaml +0 -0
  244. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/groups.yaml +0 -0
  245. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gunzip.yaml +0 -0
  246. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gws.yaml +0 -0
  247. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/gzip.yaml +0 -0
  248. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/hadolint.yaml +0 -0
  249. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/head.yaml +0 -0
  250. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/helm.yaml +0 -0
  251. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/hostname.yaml +0 -0
  252. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/id.yaml +0 -0
  253. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/install.yaml +0 -0
  254. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/isort.yaml +0 -0
  255. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/java.yaml +0 -0
  256. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/javac.yaml +0 -0
  257. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/join.yaml +0 -0
  258. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/journalctl.yaml +0 -0
  259. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/jq.yaml +0 -0
  260. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/kill.yaml +0 -0
  261. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/killall.yaml +0 -0
  262. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/kubectl.yaml +0 -0
  263. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/less.yaml +0 -0
  264. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ln.yaml +0 -0
  265. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ls.yaml +0 -0
  266. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/make.yaml +0 -0
  267. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/md5sum.yaml +0 -0
  268. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/mise.yaml +0 -0
  269. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/mkdir.yaml +0 -0
  270. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/more.yaml +0 -0
  271. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/mv.yaml +0 -0
  272. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/mvn.yaml +0 -0
  273. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/mvnw.yaml +0 -0
  274. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/mypy.yaml +0 -0
  275. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/netstat.yaml +0 -0
  276. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/nice.yaml +0 -0
  277. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/nl.yaml +0 -0
  278. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/node.yaml +0 -0
  279. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/nohup.yaml +0 -0
  280. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/npm.yaml +0 -0
  281. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/npx.yaml +0 -0
  282. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/nslookup.yaml +0 -0
  283. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/paste.yaml +0 -0
  284. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ping.yaml +0 -0
  285. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pip.yaml +0 -0
  286. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pip3.yaml +0 -0
  287. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pipx.yaml +0 -0
  288. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pkill.yaml +0 -0
  289. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pnpm.yaml +0 -0
  290. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/poetry.yaml +0 -0
  291. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/prettier.yaml +0 -0
  292. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/printenv.yaml +0 -0
  293. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/printf.yaml +0 -0
  294. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ps.yaml +0 -0
  295. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pup.yaml +0 -0
  296. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pwd.yaml +0 -0
  297. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/pytest.yaml +0 -0
  298. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/python.yaml +0 -0
  299. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/python3.yaml +0 -0
  300. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/read.yaml +0 -0
  301. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/readlink.yaml +0 -0
  302. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/realpath.yaml +0 -0
  303. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/rev.yaml +0 -0
  304. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/rg.yaml +0 -0
  305. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/rm.yaml +0 -0
  306. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/rsync.yaml +0 -0
  307. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/rtk.yaml +0 -0
  308. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ruff.yaml +0 -0
  309. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/scp.yaml +0 -0
  310. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sed.yaml +0 -0
  311. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/seq.yaml +0 -0
  312. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sh.yaml +0 -0
  313. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sha1sum.yaml +0 -0
  314. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sha256sum.yaml +0 -0
  315. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/shellcheck.yaml +0 -0
  316. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/slack.yaml +0 -0
  317. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sleep.yaml +0 -0
  318. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/snip.yaml +0 -0
  319. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sort.yaml +0 -0
  320. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ss.yaml +0 -0
  321. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/ssh.yaml +0 -0
  322. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/stat.yaml +0 -0
  323. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/stdbuf.yaml +0 -0
  324. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/strace.yaml +0 -0
  325. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/strings.yaml +0 -0
  326. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/sudo.yaml +0 -0
  327. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/systemctl.yaml +0 -0
  328. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/tac.yaml +0 -0
  329. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/tail.yaml +0 -0
  330. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/tar.yaml +0 -0
  331. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/tee.yaml +0 -0
  332. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/terraform.yaml +0 -0
  333. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/test.yaml +0 -0
  334. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/time.yaml +0 -0
  335. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/timeout.yaml +0 -0
  336. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/top.yaml +0 -0
  337. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/touch.yaml +0 -0
  338. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/tr.yaml +0 -0
  339. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/traceroute.yaml +0 -0
  340. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/true.yaml +0 -0
  341. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/tsc.yaml +0 -0
  342. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/type.yaml +0 -0
  343. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/uname.yaml +0 -0
  344. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/unexpand.yaml +0 -0
  345. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/uniq.yaml +0 -0
  346. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/unzip.yaml +0 -0
  347. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/uptime.yaml +0 -0
  348. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/uv.yaml +0 -0
  349. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/wait.yaml +0 -0
  350. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/wc.yaml +0 -0
  351. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/wget.yaml +0 -0
  352. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/whereis.yaml +0 -0
  353. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/which.yaml +0 -0
  354. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/whoami.yaml +0 -0
  355. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/xargs.yaml +0 -0
  356. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/xmllint.yaml +0 -0
  357. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/yarn.yaml +0 -0
  358. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/yes.yaml +0 -0
  359. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/yq.yaml +0 -0
  360. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/zip.yaml +0 -0
  361. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/commands/zsh.yaml +0 -0
  362. {bash_classify-0.9.1 → bash_classify-0.11.0}/src/bash_classify/py.typed +0 -0
  363. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/__init__.py +0 -0
  364. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/conftest.py +0 -0
  365. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_models.py +0 -0
  366. {bash_classify-0.9.1 → bash_classify-0.11.0}/tests/test_smoke.py +0 -0
  367. {bash_classify-0.9.1 → bash_classify-0.11.0}/uv.lock +0 -0
@@ -8,7 +8,7 @@
8
8
  {
9
9
  "name": "bash-classify-hook",
10
10
  "description": "Auto-allows low-risk bash commands in Claude Code using bash-classify",
11
- "version": "0.9.1",
11
+ "version": "0.11.0",
12
12
  "author": {
13
13
  "name": "Filip Procházka",
14
14
  "url": "https://github.com/fprochazka"
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bash-classify
3
- Version: 0.9.1
3
+ Version: 0.11.0
4
4
  Summary: Classify bash commands as READONLY, WRITE, DANGEROUS, or UNKNOWN
5
5
  Project-URL: Homepage, https://github.com/fprochazka/bash-classify
6
6
  Project-URL: Repository, https://github.com/fprochazka/bash-classify
@@ -61,6 +61,66 @@ $ echo 'find . -name "*.pyc" -delete' | bash-classify | jq '.classification'
61
61
  "DANGEROUS"
62
62
  ```
63
63
 
64
+ ## Matching command shapes
65
+
66
+ Classification answers "how risky is this?". A deny hook usually has a narrower question:
67
+ "does this expression run command shape X?" `bash-classify match` answers that one. It
68
+ parses the expression, walks every invocation at every depth, and reports which of the
69
+ shapes in a rules file were actually invoked — so a heredoc body, an `echo` string, a
70
+ `#` comment or a `grep` pattern that merely *names* the command does not count.
71
+
72
+ ```yaml
73
+ # blocked-commands.yaml
74
+ rules:
75
+ - name: mr-discussions-api
76
+ command: [glab, api]
77
+ any_arg_matches: 'merge_requests/[^/?]+/(discussions|notes)(/|\?|$)'
78
+
79
+ - name: mr-view-comments
80
+ command: [glab, mr, view]
81
+ any_option: [--comments, -c]
82
+
83
+ - name: mr-note
84
+ command: [glab, mr, note]
85
+ except: [[glab, mr, note, list]]
86
+ ```
87
+
88
+ ```bash
89
+ $ echo 'sudo glab mr note 42 -m hi' | bash-classify match --rules blocked-commands.yaml
90
+ {
91
+ "matches": [
92
+ {
93
+ "rule": "mr-note",
94
+ "command": ["glab", "mr", "note"],
95
+ "argv": ["glab", "mr", "note", "42", "-m", "hi"],
96
+ "via": ["sudo"]
97
+ }
98
+ ],
99
+ "parse_warnings": []
100
+ }
101
+ ```
102
+
103
+ Within one rule every condition given must hold; rules are independent of each other, and
104
+ one invocation can match several. `command` is a prefix match against the *resolved*
105
+ command path, so `/usr/bin/glab --repo x mr note` still resolves to `glab mr note`.
106
+ `any_option` looks at the options actually present, with values stripped and declared
107
+ clusters expanded (`-wc` carries `-c`). `any_arg_matches` is a Python `re.search` over
108
+ every argument token — a pattern written for `grep -E` needs `\S` rather than
109
+ `[^[:space:]]`. `via` lists the enclosing wrappers, outermost first.
110
+
111
+ **Two things a caller has to check.** First, `parse_warnings` is always present: when it
112
+ is non-empty the expression could not be fully parsed, so an empty `matches` proves nothing
113
+ and the caller should fall back to whatever it did before. Second, check that the output
114
+ actually has a `matches` key. A `bash-classify` older than this mode does not reject the
115
+ unknown `match` argument — it ignores it, classifies stdin and exits 0, so the caller gets
116
+ a normal classification JSON with no `matches` key. Treat a non-zero exit, unparseable
117
+ output, or output without a `matches` key as "cannot answer" and fall back; never read a
118
+ missing `matches` as "nothing matched".
119
+
120
+ Exit codes are `0` whether or not anything matched, `1` for empty input or no input within
121
+ 5 seconds, and `2` for bad arguments, an unreadable or invalid rules file, or an internal
122
+ error.
123
+
64
124
  ## Claude Code plugin
65
125
 
66
126
  The repo includes a Claude Code plugin that auto-allows low-risk bash commands via a `PreToolUse` hook.
@@ -120,6 +180,7 @@ Risk defaults are derived from classification (`READONLY`→LOW, `LOCAL_EFFECTS`
120
180
  - **Tree-sitter parsing** -- bash expressions are parsed into an AST for accurate command extraction, handling pipes, subshells, and command substitution
121
181
  - **YAML command database** -- each command has classification rules with subcommand and option matching
122
182
  - **Subcommand matching** -- `kubectl get` and `kubectl delete` can have different classifications
183
+ - **Subcommand aliases** -- a subcommand can declare other names for itself, so `glab pipeline view` resolves to `glab ci view` and any rule written for the canonical name catches the aliased spelling
123
184
  - **Multi-goal build tools** -- `subcommand_mode: match_all` handles commands like `mvn clean install` and `gradle clean build test` where multiple goals can be combined in any order
124
185
  - **Delegation for wrappers** -- commands like `xargs`, `sudo`, and `env` delegate classification to the inner command
125
186
  - **File path detection** -- redirect operators (`>`, `>>`, `<`) are parsed into `write_paths`/`read_paths` in the output; writes to `/tmp` and `/var/tmp` stay at LOW risk
@@ -134,6 +195,41 @@ print(result.classification) # Classification.READONLY
134
195
  print(result.risk) # Risk.LOW
135
196
  ```
136
197
 
198
+ Each command result carries the options it actually uses and the positionals left after parsing. Option values
199
+ are stripped, so `--key=value` shows up as `--key` and `-fvalue` as `-f`:
200
+
201
+ ```python
202
+ command = classify_expression("git commit --amend -m 'wip'").commands[0]
203
+ print(command.command) # ['git', 'commit']
204
+ print(command.options) # ['--amend', '-m']
205
+ print(command.positionals) # []
206
+ ```
207
+
208
+ `iter_invocations` walks every invocation in an expression depth-first — top-level commands and, recursively,
209
+ the inner commands that wrappers such as `sudo`, `timeout` or `bash -c` delegate to. It yields each invocation
210
+ with its `via` chain: the enclosing wrappers, outermost first, empty at the top level.
211
+
212
+ ```python
213
+ from bash_classify import classify_expression, iter_invocations
214
+
215
+ for invocation, via in iter_invocations(classify_expression("sudo timeout 5 ls")):
216
+ print(via, invocation.command)
217
+ # [] ['sudo']
218
+ # ['sudo'] ['timeout']
219
+ # ['sudo', 'timeout'] ['ls']
220
+ ```
221
+
222
+ `load_rules` and `match_expression` are the same thing from Python:
223
+
224
+ ```python
225
+ from bash_classify import load_rules, match_expression
226
+
227
+ rules = load_rules("blocked-commands.yaml")
228
+ result = match_expression('cat > brief.md <<"EOF"\nmentions glab mr note\nEOF', rules)
229
+ print(result.matches) # [] - the heredoc body is data, not a command
230
+ print(result.parse_warnings) # []
231
+ ```
232
+
137
233
  See [SPEC.md](SPEC.md) for the full specification.
138
234
 
139
235
  ## Development
@@ -32,6 +32,66 @@ $ echo 'find . -name "*.pyc" -delete' | bash-classify | jq '.classification'
32
32
  "DANGEROUS"
33
33
  ```
34
34
 
35
+ ## Matching command shapes
36
+
37
+ Classification answers "how risky is this?". A deny hook usually has a narrower question:
38
+ "does this expression run command shape X?" `bash-classify match` answers that one. It
39
+ parses the expression, walks every invocation at every depth, and reports which of the
40
+ shapes in a rules file were actually invoked — so a heredoc body, an `echo` string, a
41
+ `#` comment or a `grep` pattern that merely *names* the command does not count.
42
+
43
+ ```yaml
44
+ # blocked-commands.yaml
45
+ rules:
46
+ - name: mr-discussions-api
47
+ command: [glab, api]
48
+ any_arg_matches: 'merge_requests/[^/?]+/(discussions|notes)(/|\?|$)'
49
+
50
+ - name: mr-view-comments
51
+ command: [glab, mr, view]
52
+ any_option: [--comments, -c]
53
+
54
+ - name: mr-note
55
+ command: [glab, mr, note]
56
+ except: [[glab, mr, note, list]]
57
+ ```
58
+
59
+ ```bash
60
+ $ echo 'sudo glab mr note 42 -m hi' | bash-classify match --rules blocked-commands.yaml
61
+ {
62
+ "matches": [
63
+ {
64
+ "rule": "mr-note",
65
+ "command": ["glab", "mr", "note"],
66
+ "argv": ["glab", "mr", "note", "42", "-m", "hi"],
67
+ "via": ["sudo"]
68
+ }
69
+ ],
70
+ "parse_warnings": []
71
+ }
72
+ ```
73
+
74
+ Within one rule every condition given must hold; rules are independent of each other, and
75
+ one invocation can match several. `command` is a prefix match against the *resolved*
76
+ command path, so `/usr/bin/glab --repo x mr note` still resolves to `glab mr note`.
77
+ `any_option` looks at the options actually present, with values stripped and declared
78
+ clusters expanded (`-wc` carries `-c`). `any_arg_matches` is a Python `re.search` over
79
+ every argument token — a pattern written for `grep -E` needs `\S` rather than
80
+ `[^[:space:]]`. `via` lists the enclosing wrappers, outermost first.
81
+
82
+ **Two things a caller has to check.** First, `parse_warnings` is always present: when it
83
+ is non-empty the expression could not be fully parsed, so an empty `matches` proves nothing
84
+ and the caller should fall back to whatever it did before. Second, check that the output
85
+ actually has a `matches` key. A `bash-classify` older than this mode does not reject the
86
+ unknown `match` argument — it ignores it, classifies stdin and exits 0, so the caller gets
87
+ a normal classification JSON with no `matches` key. Treat a non-zero exit, unparseable
88
+ output, or output without a `matches` key as "cannot answer" and fall back; never read a
89
+ missing `matches` as "nothing matched".
90
+
91
+ Exit codes are `0` whether or not anything matched, `1` for empty input or no input within
92
+ 5 seconds, and `2` for bad arguments, an unreadable or invalid rules file, or an internal
93
+ error.
94
+
35
95
  ## Claude Code plugin
36
96
 
37
97
  The repo includes a Claude Code plugin that auto-allows low-risk bash commands via a `PreToolUse` hook.
@@ -91,6 +151,7 @@ Risk defaults are derived from classification (`READONLY`→LOW, `LOCAL_EFFECTS`
91
151
  - **Tree-sitter parsing** -- bash expressions are parsed into an AST for accurate command extraction, handling pipes, subshells, and command substitution
92
152
  - **YAML command database** -- each command has classification rules with subcommand and option matching
93
153
  - **Subcommand matching** -- `kubectl get` and `kubectl delete` can have different classifications
154
+ - **Subcommand aliases** -- a subcommand can declare other names for itself, so `glab pipeline view` resolves to `glab ci view` and any rule written for the canonical name catches the aliased spelling
94
155
  - **Multi-goal build tools** -- `subcommand_mode: match_all` handles commands like `mvn clean install` and `gradle clean build test` where multiple goals can be combined in any order
95
156
  - **Delegation for wrappers** -- commands like `xargs`, `sudo`, and `env` delegate classification to the inner command
96
157
  - **File path detection** -- redirect operators (`>`, `>>`, `<`) are parsed into `write_paths`/`read_paths` in the output; writes to `/tmp` and `/var/tmp` stay at LOW risk
@@ -105,6 +166,41 @@ print(result.classification) # Classification.READONLY
105
166
  print(result.risk) # Risk.LOW
106
167
  ```
107
168
 
169
+ Each command result carries the options it actually uses and the positionals left after parsing. Option values
170
+ are stripped, so `--key=value` shows up as `--key` and `-fvalue` as `-f`:
171
+
172
+ ```python
173
+ command = classify_expression("git commit --amend -m 'wip'").commands[0]
174
+ print(command.command) # ['git', 'commit']
175
+ print(command.options) # ['--amend', '-m']
176
+ print(command.positionals) # []
177
+ ```
178
+
179
+ `iter_invocations` walks every invocation in an expression depth-first — top-level commands and, recursively,
180
+ the inner commands that wrappers such as `sudo`, `timeout` or `bash -c` delegate to. It yields each invocation
181
+ with its `via` chain: the enclosing wrappers, outermost first, empty at the top level.
182
+
183
+ ```python
184
+ from bash_classify import classify_expression, iter_invocations
185
+
186
+ for invocation, via in iter_invocations(classify_expression("sudo timeout 5 ls")):
187
+ print(via, invocation.command)
188
+ # [] ['sudo']
189
+ # ['sudo'] ['timeout']
190
+ # ['sudo', 'timeout'] ['ls']
191
+ ```
192
+
193
+ `load_rules` and `match_expression` are the same thing from Python:
194
+
195
+ ```python
196
+ from bash_classify import load_rules, match_expression
197
+
198
+ rules = load_rules("blocked-commands.yaml")
199
+ result = match_expression('cat > brief.md <<"EOF"\nmentions glab mr note\nEOF', rules)
200
+ print(result.matches) # [] - the heredoc body is data, not a command
201
+ print(result.parse_warnings) # []
202
+ ```
203
+
108
204
  See [SPEC.md](SPEC.md) for the full specification.
109
205
 
110
206
  ## Development
@@ -186,12 +186,90 @@ $ echo 'sh -c "ls /tmp | grep log"' | bash-classify
186
186
  }
187
187
  ```
188
188
 
189
+ ### match mode
190
+
191
+ ```
192
+ $ echo 'sudo glab mr note 42 -m hi' | bash-classify match --rules blocked-commands.yaml
193
+ ```
194
+
195
+ ```json
196
+ {
197
+ "matches": [
198
+ {
199
+ "rule": "mr-note",
200
+ "command": ["glab", "mr", "note"],
201
+ "argv": ["glab", "mr", "note", "42", "-m", "hi"],
202
+ "via": ["sudo"]
203
+ }
204
+ ],
205
+ "parse_warnings": []
206
+ }
207
+ ```
208
+
209
+ `match` answers a narrower question than classification: which of the command shapes
210
+ declared in a rules file does this expression actually invoke? Shell text that only names
211
+ a command — a heredoc body, an `echo` or `printf` argument, a `#` comment, a
212
+ `git commit -m` message, a `grep` pattern — is not an invocation and does not match.
213
+
214
+ #### The rules file
215
+
216
+ ```yaml
217
+ rules:
218
+ - name: mr-note # required, unique within the file
219
+ command: [glab, mr, note] # required; prefix match on the resolved path
220
+ except: [[glab, mr, note, list]] # optional; prefix paths excluded from the match
221
+ any_option: [--comments, -c] # optional; at least one of these is present
222
+ any_arg_matches: 'merge_requests/[^/?]+/notes' # optional; Python re.search
223
+ ```
224
+
225
+ Within one rule every condition that is given must hold (AND). Rules are independent of
226
+ each other (OR), and one invocation may match several rules; each match is reported.
227
+
228
+ | Field | Meaning |
229
+ |---|---|
230
+ | `name` | Reported back on every match; must be unique within the file |
231
+ | `command` | Prefix match against `command` — the *resolved* path, so `/usr/bin/glab --repo x mr note` matches `[glab, mr, note]` |
232
+ | `except` | Prefix paths excluded from the match, so `[glab, mr, note]` can spare `glab mr note list` |
233
+ | `any_option` | At least one of these must appear in `options`: as typed, values stripped, `--key=value` counting as `--key` and a declared cluster `-wc` counting as `-w` and `-c`. Tokens after `--` are positionals and never match |
234
+ | `any_arg_matches` | Python `re.search` applied to every argument token: `argv[1:]` with the resolved subcommand words removed once each. Option flags, option values and positionals are all included; `argv[0]` and the subcommand words are not. A pattern written for `grep -E` needs `\S` instead of `[^[:space:]]` |
235
+
236
+ A rule matches an invocation at any depth: top-level commands and, recursively, the inner
237
+ commands that wrappers delegate to (`sudo`, `timeout`, `xargs`, `bash -c`, `find -exec`,
238
+ `eval`, `exec`). Commands inside `$(...)` are top-level invocations in their own right.
239
+ `via` lists the enclosing wrappers, outermost first, each as its resolved path joined by
240
+ spaces; it is empty at the top level.
241
+
242
+ `parse_warnings` is **always** present in match output, even when empty, because a caller
243
+ has to check it: a non-empty list means the expression could not be fully parsed, so an
244
+ empty `matches` list proves nothing.
245
+
246
+ #### Exit codes for `match`
247
+
248
+ - `0` — ran successfully, whether or not anything matched
249
+ - `1` — empty input, or no input on stdin within 5 seconds
250
+ - `2` — bad arguments, a missing or invalid rules file (the message names the file and the
251
+ offending rule), or an internal error
252
+
253
+ A caller cannot rely on the exit code alone to detect an old binary. Versions before this
254
+ mode parsed `sys.argv` by hand and ignored anything they did not recognize, so
255
+ `bash-classify match --rules f.yaml` on such a binary classifies stdin and exits `0`,
256
+ printing an ordinary classification document with no `matches` key. The check that works is
257
+ the output shape: require a `matches` key, and treat a non-zero exit, unparseable output or
258
+ a missing `matches` key alike as "cannot answer" — fall back rather than reading it as
259
+ "nothing matched".
260
+
189
261
  ### Exit codes
190
262
 
191
263
  - `0` — successfully classified
192
- - `1` — parse error (invalid bash syntax)
264
+ - `1` — empty input, or no input arrived on stdin within 5 seconds
193
265
  - `2` — internal error
194
266
 
267
+ A bash syntax error does **not** change the exit code. The parse is best-effort: the tool still exits `0`, records the
268
+ problem in `parse_warnings`, and returns whatever commands it could extract from the partial parse. Consumers rely on
269
+ this — check `parse_warnings` to decide whether the `commands` list can be trusted, not the exit code.
270
+
271
+ `match` mode has its own, slightly different set — see [Exit codes for `match`](#exit-codes-for-match) above.
272
+
195
273
  ### Classification levels
196
274
 
197
275
  | Level | Meaning |
@@ -299,7 +377,7 @@ The parser walks the CST and extracts a list of `CommandInvocation` objects, eac
299
377
  | Command substitution `$(cmd)` | Inner command extracted recursively |
300
378
  | Process substitution `<(cmd)` / `>(cmd)` | Inner command extracted recursively |
301
379
  | Subshells `(cmd)` | Inner command extracted recursively |
302
- | Heredocs `<<EOF` | Content captured but not parsed as commands |
380
+ | Heredocs `<<EOF` | Body is dropped entirely — it is never parsed as commands and never appears in any `argv`; only the operator and the delimiter are recorded as a redirect. Commands written *after* the opener on the same line (`cat <<EOF \| wc -l`, `cat <<EOF && rm x`) are extracted like any other pipeline or list member — tree-sitter nests them inside the heredoc node, but they are real commands. A `;` or `&` in that position is a tree-sitter parse error: the follower cannot be recovered and a `parse_warnings` entry says so |
303
381
  | Variable assignments `X=1 cmd` | Prefix assignments stripped, `cmd` extracted |
304
382
  | Backgrounding `cmd &` | Classified as EXTERNAL_EFFECTS (side effect: background process) |
305
383
 
@@ -307,18 +385,20 @@ The parser walks the CST and extracts a list of `CommandInvocation` objects, eac
307
385
 
308
386
  | Construct | Reason |
309
387
  |---|---|
310
- | `eval "..."` | Arbitrary code execution, unparseable |
311
- | `sh -c "..."` / `bash -c "..."` | Nested shell, unparseable |
312
- | `source file` / `. file` | Executes external script |
388
+ | `eval "..."` | Arbitrary code execution. The argument is still parsed and the inner command exposed, but a `min_classification: DANGEROUS` floor keeps both the inner and `eval` itself DANGEROUS |
389
+ | `exec cmd` | Replaces the shell with `cmd`. The inner command is exposed, with the same DANGEROUS floor |
390
+ | `source file` / `. file` | Executes external script, whose contents are never visible |
313
391
  | Unquoted variable expansion in command position (`$CMD args`) | Command identity unknown at parse time |
314
392
 
393
+ `sh` and `bash` are **not** in this table. Their base classification is DANGEROUS, which is what a bare `sh` or `bash` gets, but `-c` is a `flag_value_is_expression` delegation with no `min_classification`: the expression is parsed, the inner commands are exposed, and the wrapper base is erased, so `sh -c "ls"` classifies READONLY.
394
+
315
395
  ## Command Matching Algorithm
316
396
 
317
397
  This is the core of the tool. Given an `argv` like `["kubectl", "--context=prod", "get", "pods", "-n", "kube-system"]`, the matcher must:
318
398
 
319
399
  1. **Look up the binary** (`kubectl`) in the database
320
400
  2. **Separate global options** from the rest of `argv`, using the database definition of which options are global and whether they take a value
321
- 3. **Match the subcommand chain** from the remaining tokens (e.g. `get` → resolve to `kubectl.get`)
401
+ 3. **Match the subcommand chain** from the remaining tokens (e.g. `get` → resolve to `kubectl.get`), resolving any declared subcommand alias to its canonical name
322
402
  4. **Classify remaining options** against the matched subcommand's option definitions
323
403
  5. **Determine the final classification** based on subcommand default + any option overrides
324
404
 
@@ -371,6 +451,14 @@ The default mode. Subcommands are matched as a greedy chain through nested subco
371
451
 
372
452
  Example: `kubectl rollout status` matches `kubectl` → `rollout` → `status`, walking the tree.
373
453
 
454
+ #### Subcommand aliases
455
+
456
+ A subcommand may declare `aliases`. Every alias is another name for the same definition, so matching a word against a subcommand dictionary tries the canonical names and the aliases alike. What is reported is always the canonical name: `glab pipeline view 1000` yields `command: ["glab", "ci", "view"]` and `matched_rule: "glab.ci.view"`, while `argv` keeps `["glab", "pipeline", "view", "1000"]` — the word that was actually typed. Classification, options and `strict` behaviour are the canonical subcommand's, since it is literally the same definition.
457
+
458
+ This matters to `match` mode: a rule written as `command: [glab, ci, view]` catches the aliased spelling too, without listing it.
459
+
460
+ Aliases live in one namespace with their siblings. An alias that names a sibling subcommand, or that two siblings both claim, is rejected when the database file is loaded — the error names the file and the alias.
461
+
374
462
  #### `match_all`
375
463
 
376
464
  Each positional argument is matched independently against the **same** top-level subcommand dictionary. This is designed for build tools like Maven and Gradle where multiple goals/tasks appear as positional arguments in any order.
@@ -841,6 +929,7 @@ The `delegates_to` field defines how a command (or option like `find -exec`) han
841
929
  | `after_separator` | Everything after the `separator` token forms the inner argv. | `kubectl exec pod -- ls -la` → inner: `["ls", "-la"]` |
842
930
  | `terminated_argv` | Tokens after the flag up to `terminator` form the inner argv. `{}` tokens are stripped (they are `find` placeholders). | `find . -exec rm {} \;` → inner: `["rm"]` |
843
931
  | `flag_value_is_expression` | The value of the specified `flag` is a complete shell expression string, parsed from scratch through the bash parser (not just tokenized as argv). | `sh -c "ls \| grep foo"` → inner expression: `ls \| grep foo` (two piped commands) |
932
+ | `args_are_expression` | Every token after the command word is joined with single spaces and the result is parsed as a complete shell expression, the way `eval` concatenates its arguments before running them. Option-looking tokens are included, because a command using this mode has no options of its own. Invocations the parser cannot recover yield no inner command. | `eval "glab mr list"` and `eval glab mr list` → inner: `["glab", "mr", "list"]` |
844
933
 
845
934
  #### Delegation fields
846
935
 
@@ -871,6 +960,14 @@ When command-level delegation resolves at least one inner command (and no option
871
960
  | `subcommand_mode` | `enum` | How subcommands are matched: `hierarchical` (default, chained tree walk) or `match_all` (each positional arg matched independently) |
872
961
  | `delegates_to` | `object` | How this command delegates execution to an inner command (see delegation modes) |
873
962
 
963
+ #### Subcommand level
964
+
965
+ A subcommand entry takes the same fields as the command level, minus `command` and `global_options` — its name is the key it is filed under — plus:
966
+
967
+ | Field | Type | Description |
968
+ |---|---|---|
969
+ | `aliases` | `list[str]` | Alternative names for this subcommand (e.g. `pipe` and `pipeline` for `glab ci`). Each alias resolves to this same definition; the resolved `command` path and `matched_rule` name the canonical subcommand, while `argv` keeps the word that was typed. An alias that collides with a sibling subcommand, or that two siblings both claim, is a load-time error. |
970
+
874
971
  #### Option level
875
972
 
876
973
  | Field | Type | Description |
@@ -909,14 +1006,15 @@ Directories are reported as-is (not resolved) since variable expansion may be in
909
1006
 
910
1007
  ## Special-cased Commands
911
1008
 
912
- Most delegation behavior is expressed in the database via `delegates_to`. Only shell builtins that cannot be modeled as regular commands need hardcoded handling:
1009
+ Most delegation behavior is expressed in the database via `delegates_to`. Only shell builtins that cannot be modeled as regular commands need hardcoded handling — the full set is three short lists in `matcher.py`:
913
1010
 
914
1011
  | Command | Special handling |
915
1012
  |---|---|
916
1013
  | `cd`, `pushd`, `popd` | Directory tracking; classified as `READONLY` |
917
- | `eval` | Always `DANGEROUS` — argument is arbitrary code, no delegation possible |
918
- | `source`, `.` | Always `DANGEROUS` — executes external script |
919
- | `exec` (builtin) | Always `DANGEROUS` — replaces the current process |
1014
+ | `[`, `[[`, `test` | Always `READONLY` — condition evaluation with no side effects |
1015
+ | `source`, `.` | Always `DANGEROUS` — executes an external script whose contents are never visible |
1016
+
1017
+ `eval` and `exec` are **not** in that set. They are ordinary database entries (`commands/eval.yaml`, `commands/exec.yaml`) that delegate: `eval` via `args_are_expression`, `exec` via `rest_are_argv`, each with `min_classification: DANGEROUS`. So their inner command is parsed and reported in `inner_commands`, while the floor keeps both the inner and the wrapper at `DANGEROUS`/`HIGH`.
920
1018
 
921
1019
  Everything else (`sudo`, `env`, `xargs`, `sh -c`, `nice`, `nohup`, `timeout`, `time`, `find -exec`, `kubectl exec --`, etc.) is handled via `delegates_to` in the database — no special code needed.
922
1020
 
@@ -930,7 +1028,30 @@ Python 3.12+. Dependencies:
930
1028
 
931
1029
  ## Output Schema
932
1030
 
933
- The `commands` list is recursive any command entry can contain `inner_commands` when the command delegates execution to another command (via `delegates_to` in the database).
1031
+ This is the schema of the default mode. `match` mode has its own, much smaller one:
1032
+
1033
+ ```json
1034
+ {
1035
+ "matches": [
1036
+ {
1037
+ "rule": "string — the name of the rule that matched",
1038
+ "command": ["string — the resolved command path of the invocation"],
1039
+ "argv": ["string — the full argv of the invocation"],
1040
+ "via": ["string — enclosing wrapper commands, outermost first; empty at top level"]
1041
+ }
1042
+ ],
1043
+ "parse_warnings": ["string — non-fatal issues encountered during parsing"]
1044
+ }
1045
+ ```
1046
+
1047
+ Both keys are always present in match output, `matches` as `[]` when nothing matched and
1048
+ `parse_warnings` as `[]` on a clean parse. As in the default mode, `parse_warnings` covers
1049
+ nested expressions: a syntax error inside `bash -c "..."` or `eval "..."` surfaces here too.
1050
+ A caller must check both: a non-empty `parse_warnings` means an empty `matches` proves
1051
+ nothing, and a *missing* `matches` key means the output came from a binary that predates
1052
+ this mode.
1053
+
1054
+ The `commands` list below is recursive — any command entry can contain `inner_commands` when the command delegates execution to another command (via `delegates_to` in the database).
934
1055
 
935
1056
  ```json
936
1057
  {
@@ -945,6 +1066,8 @@ The `commands` list is recursive — any command entry can contain `inner_comman
945
1066
  "argv": ["string — full original argv for this command"],
946
1067
  "classification": "READONLY | LOCAL_EFFECTS | EXTERNAL_EFFECTS | DANGEROUS | UNKNOWN",
947
1068
  "matched_rule": "string — dotted path in database, or null",
1069
+ "options": ["string — every option present, canonical flag only, no values; always present, empty when none"],
1070
+ "positionals": ["string — non-option tokens left after subcommand and option parsing; always present, empty when none"],
948
1071
  "ignored_options": ["string — global options that were stripped"],
949
1072
  "remaining_options": ["string — options that were not in database"],
950
1073
  "classification_reason": "string — why this classification was chosen",
@@ -959,6 +1082,8 @@ The `commands` list is recursive — any command entry can contain `inner_comman
959
1082
  "argv": ["..."],
960
1083
  "classification": "...",
961
1084
  "matched_rule": "...",
1085
+ "options": ["... — same meaning as on the enclosing command"],
1086
+ "positionals": ["... — same meaning as on the enclosing command"],
962
1087
  "inner_commands": ["... — recursive, can nest further"]
963
1088
  }
964
1089
  ]
@@ -971,10 +1096,37 @@ The `commands` list is recursive — any command entry can contain `inner_comman
971
1096
  "affects_classification": "bool"
972
1097
  }
973
1098
  ],
974
- "parse_warnings": ["string — non-fatal issues encountered during parsing"]
1099
+ "parse_warnings": ["string — non-fatal issues encountered during parsing. A non-empty list means the parse was best-effort and the commands list may be incomplete."]
975
1100
  }
976
1101
  ```
977
1102
 
1103
+ `parse_warnings` covers **nested** expressions as well as the outer one. A syntax error inside
1104
+ `bash -c "..."`, `sh -c`, `zsh -c` or `eval "..."` surfaces here at any wrapper depth
1105
+ (`sudo bash -c '...'`, `find . -exec bash -c '...' \;`), and the warning text names the nested
1106
+ expression it came from, so a caller can tell which part of the command could not be read.
1107
+ Identical warnings are reported once.
1108
+
1109
+ #### `options` and `positionals`
1110
+
1111
+ `options` records which options an invocation actually carries, with option values stripped and each option in
1112
+ its canonical form: `--key=value` contributes `--key`, `-fvalue` contributes `-f`, and a cluster whose every
1113
+ character is declared in the database (`-wc`) contributes each character (`-w`, `-c`). Options the database does
1114
+ not model are included too — present means present. Tokens after `--` are positionals and contribute nothing.
1115
+
1116
+ Two consequences of matching against a database that models only part of a real CLI. In a cluster whose first
1117
+ character is declared but a later one is not (`-wx` with only `-w` declared), the undeclared characters are not
1118
+ reported anywhere — not in `options`, not in `remaining_options` — so a check against `options` can miss such an
1119
+ option but can never falsely fire on one. In the other direction, an option the database does not model but that
1120
+ really takes a value has its value reported as a second option (`-e -foo` yields `-e` and `-foo`, because nothing
1121
+ says `-e` consumes the next token), so `options` errs toward over-reporting for unmodelled options. Declaring the
1122
+ option in the database fixes both.
1123
+
1124
+ `positionals` holds the non-option tokens that remain once the subcommand chain and the options have been
1125
+ consumed, including `--` itself and everything after it.
1126
+
1127
+ Both are always serialized, as `[]` when the command has none. Results that never reach option parsing — shell
1128
+ builtins, an unknown binary, an empty argv — carry `null` internally and serialize as `[]`.
1129
+
978
1130
  ### Recursive classification rules
979
1131
 
980
1132
  1. A command's `classification` is the **maximum** of:
@@ -990,14 +1142,13 @@ The `commands` list is recursive — any command entry can contain `inner_comman
990
1142
 
991
1143
  ## Non-goals
992
1144
 
993
- - **Not a sandbox.** This is a classifier, not an enforcer. It does not execute or block anything.
1145
+ - **Not a sandbox.** This is a classifier, not an enforcer. It does not execute or block anything. `match` mode is no exception: it reports which declared command shapes an expression invokes, and the caller decides what to do about it.
994
1146
  - **No awk/sed/perl script analysis.** These are classified as a whole command; their embedded programs are opaque. They should simply not be in the READONLY allowlist.
995
1147
  - **No variable resolution.** `$DIR`, `$(cmd)` in command position → UNKNOWN. We classify what we can see statically.
996
- - **No alias/function resolution.** We classify the literal command name as written.
1148
+ - **No shell alias/function resolution.** We classify the literal command name as written.
997
1149
 
998
1150
  ## Future extensions
999
1151
 
1000
- - `--format=decision` — output only the classification string (for use in hooks)
1001
1152
  - `--database=/path` — custom database directory
1002
1153
  - `--explain` — verbose output showing the matching steps
1003
1154
  - Interactive database builder — run `command --help` and generate a skeleton YAML definition
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bash-classify-hook",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Auto-allows low-risk bash commands in Claude Code using bash-classify",
5
5
  "author": {
6
6
  "name": "Filip Prochazka"
@@ -84,6 +84,25 @@ options: # options that affect classification
84
84
  | `subcommands` | map | -- | Nested subcommand definitions (recursive) |
85
85
  | `delegates_to` | object | -- | How the command hands off to an inner command |
86
86
 
87
+ #### Subcommand fields
88
+
89
+ A subcommand takes the same fields as the top level, minus `command`, `description` and `global_options`, plus:
90
+
91
+ | Field | Type | Description |
92
+ |-------|------|-------------|
93
+ | `aliases` | list | Alternative names for this subcommand (e.g. `pipe` and `pipeline` for `glab ci`) |
94
+
95
+ An alias is another name for the same definition, not a copy of it: `glab pipeline view` classifies exactly as `glab ci view` and is reported as `["glab", "ci", "view"]`, while `argv` keeps the word that was typed. Declare aliases only for names the tool itself accepts (`glab ci --help` lists `pipe` and `pipeline`), and never a name a sibling subcommand already uses — that is rejected when the file loads.
96
+
97
+ ```yaml
98
+ subcommands:
99
+ ci:
100
+ aliases: [pipe, pipeline]
101
+ subcommands:
102
+ list:
103
+ classification: READONLY
104
+ ```
105
+
87
106
  #### Option fields
88
107
 
89
108
  | Field | Type | Description |
@@ -330,20 +349,43 @@ options:
330
349
  -c: {takes_value: true}
331
350
  ```
332
351
 
352
+ ### `args_are_expression`
353
+
354
+ Every argument is joined with single spaces and the result is parsed as a complete shell expression. This is what
355
+ `eval` does: it concatenates its arguments and runs the concatenation as shell source, so `eval ls -la` and
356
+ `eval "ls -la"` are the same command. Unlike `flag_value_is_expression`, no flag selects the expression -- every
357
+ token after the binary is part of it, so a command using this mode must not declare options of its own.
358
+
359
+ **eval:** `eval "git push --force"` -- the string `git push --force` is parsed as a full expression, producing one
360
+ inner command.
361
+
362
+ ```yaml
363
+ command: eval
364
+ classification: DANGEROUS
365
+ strict: false
366
+ delegates_to:
367
+ mode: args_are_expression
368
+ min_classification: DANGEROUS # eval stays DANGEROUS even when the inner is READONLY
369
+ ```
370
+
371
+ The `min_classification` floor is not optional here. Command-level delegation erases the wrapper's own base
372
+ classification once it resolves an inner command, so without the floor `eval "ls"` would classify READONLY.
373
+
333
374
  ### Delegation fields
334
375
 
335
376
  | Field | Type | Applies to | Description |
336
377
  |-------|------|-----------|-------------|
337
- | `mode` | enum | all | One of the four modes above |
378
+ | `mode` | enum | all | One of the modes above |
338
379
  | `separator` | string | `after_separator` | Token that separates wrapper args from inner args |
339
380
  | `terminator` | string | `terminated_argv` | Token that ends the inner argv |
340
381
  | `flag` | string | `flag_value_is_expression` | Which flag's value to parse as an expression |
382
+ | `skip_leading_positionals` | integer | `rest_are_argv` | Positional tokens belonging to the wrapper itself before the inner command starts (e.g. `timeout DURATION COMMAND...` uses `1`) |
341
383
  | `strip_assignments` | boolean | `rest_are_argv` | Strip leading `KEY=VALUE` tokens before inner command |
342
384
  | `min_classification` | enum | all | Floor classification for the inner command |
343
385
 
344
386
  ### `min_classification`
345
387
 
346
- Forces the inner command to be classified at least at the given level. sudo uses this to ensure that even `sudo ls` is at least EXTERNAL_EFFECTS -- because running anything under elevated privileges is not a no-op.
388
+ Forces the inner command to be classified at least at the given level. sudo uses this to ensure that even `sudo ls` is at least EXTERNAL_EFFECTS -- because running anything under elevated privileges is not a no-op. `eval` and `exec` use it the same way, with a `DANGEROUS` floor.
347
389
 
348
390
  ## 7. Special Cases
349
391
 
@@ -362,9 +404,14 @@ These cannot be modeled as database entries because they are shell builtins with
362
404
  | Builtin | Classification | Reason |
363
405
  |---------|---------------|--------|
364
406
  | `cd`, `pushd`, `popd` | READONLY | Directory navigation only |
365
- | `eval` | DANGEROUS | Arbitrary code execution, argument is unparseable |
366
- | `source`, `.` | DANGEROUS | Executes an external script |
367
- | `exec` (builtin) | DANGEROUS | Replaces the current process |
407
+ | `[`, `[[`, `test` | READONLY | Condition evaluation, no side effects |
408
+ | `source`, `.` | DANGEROUS | Executes an external script, whose contents are never visible |
409
+
410
+ `eval` and `exec` used to be in this list. They are database entries now -- `commands/eval.yaml`
411
+ delegates via `args_are_expression`, `commands/exec.yaml` via `rest_are_argv`, both with
412
+ `min_classification: DANGEROUS`. Modelling them in YAML exposes the inner command instead of
413
+ hiding it, and the floor is what keeps them DANGEROUS even when the inner command is READONLY.
414
+ Reach for a hardcoded builtin only when the command's argument is genuinely not a command line.
368
415
 
369
416
  ### Path-qualified commands
370
417