bash-classify 0.9.0__tar.gz → 0.10.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 (365) hide show
  1. {bash_classify-0.9.0 → bash_classify-0.10.0}/.claude-plugin/marketplace.json +1 -1
  2. {bash_classify-0.9.0 → bash_classify-0.10.0}/PKG-INFO +97 -2
  3. {bash_classify-0.9.0 → bash_classify-0.10.0}/README.md +95 -0
  4. {bash_classify-0.9.0 → bash_classify-0.10.0}/SPEC.md +148 -13
  5. {bash_classify-0.9.0 → bash_classify-0.10.0}/coding-agent-plugins/claude-code/.claude-plugin/plugin.json +1 -1
  6. {bash_classify-0.9.0 → bash_classify-0.10.0}/docs/classification-guidance.md +33 -5
  7. {bash_classify-0.9.0 → bash_classify-0.10.0}/schemas/command.schema.json +7 -1
  8. bash_classify-0.10.0/schemas/match-rules.schema.json +57 -0
  9. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/__init__.py +9 -1
  10. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/classifier.py +30 -2
  11. bash_classify-0.10.0/src/bash_classify/cli.py +250 -0
  12. bash_classify-0.10.0/src/bash_classify/commands/eval.yaml +8 -0
  13. bash_classify-0.10.0/src/bash_classify/commands/exec.yaml +12 -0
  14. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/git.yaml +37 -4
  15. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/glab.yaml +65 -0
  16. bash_classify-0.10.0/src/bash_classify/commands/snip.yaml +79 -0
  17. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/matcher.py +95 -13
  18. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/models.py +5 -0
  19. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/parser.py +118 -2
  20. bash_classify-0.10.0/src/bash_classify/rules.py +248 -0
  21. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-award-emoji.sh +1 -0
  22. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-include-diverged.sh +1 -0
  23. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-itself.sh +1 -0
  24. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-pipelines-list.sh +1 -0
  25. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/api-merge-request-update-description.sh +1 -0
  26. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/api-project-lookup.sh +1 -0
  27. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/ci-list.sh +1 -0
  28. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/comment-only-mention.sh +2 -0
  29. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/echo-mentions-api-endpoint.sh +1 -0
  30. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/echo-mentions-command.sh +1 -0
  31. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/git-commit-heredoc-message.sh +8 -0
  32. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/git-commit-inline-message.sh +3 -0
  33. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/glab-discussion-read-and-api-mr.sh +1 -0
  34. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/glab-discussion-read.sh +1 -0
  35. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/glab-pipeline-inspect.sh +1 -0
  36. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/grep-pattern-mentions-command.sh +1 -0
  37. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/grep-recursive-for-command.sh +1 -0
  38. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/heredoc-brief-2026-09-04.sh +88 -0
  39. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/heredoc-brief-mentions-blocked-commands.sh +14 -0
  40. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/heredoc-report-mentions-blocked.sh +10 -0
  41. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/heredoc-skill-custom-delimiter.sh +11 -0
  42. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/heredoc-skill-phrase-split-across-lines.sh +10 -0
  43. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/hook-self-test-json-payload.sh +1 -0
  44. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/loop-over-quoted-command-strings.sh +1 -0
  45. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-note-list-output-json.sh +1 -0
  46. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-note-list.sh +1 -0
  47. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-update-then-view.sh +3 -0
  48. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-view-bare.sh +1 -0
  49. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-view-grep-pipeline.sh +2 -0
  50. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-view-output-equals-json.sh +1 -0
  51. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-view-output-json.sh +1 -0
  52. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/mr-view-output-short-flag.sh +1 -0
  53. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/printf-mentions-command.sh +1 -0
  54. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/python-heredoc-triple-quoted-mention.sh +15 -0
  55. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/allow/ripgrep-pattern-args.sh +1 -0
  56. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-absolute-binary-path.sh +1 -0
  57. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-delete-note-nested-api.sh +1 -0
  58. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-cd-and-prefix.sh +1 -0
  59. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-export-host-loop.sh +8 -0
  60. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-for-loop.sh +1 -0
  61. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-heredoc-then-call.sh +8 -0
  62. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-hostname-python-consumer.sh +9 -0
  63. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-line-continuation.sh +2 -0
  64. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-method-get-field.sh +1 -0
  65. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-output-ndjson.sh +1 -0
  66. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-paginate-before-path.sh +3 -0
  67. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-paginate.sh +1 -0
  68. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-per-page-query.sh +1 -0
  69. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-quoted-paginate-jq.sh +1 -0
  70. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-redirect-to-file.sh +1 -0
  71. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-repo-flag-first.sh +1 -0
  72. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-discussions-var-capture.sh +1 -0
  73. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-edit-note-put-heredoc.sh +7 -0
  74. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-new-diff-note-input-stdin.sh +1 -0
  75. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-new-diff-note-position-fields.sh +6 -0
  76. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-note-award-emoji.sh +1 -0
  77. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-note-by-id-or-retry.sh +1 -0
  78. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-notes-after-allowed-mr-view.sh +1 -0
  79. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-notes-query-sort.sh +1 -0
  80. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-notes-timeout-prefix.sh +1 -0
  81. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-reply-method-post-after-path.sh +6 -0
  82. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-reply-post-body-from-file.sh +1 -0
  83. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-reply-post-heredoc-body.sh +8 -0
  84. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-resolve-loop.sh +1 -0
  85. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-resolve-put-field.sh +1 -0
  86. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/api-single-discussion-by-id.sh +1 -0
  87. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/heredoc-then-and-chain-to-blocked.sh +3 -0
  88. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/heredoc-then-pipe-to-blocked.sh +3 -0
  89. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-custom-heredoc-delimiter.sh +8 -0
  90. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-help.sh +1 -0
  91. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-heredoc-body.sh +7 -0
  92. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-inline-after-mr-update.sh +1 -0
  93. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-long-message-flag.sh +1 -0
  94. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-repo-before-message.sh +1 -0
  95. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-second-in-pipeline.sh +1 -0
  96. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-note-then-close.sh +1 -0
  97. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-bare.sh +1 -0
  98. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-loop.sh +1 -0
  99. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-no-iid.sh +1 -0
  100. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-page-flag.sh +1 -0
  101. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-repo-flag.sh +1 -0
  102. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-short-cluster.sh +1 -0
  103. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/mr-view-comments-short-flag.sh +1 -0
  104. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/wrapper-bash-c.sh +1 -0
  105. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/block/wrapper-sudo.sh +1 -0
  106. bash_classify-0.10.0/tests/fixtures/match/glab-discussion/rules.yaml +17 -0
  107. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-job-metadata-without-trace.sh +1 -0
  108. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-job-metadata-xargs.sh +1 -0
  109. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-merge-request-itself.sh +1 -0
  110. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-merge-request-pipelines-list-per-page.sh +5 -0
  111. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-merge-request-pipelines-list.sh +1 -0
  112. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-pipelines-list-bash-c.sh +1 -0
  113. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-pipelines-list-ref.sh +1 -0
  114. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/api-pipelines-list-single-quoted.sh +1 -0
  115. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/ci-lint-local-file.sh +1 -0
  116. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/ci-list-per-page.sh +1 -0
  117. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/ci-list.sh +1 -0
  118. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/ci-status-compact.sh +1 -0
  119. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/ci-status-pipeline-id.sh +1 -0
  120. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/comment-only-mention.sh +2 -0
  121. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/echo-mentions-ci-command.sh +1 -0
  122. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/git-commit-heredoc-message.sh +9 -0
  123. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/git-commit-inline-message.sh +1 -0
  124. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/glab-discussion-read.sh +1 -0
  125. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/glab-pipeline-inspect.sh +1 -0
  126. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/grep-pattern-mentions-ci.sh +1 -0
  127. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/heredoc-brief-mentions-ci-commands.sh +16 -0
  128. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/heredoc-skill-mentions-ci.sh +10 -0
  129. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/loop-over-quoted-command-strings.sh +1 -0
  130. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/printf-mentions-ci-command.sh +1 -0
  131. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/allow/ripgrep-pattern-args.sh +1 -0
  132. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-continuation-lines.sh +5 -0
  133. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-input-and-header.sh +1 -0
  134. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-method-post-field.sh +1 -0
  135. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-ci-lint-raw-field-cat.sh +1 -0
  136. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-after-allowed-metadata.sh +1 -0
  137. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-env-assignment.sh +1 -0
  138. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-redirect-to-file.sh +1 -0
  139. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-retry-loop.sh +1 -0
  140. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-job-trace-xargs.sh +1 -0
  141. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-detail.sh +1 -0
  142. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-env-assignment.sh +1 -0
  143. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-hostname-after-path.sh +1 -0
  144. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-comment-and-loop.sh +5 -0
  145. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-list.sh +1 -0
  146. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-nested-loop-timeout.sh +6 -0
  147. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-paginate-ndjson.sh +2 -0
  148. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-python-consumer.sh +6 -0
  149. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-jobs-scope-failed-loop.sh +1 -0
  150. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-status-poll-loop.sh +6 -0
  151. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/api-pipeline-test-report.sh +3 -0
  152. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-after-allowed-status.sh +2 -0
  153. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-branch-in-echo-chain.sh +3 -0
  154. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-branch-ref-sleep-prefix.sh +1 -0
  155. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-json-jq-sort-uniq.sh +1 -0
  156. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-then-ci-trace.sh +4 -0
  157. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-var-capture-poll-loop.sh +9 -0
  158. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-get-with-job-details-repo.sh +1 -0
  159. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-trace-job-name-with-pipeline.sh +1 -0
  160. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-trace-pipeline-id-before-job-name.sh +1 -0
  161. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-trace-redirect-to-file.sh +1 -0
  162. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-trace-repo-before-job-id.sh +1 -0
  163. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-view-help.sh +1 -0
  164. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-view-job-web-or-api.sh +1 -0
  165. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/ci-view-pipeline-id.sh +1 -0
  166. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/heredoc-then-pipe-to-blocked.sh +3 -0
  167. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/wrapper-bash-c.sh +1 -0
  168. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/block/wrapper-sudo.sh +1 -0
  169. bash_classify-0.10.0/tests/fixtures/match/glab-pipeline/rules.yaml +26 -0
  170. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_classifier.py +130 -1
  171. bash_classify-0.10.0/tests/test_cli.py +255 -0
  172. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_cli_serialization.py +64 -0
  173. bash_classify-0.10.0/tests/test_match_fixtures.py +176 -0
  174. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_matcher.py +225 -0
  175. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_parser.py +98 -0
  176. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_real_world.py +140 -0
  177. bash_classify-0.10.0/tests/test_rules.py +443 -0
  178. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_schema.py +19 -0
  179. bash_classify-0.9.0/src/bash_classify/cli.py +0 -149
  180. bash_classify-0.9.0/tests/test_cli.py +0 -82
  181. {bash_classify-0.9.0 → bash_classify-0.10.0}/.claude/rules/command-database.md +0 -0
  182. {bash_classify-0.9.0 → bash_classify-0.10.0}/.editorconfig +0 -0
  183. {bash_classify-0.9.0 → bash_classify-0.10.0}/.github/workflows/ci.yml +0 -0
  184. {bash_classify-0.9.0 → bash_classify-0.10.0}/.github/workflows/publish.yml +0 -0
  185. {bash_classify-0.9.0 → bash_classify-0.10.0}/.gitignore +0 -0
  186. {bash_classify-0.9.0 → bash_classify-0.10.0}/.python-version +0 -0
  187. {bash_classify-0.9.0 → bash_classify-0.10.0}/CLAUDE.md +0 -0
  188. {bash_classify-0.9.0 → bash_classify-0.10.0}/LICENSE +0 -0
  189. {bash_classify-0.9.0 → bash_classify-0.10.0}/coding-agent-plugins/claude-code/hooks/hook-permission-request.sh +0 -0
  190. {bash_classify-0.9.0 → bash_classify-0.10.0}/coding-agent-plugins/claude-code/hooks/hook-pre-tool-use.sh +0 -0
  191. {bash_classify-0.9.0 → bash_classify-0.10.0}/coding-agent-plugins/claude-code/hooks/hooks.json +0 -0
  192. {bash_classify-0.9.0 → bash_classify-0.10.0}/pyproject.toml +0 -0
  193. {bash_classify-0.9.0 → bash_classify-0.10.0}/ruff.toml +0 -0
  194. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/__main__.py +0 -0
  195. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/agent-browser.yaml +0 -0
  196. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/apt.yaml +0 -0
  197. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/awk.yaml +0 -0
  198. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/basename.yaml +0 -0
  199. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/bash-classify.yaml +0 -0
  200. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/bash.yaml +0 -0
  201. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/black.yaml +0 -0
  202. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/brew.yaml +0 -0
  203. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/builtin.yaml +0 -0
  204. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/cargo.yaml +0 -0
  205. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/cat.yaml +0 -0
  206. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/chgrp.yaml +0 -0
  207. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/chmod.yaml +0 -0
  208. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/chown.yaml +0 -0
  209. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/column.yaml +0 -0
  210. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/comm.yaml +0 -0
  211. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/command.yaml +0 -0
  212. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/cp.yaml +0 -0
  213. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/curl.yaml +0 -0
  214. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/cut.yaml +0 -0
  215. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/date.yaml +0 -0
  216. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/df.yaml +0 -0
  217. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/diff.yaml +0 -0
  218. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/dig.yaml +0 -0
  219. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/dirname.yaml +0 -0
  220. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/docker.yaml +0 -0
  221. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/du.yaml +0 -0
  222. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/echo.yaml +0 -0
  223. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/env.yaml +0 -0
  224. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/eslint.yaml +0 -0
  225. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/expand.yaml +0 -0
  226. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/false.yaml +0 -0
  227. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/file.yaml +0 -0
  228. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/find.yaml +0 -0
  229. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/fmt.yaml +0 -0
  230. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/fold.yaml +0 -0
  231. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/free.yaml +0 -0
  232. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gawk.yaml +0 -0
  233. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gcloud.yaml +0 -0
  234. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gh.yaml +0 -0
  235. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/glab-discussion.yaml +0 -0
  236. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/go.yaml +0 -0
  237. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gog.yaml +0 -0
  238. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gradle.yaml +0 -0
  239. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gradlew.yaml +0 -0
  240. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/grep.yaml +0 -0
  241. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/groups.yaml +0 -0
  242. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gunzip.yaml +0 -0
  243. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gws.yaml +0 -0
  244. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/gzip.yaml +0 -0
  245. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/hadolint.yaml +0 -0
  246. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/head.yaml +0 -0
  247. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/helm.yaml +0 -0
  248. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/hostname.yaml +0 -0
  249. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/id.yaml +0 -0
  250. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/install.yaml +0 -0
  251. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/isort.yaml +0 -0
  252. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/java.yaml +0 -0
  253. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/javac.yaml +0 -0
  254. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/join.yaml +0 -0
  255. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/journalctl.yaml +0 -0
  256. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/jq.yaml +0 -0
  257. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/kill.yaml +0 -0
  258. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/killall.yaml +0 -0
  259. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/kubectl.yaml +0 -0
  260. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/less.yaml +0 -0
  261. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ln.yaml +0 -0
  262. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ls.yaml +0 -0
  263. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/make.yaml +0 -0
  264. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/md5sum.yaml +0 -0
  265. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/mise.yaml +0 -0
  266. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/mkdir.yaml +0 -0
  267. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/more.yaml +0 -0
  268. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/mv.yaml +0 -0
  269. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/mvn.yaml +0 -0
  270. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/mvnw.yaml +0 -0
  271. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/mypy.yaml +0 -0
  272. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/netstat.yaml +0 -0
  273. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/nice.yaml +0 -0
  274. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/nl.yaml +0 -0
  275. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/node.yaml +0 -0
  276. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/nohup.yaml +0 -0
  277. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/npm.yaml +0 -0
  278. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/npx.yaml +0 -0
  279. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/nslookup.yaml +0 -0
  280. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/paste.yaml +0 -0
  281. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ping.yaml +0 -0
  282. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pip.yaml +0 -0
  283. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pip3.yaml +0 -0
  284. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pipx.yaml +0 -0
  285. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pkill.yaml +0 -0
  286. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pnpm.yaml +0 -0
  287. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/poetry.yaml +0 -0
  288. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/prettier.yaml +0 -0
  289. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/printenv.yaml +0 -0
  290. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/printf.yaml +0 -0
  291. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ps.yaml +0 -0
  292. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pup.yaml +0 -0
  293. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pwd.yaml +0 -0
  294. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/pytest.yaml +0 -0
  295. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/python.yaml +0 -0
  296. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/python3.yaml +0 -0
  297. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/read.yaml +0 -0
  298. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/readlink.yaml +0 -0
  299. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/realpath.yaml +0 -0
  300. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/rev.yaml +0 -0
  301. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/rg.yaml +0 -0
  302. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/rm.yaml +0 -0
  303. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/rsync.yaml +0 -0
  304. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/rtk.yaml +0 -0
  305. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ruff.yaml +0 -0
  306. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/scp.yaml +0 -0
  307. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sed.yaml +0 -0
  308. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/seq.yaml +0 -0
  309. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sh.yaml +0 -0
  310. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sha1sum.yaml +0 -0
  311. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sha256sum.yaml +0 -0
  312. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/shellcheck.yaml +0 -0
  313. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/slack.yaml +0 -0
  314. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sleep.yaml +0 -0
  315. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sort.yaml +0 -0
  316. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ss.yaml +0 -0
  317. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/ssh.yaml +0 -0
  318. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/stat.yaml +0 -0
  319. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/stdbuf.yaml +0 -0
  320. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/strace.yaml +0 -0
  321. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/strings.yaml +0 -0
  322. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/sudo.yaml +0 -0
  323. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/systemctl.yaml +0 -0
  324. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/tac.yaml +0 -0
  325. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/tail.yaml +0 -0
  326. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/tar.yaml +0 -0
  327. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/tee.yaml +0 -0
  328. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/terraform.yaml +0 -0
  329. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/test.yaml +0 -0
  330. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/time.yaml +0 -0
  331. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/timeout.yaml +0 -0
  332. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/top.yaml +0 -0
  333. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/touch.yaml +0 -0
  334. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/tr.yaml +0 -0
  335. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/traceroute.yaml +0 -0
  336. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/true.yaml +0 -0
  337. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/tsc.yaml +0 -0
  338. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/type.yaml +0 -0
  339. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/uname.yaml +0 -0
  340. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/unexpand.yaml +0 -0
  341. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/uniq.yaml +0 -0
  342. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/unzip.yaml +0 -0
  343. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/uptime.yaml +0 -0
  344. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/uv.yaml +0 -0
  345. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/wait.yaml +0 -0
  346. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/wc.yaml +0 -0
  347. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/wget.yaml +0 -0
  348. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/whereis.yaml +0 -0
  349. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/which.yaml +0 -0
  350. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/whoami.yaml +0 -0
  351. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/xargs.yaml +0 -0
  352. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/xmllint.yaml +0 -0
  353. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/yarn.yaml +0 -0
  354. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/yes.yaml +0 -0
  355. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/yq.yaml +0 -0
  356. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/zip.yaml +0 -0
  357. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/commands/zsh.yaml +0 -0
  358. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/database.py +0 -0
  359. {bash_classify-0.9.0 → bash_classify-0.10.0}/src/bash_classify/py.typed +0 -0
  360. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/__init__.py +0 -0
  361. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/conftest.py +0 -0
  362. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_database.py +0 -0
  363. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_models.py +0 -0
  364. {bash_classify-0.9.0 → bash_classify-0.10.0}/tests/test_smoke.py +0 -0
  365. {bash_classify-0.9.0 → bash_classify-0.10.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.0",
11
+ "version": "0.10.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.0
3
+ Version: 0.10.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.
@@ -134,6 +194,41 @@ print(result.classification) # Classification.READONLY
134
194
  print(result.risk) # Risk.LOW
135
195
  ```
136
196
 
197
+ Each command result carries the options it actually uses and the positionals left after parsing. Option values
198
+ are stripped, so `--key=value` shows up as `--key` and `-fvalue` as `-f`:
199
+
200
+ ```python
201
+ command = classify_expression("git commit --amend -m 'wip'").commands[0]
202
+ print(command.command) # ['git', 'commit']
203
+ print(command.options) # ['--amend', '-m']
204
+ print(command.positionals) # []
205
+ ```
206
+
207
+ `iter_invocations` walks every invocation in an expression depth-first — top-level commands and, recursively,
208
+ the inner commands that wrappers such as `sudo`, `timeout` or `bash -c` delegate to. It yields each invocation
209
+ with its `via` chain: the enclosing wrappers, outermost first, empty at the top level.
210
+
211
+ ```python
212
+ from bash_classify import classify_expression, iter_invocations
213
+
214
+ for invocation, via in iter_invocations(classify_expression("sudo timeout 5 ls")):
215
+ print(via, invocation.command)
216
+ # [] ['sudo']
217
+ # ['sudo'] ['timeout']
218
+ # ['sudo', 'timeout'] ['ls']
219
+ ```
220
+
221
+ `load_rules` and `match_expression` are the same thing from Python:
222
+
223
+ ```python
224
+ from bash_classify import load_rules, match_expression
225
+
226
+ rules = load_rules("blocked-commands.yaml")
227
+ result = match_expression('cat > brief.md <<"EOF"\nmentions glab mr note\nEOF', rules)
228
+ print(result.matches) # [] - the heredoc body is data, not a command
229
+ print(result.parse_warnings) # []
230
+ ```
231
+
137
232
  See [SPEC.md](SPEC.md) for the full specification.
138
233
 
139
234
  ## 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.
@@ -105,6 +165,41 @@ print(result.classification) # Classification.READONLY
105
165
  print(result.risk) # Risk.LOW
106
166
  ```
107
167
 
168
+ Each command result carries the options it actually uses and the positionals left after parsing. Option values
169
+ are stripped, so `--key=value` shows up as `--key` and `-fvalue` as `-f`:
170
+
171
+ ```python
172
+ command = classify_expression("git commit --amend -m 'wip'").commands[0]
173
+ print(command.command) # ['git', 'commit']
174
+ print(command.options) # ['--amend', '-m']
175
+ print(command.positionals) # []
176
+ ```
177
+
178
+ `iter_invocations` walks every invocation in an expression depth-first — top-level commands and, recursively,
179
+ the inner commands that wrappers such as `sudo`, `timeout` or `bash -c` delegate to. It yields each invocation
180
+ with its `via` chain: the enclosing wrappers, outermost first, empty at the top level.
181
+
182
+ ```python
183
+ from bash_classify import classify_expression, iter_invocations
184
+
185
+ for invocation, via in iter_invocations(classify_expression("sudo timeout 5 ls")):
186
+ print(via, invocation.command)
187
+ # [] ['sudo']
188
+ # ['sudo'] ['timeout']
189
+ # ['sudo', 'timeout'] ['ls']
190
+ ```
191
+
192
+ `load_rules` and `match_expression` are the same thing from Python:
193
+
194
+ ```python
195
+ from bash_classify import load_rules, match_expression
196
+
197
+ rules = load_rules("blocked-commands.yaml")
198
+ result = match_expression('cat > brief.md <<"EOF"\nmentions glab mr note\nEOF', rules)
199
+ print(result.matches) # [] - the heredoc body is data, not a command
200
+ print(result.parse_warnings) # []
201
+ ```
202
+
108
203
  See [SPEC.md](SPEC.md) for the full specification.
109
204
 
110
205
  ## 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,11 +385,13 @@ 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:
@@ -841,6 +921,7 @@ The `delegates_to` field defines how a command (or option like `find -exec`) han
841
921
  | `after_separator` | Everything after the `separator` token forms the inner argv. | `kubectl exec pod -- ls -la` → inner: `["ls", "-la"]` |
842
922
  | `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
923
  | `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) |
924
+ | `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
925
 
845
926
  #### Delegation fields
846
927
 
@@ -909,14 +990,15 @@ Directories are reported as-is (not resolved) since variable expansion may be in
909
990
 
910
991
  ## Special-cased Commands
911
992
 
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:
993
+ 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
994
 
914
995
  | Command | Special handling |
915
996
  |---|---|
916
997
  | `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 |
998
+ | `[`, `[[`, `test` | Always `READONLY` — condition evaluation with no side effects |
999
+ | `source`, `.` | Always `DANGEROUS` — executes an external script whose contents are never visible |
1000
+
1001
+ `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
1002
 
921
1003
  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
1004
 
@@ -930,7 +1012,30 @@ Python 3.12+. Dependencies:
930
1012
 
931
1013
  ## Output Schema
932
1014
 
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).
1015
+ This is the schema of the default mode. `match` mode has its own, much smaller one:
1016
+
1017
+ ```json
1018
+ {
1019
+ "matches": [
1020
+ {
1021
+ "rule": "string — the name of the rule that matched",
1022
+ "command": ["string — the resolved command path of the invocation"],
1023
+ "argv": ["string — the full argv of the invocation"],
1024
+ "via": ["string — enclosing wrapper commands, outermost first; empty at top level"]
1025
+ }
1026
+ ],
1027
+ "parse_warnings": ["string — non-fatal issues encountered during parsing"]
1028
+ }
1029
+ ```
1030
+
1031
+ Both keys are always present in match output, `matches` as `[]` when nothing matched and
1032
+ `parse_warnings` as `[]` on a clean parse. As in the default mode, `parse_warnings` covers
1033
+ nested expressions: a syntax error inside `bash -c "..."` or `eval "..."` surfaces here too.
1034
+ A caller must check both: a non-empty `parse_warnings` means an empty `matches` proves
1035
+ nothing, and a *missing* `matches` key means the output came from a binary that predates
1036
+ this mode.
1037
+
1038
+ 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
1039
 
935
1040
  ```json
936
1041
  {
@@ -945,6 +1050,8 @@ The `commands` list is recursive — any command entry can contain `inner_comman
945
1050
  "argv": ["string — full original argv for this command"],
946
1051
  "classification": "READONLY | LOCAL_EFFECTS | EXTERNAL_EFFECTS | DANGEROUS | UNKNOWN",
947
1052
  "matched_rule": "string — dotted path in database, or null",
1053
+ "options": ["string — every option present, canonical flag only, no values; always present, empty when none"],
1054
+ "positionals": ["string — non-option tokens left after subcommand and option parsing; always present, empty when none"],
948
1055
  "ignored_options": ["string — global options that were stripped"],
949
1056
  "remaining_options": ["string — options that were not in database"],
950
1057
  "classification_reason": "string — why this classification was chosen",
@@ -959,6 +1066,8 @@ The `commands` list is recursive — any command entry can contain `inner_comman
959
1066
  "argv": ["..."],
960
1067
  "classification": "...",
961
1068
  "matched_rule": "...",
1069
+ "options": ["... — same meaning as on the enclosing command"],
1070
+ "positionals": ["... — same meaning as on the enclosing command"],
962
1071
  "inner_commands": ["... — recursive, can nest further"]
963
1072
  }
964
1073
  ]
@@ -971,10 +1080,37 @@ The `commands` list is recursive — any command entry can contain `inner_comman
971
1080
  "affects_classification": "bool"
972
1081
  }
973
1082
  ],
974
- "parse_warnings": ["string — non-fatal issues encountered during parsing"]
1083
+ "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
1084
  }
976
1085
  ```
977
1086
 
1087
+ `parse_warnings` covers **nested** expressions as well as the outer one. A syntax error inside
1088
+ `bash -c "..."`, `sh -c`, `zsh -c` or `eval "..."` surfaces here at any wrapper depth
1089
+ (`sudo bash -c '...'`, `find . -exec bash -c '...' \;`), and the warning text names the nested
1090
+ expression it came from, so a caller can tell which part of the command could not be read.
1091
+ Identical warnings are reported once.
1092
+
1093
+ #### `options` and `positionals`
1094
+
1095
+ `options` records which options an invocation actually carries, with option values stripped and each option in
1096
+ its canonical form: `--key=value` contributes `--key`, `-fvalue` contributes `-f`, and a cluster whose every
1097
+ character is declared in the database (`-wc`) contributes each character (`-w`, `-c`). Options the database does
1098
+ not model are included too — present means present. Tokens after `--` are positionals and contribute nothing.
1099
+
1100
+ Two consequences of matching against a database that models only part of a real CLI. In a cluster whose first
1101
+ character is declared but a later one is not (`-wx` with only `-w` declared), the undeclared characters are not
1102
+ reported anywhere — not in `options`, not in `remaining_options` — so a check against `options` can miss such an
1103
+ option but can never falsely fire on one. In the other direction, an option the database does not model but that
1104
+ really takes a value has its value reported as a second option (`-e -foo` yields `-e` and `-foo`, because nothing
1105
+ says `-e` consumes the next token), so `options` errs toward over-reporting for unmodelled options. Declaring the
1106
+ option in the database fixes both.
1107
+
1108
+ `positionals` holds the non-option tokens that remain once the subcommand chain and the options have been
1109
+ consumed, including `--` itself and everything after it.
1110
+
1111
+ Both are always serialized, as `[]` when the command has none. Results that never reach option parsing — shell
1112
+ builtins, an unknown binary, an empty argv — carry `null` internally and serialize as `[]`.
1113
+
978
1114
  ### Recursive classification rules
979
1115
 
980
1116
  1. A command's `classification` is the **maximum** of:
@@ -990,14 +1126,13 @@ The `commands` list is recursive — any command entry can contain `inner_comman
990
1126
 
991
1127
  ## Non-goals
992
1128
 
993
- - **Not a sandbox.** This is a classifier, not an enforcer. It does not execute or block anything.
1129
+ - **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
1130
  - **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
1131
  - **No variable resolution.** `$DIR`, `$(cmd)` in command position → UNKNOWN. We classify what we can see statically.
996
1132
  - **No alias/function resolution.** We classify the literal command name as written.
997
1133
 
998
1134
  ## Future extensions
999
1135
 
1000
- - `--format=decision` — output only the classification string (for use in hooks)
1001
1136
  - `--database=/path` — custom database directory
1002
1137
  - `--explain` — verbose output showing the matching steps
1003
1138
  - 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.0",
3
+ "version": "0.10.0",
4
4
  "description": "Auto-allows low-risk bash commands in Claude Code using bash-classify",
5
5
  "author": {
6
6
  "name": "Filip Prochazka"
@@ -330,20 +330,43 @@ options:
330
330
  -c: {takes_value: true}
331
331
  ```
332
332
 
333
+ ### `args_are_expression`
334
+
335
+ Every argument is joined with single spaces and the result is parsed as a complete shell expression. This is what
336
+ `eval` does: it concatenates its arguments and runs the concatenation as shell source, so `eval ls -la` and
337
+ `eval "ls -la"` are the same command. Unlike `flag_value_is_expression`, no flag selects the expression -- every
338
+ token after the binary is part of it, so a command using this mode must not declare options of its own.
339
+
340
+ **eval:** `eval "git push --force"` -- the string `git push --force` is parsed as a full expression, producing one
341
+ inner command.
342
+
343
+ ```yaml
344
+ command: eval
345
+ classification: DANGEROUS
346
+ strict: false
347
+ delegates_to:
348
+ mode: args_are_expression
349
+ min_classification: DANGEROUS # eval stays DANGEROUS even when the inner is READONLY
350
+ ```
351
+
352
+ The `min_classification` floor is not optional here. Command-level delegation erases the wrapper's own base
353
+ classification once it resolves an inner command, so without the floor `eval "ls"` would classify READONLY.
354
+
333
355
  ### Delegation fields
334
356
 
335
357
  | Field | Type | Applies to | Description |
336
358
  |-------|------|-----------|-------------|
337
- | `mode` | enum | all | One of the four modes above |
359
+ | `mode` | enum | all | One of the modes above |
338
360
  | `separator` | string | `after_separator` | Token that separates wrapper args from inner args |
339
361
  | `terminator` | string | `terminated_argv` | Token that ends the inner argv |
340
362
  | `flag` | string | `flag_value_is_expression` | Which flag's value to parse as an expression |
363
+ | `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
364
  | `strip_assignments` | boolean | `rest_are_argv` | Strip leading `KEY=VALUE` tokens before inner command |
342
365
  | `min_classification` | enum | all | Floor classification for the inner command |
343
366
 
344
367
  ### `min_classification`
345
368
 
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.
369
+ 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
370
 
348
371
  ## 7. Special Cases
349
372
 
@@ -362,9 +385,14 @@ These cannot be modeled as database entries because they are shell builtins with
362
385
  | Builtin | Classification | Reason |
363
386
  |---------|---------------|--------|
364
387
  | `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 |
388
+ | `[`, `[[`, `test` | READONLY | Condition evaluation, no side effects |
389
+ | `source`, `.` | DANGEROUS | Executes an external script, whose contents are never visible |
390
+
391
+ `eval` and `exec` used to be in this list. They are database entries now -- `commands/eval.yaml`
392
+ delegates via `args_are_expression`, `commands/exec.yaml` via `rest_are_argv`, both with
393
+ `min_classification: DANGEROUS`. Modelling them in YAML exposes the inner command instead of
394
+ hiding it, and the floor is what keeps them DANGEROUS even when the inner command is READONLY.
395
+ Reach for a hardcoded builtin only when the command's argument is genuinely not a command line.
368
396
 
369
397
  ### Path-qualified commands
370
398
 
@@ -94,7 +94,13 @@
94
94
  "properties": {
95
95
  "mode": {
96
96
  "type": "string",
97
- "enum": ["rest_are_argv", "after_separator", "terminated_argv", "flag_value_is_expression"],
97
+ "enum": [
98
+ "rest_are_argv",
99
+ "after_separator",
100
+ "terminated_argv",
101
+ "flag_value_is_expression",
102
+ "args_are_expression"
103
+ ],
98
104
  "description": "Delegation mode"
99
105
  },
100
106
  "separator": {
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/fprochazka/bash-classify/schemas/match-rules.schema.json",
4
+ "title": "bash-classify match rules",
5
+ "description": "Command shapes for `bash-classify match --rules`. Within one rule every given condition must hold; rules are independent of each other.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["rules"],
9
+ "properties": {
10
+ "rules": {
11
+ "type": "array",
12
+ "minItems": 1,
13
+ "items": { "$ref": "#/$defs/Rule" },
14
+ "description": "The command shapes to look for"
15
+ }
16
+ },
17
+ "$defs": {
18
+ "CommandPath": {
19
+ "type": "array",
20
+ "minItems": 1,
21
+ "items": { "type": "string", "minLength": 1 },
22
+ "description": "A resolved command path, binary first: [glab, mr, note]"
23
+ },
24
+ "Rule": {
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": ["name", "command"],
28
+ "properties": {
29
+ "name": {
30
+ "type": "string",
31
+ "minLength": 1,
32
+ "description": "Rule name, unique within the file; reported back on every match"
33
+ },
34
+ "command": {
35
+ "$ref": "#/$defs/CommandPath",
36
+ "description": "Prefix match against the resolved command path, so [glab, mr, note] also matches `glab --repo x mr note`"
37
+ },
38
+ "except": {
39
+ "type": "array",
40
+ "items": { "$ref": "#/$defs/CommandPath" },
41
+ "description": "Command paths excluded from the match, also by prefix"
42
+ },
43
+ "any_option": {
44
+ "type": "array",
45
+ "minItems": 1,
46
+ "items": { "type": "string", "pattern": "^-" },
47
+ "description": "At least one of these options must be present, as typed; a declared cluster -wc counts as -w and -c, and --key=value counts as --key"
48
+ },
49
+ "any_arg_matches": {
50
+ "type": "string",
51
+ "minLength": 1,
52
+ "description": "Python re.search pattern applied to every argument token: argv[1:] minus the resolved subcommand words"
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
@@ -1,15 +1,23 @@
1
1
  """bash-classify: Classify bash expressions by their side-effect risk level."""
2
2
 
3
- from bash_classify.classifier import classify_expression
3
+ from bash_classify.classifier import classify_expression, iter_invocations
4
4
  from bash_classify.database import CommandDatabase, load_database
5
5
  from bash_classify.models import Classification, CommandResult, ExpressionResult, Risk
6
+ from bash_classify.rules import Match, MatchResult, Rule, RulesError, load_rules, match_expression
6
7
 
7
8
  __all__ = [
8
9
  "classify_expression",
10
+ "iter_invocations",
9
11
  "load_database",
10
12
  "CommandDatabase",
11
13
  "Classification",
12
14
  "Risk",
13
15
  "ExpressionResult",
14
16
  "CommandResult",
17
+ "Rule",
18
+ "RulesError",
19
+ "Match",
20
+ "MatchResult",
21
+ "load_rules",
22
+ "match_expression",
15
23
  ]