Metadata-Version: 2.5
Name: bash-classify
Version: 0.14.1
Summary: Classify bash commands as READONLY, WRITE, DANGEROUS, or UNKNOWN
Project-URL: Homepage, https://github.com/fprochazka/bash-classify
Project-URL: Repository, https://github.com/fprochazka/bash-classify
Project-URL: Issues, https://github.com/fprochazka/bash-classify/issues
Project-URL: Documentation, https://github.com/fprochazka/bash-classify/blob/main/SPEC.md
Author-email: Filip Procházka <dev@fprochazka.cz>
License-Expression: MIT
License-File: LICENSE
Keywords: bash,classification,cli,security,tree-sitter
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Shells
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: pyyaml>=6.0
Requires-Dist: tree-sitter-bash>=0.23
Requires-Dist: tree-sitter>=0.24
Description-Content-Type: text/markdown

# bash-classify

Classify bash commands by their side-effect risk level.

## What it does

bash-classify parses bash expressions using tree-sitter, classifies each command against a database of 150+ known commands, and outputs a structured JSON verdict. Commands are classified along two axes: **classification** (`READONLY`, `LOCAL_EFFECTS`, `EXTERNAL_EFFECTS`, `DANGEROUS`, `UNKNOWN`) describing what kind of effects a command has, and **risk** (`LOW`, `MEDIUM`, `HIGH`) describing how worried you should be.

Designed primarily as a [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hook to automatically allow low-risk commands while flagging risky ones for human review.

## Installation

```bash
uv tool install bash-classify
# or
pip install bash-classify
```

To track a commit rather than a release — worth doing if you want to test a change before it ships — pin it directly:

```
bash-classify @ git+https://github.com/fprochazka/bash-classify@<commit>
```

The version is derived from the last tag, so a commit past it resolves to a pre-release such as `0.12.1.dev3+g8699357`. An ordinary constraint still admits that: `>=0.12,<0.13` contains it, because a specifier set only excludes pre-releases when its `prereleases` flag is explicitly false, and it is not by default. Writing the floor as `>=0.12.1.dev0,<0.13` is worth doing anyway — it says out loud that the pinned thing is a pre-release, and it sets that flag — but it is a marker for whoever reads the file next, not something the resolver needs.

## Quick start

```bash
$ echo 'kubectl get pods -n production' | bash-classify | jq '.classification'
"READONLY"

$ echo 'git push --force origin main' | bash-classify | jq '.classification'
"DANGEROUS"

$ echo 'cp file.txt /etc/config' | bash-classify | jq '.classification'
"DANGEROUS"

$ echo 'find . -name "*.pyc" -delete' | bash-classify | jq '.classification'
"DANGEROUS"
```

## Matching command shapes

Classification answers "how risky is this?". A deny hook usually has a narrower question:
"does this expression run command shape X?" `bash-classify match` answers that one. It
parses the expression, walks every invocation at every depth, and reports which of the
shapes in a rules file were actually invoked — so a heredoc body, an `echo` string, a
`#` comment or a `grep` pattern that merely *names* the command does not count.

```yaml
# blocked-commands.yaml
rules:
  - name: mr-discussions-api
    command: [glab, api]
    any_arg_matches: 'merge_requests/[^/?]+/(discussions|notes)(/|\?|$)'

  - name: mr-view-comments
    command: [glab, mr, view]
    any_option: [--comments, -c]

  - name: mr-note
    command: [glab, mr, note]
    except: [[glab, mr, note, list]]

  - name: python-script
    command: [python3]
    except_option: [-c]
```

```bash
$ echo 'sudo glab mr note 42 -m hi' | bash-classify match --rules blocked-commands.yaml
{
  "matches": [
    {
      "rule": "mr-note",
      "command": ["glab", "mr", "note"],
      "argv": ["glab", "mr", "note", "42", "-m", "hi"],
      "via": ["sudo"]
    }
  ],
  "parse_warnings": []
}
```

Within one rule every condition given must hold; rules are independent of each other, and one invocation can match several. `command` is a prefix match against the *resolved* command path, so `/usr/bin/glab --repo x mr note` still resolves to `glab mr note`. A command path — in `command` and in every `except` entry alike — holds the binary and its subcommands only. An option never resolves into one, so a path that contains one is rejected when the rules file loads, rather than loading clean and matching nothing.

`any_option` and `except_option` are mirrors of each other: `any_option` requires at least one of the listed options to be present, `except_option` requires that none of them is. Both look at the options actually present, with values stripped (`--comments=true` counts as `--comments`) and declared clusters expanded (`-wc` carries `-c`); tokens after `--` are positionals and count as absent. An option the command database does not declare still counts, so `except_option: [-c]` separates `python3 script.py` from `python3 -c '...'`. `any_arg_matches` is a Python `re.search` over every argument token — a pattern written for `grep -E` needs `\S` rather than `[^[:space:]]`. `via` lists the enclosing wrappers, outermost first.

**Two things a caller has to check.** First, `parse_warnings` is always present: when it
is non-empty the expression could not be fully parsed, so an empty `matches` proves nothing
and the caller should fall back to whatever it did before. Second, check that the output
actually has a `matches` key. A `bash-classify` older than this mode does not reject the
unknown `match` argument — it ignores it, classifies stdin and exits 0, so the caller gets
a normal classification JSON with no `matches` key. Treat a non-zero exit, unparseable
output, or output without a `matches` key as "cannot answer" and fall back; never read a
missing `matches` as "nothing matched".

Exit codes are `0` whether or not anything matched, `1` for empty input or no input within
5 seconds, and `2` for bad arguments, an unreadable or invalid rules file, or an internal
error.

## Sensitive paths

Classification says what a command does to the system. It says nothing about what the command touches. Reading a private key really is read-only, so `cat ~/.ssh/id_rsa` is `READONLY`, and anything that auto-approves on `risk: LOW` auto-approves it.

So every argv token and every redirect target is checked against a denylist of paths that hold credentials. A hit leaves classification alone and floors `risk` at `HIGH`.

The list covers SSH and GPG keys, cloud and cluster credentials, `.env` files, `.netrc` and `.npmrc`, `.git-credentials`, `.pgpass` and `.my.cnf`, and the token stores of command-line tools: `~/.claude.json`, `~/.config/gh/hosts.yml` and `~/.config/glab-cli/config.yml`. Settings files are not on it. `~/.aws/config`, `.git/config`, `~/.gitconfig` and `~/.config/gh/config.yml` are routine debugging and stay `LOW`.

```bash
$ echo 'cat ~/.ssh/id_rsa' | bash-classify | jq '{classification, risk, sensitive_paths}'
{
  "classification": "READONLY",
  "risk": "HIGH",
  "sensitive_paths": [
    {
      "token": "~/.ssh/id_rsa",
      "rule": "ssh",
      "source": "argv",
      "spelling": "literal"
    }
  ]
}
```

`sensitive_paths` is always present, on the expression and on every command and inner command, so a caller that wants a different policy reads the detail instead of the verdict. A hit found inside `sudo`, `xargs`, `sh -c` or `find -exec` is reported at that depth and again on every level above it, up to the expression.

`source` says where the token came from: `argv`, `argv_write`, `redirect_read`, `redirect_write` or `env_dump`. `argv` is vague on purpose. `cat X` reads and `tee X` writes, and telling those apart needs per-command knowledge the database does not carry for a positional. Two sources do know the direction. A redirect knows it because the operator says so, and `argv_write` is the value of an option the tool documents as naming a file it writes, such as `curl -o` — so `curl -o ~/.ssh/authorized_keys https://x` reports a write while `cat ~/.ssh/authorized_keys` reports an argument of unknown direction.

A marked option's value is reported *only* as `argv_write`, so selecting `source == "argv"` no longer sees it — `curl -o ~/.ssh/authorized_keys` has one hit and it is not an `argv` one. And `argv_write` never covers a destination written as a plain positional (`cp a b`, `tee out.txt`), an operand that is not an option (`dd of=X`), or an option whose direction depends on another option (`tar -f`, `tar -C`). Read it as an extra signal, never as the definition of "this writes" — `source != "argv_write"` does not mean nothing is written. [SPEC.md](SPEC.md) has the full list.

Paths match on whole segments and are never anchored, so `~/.ssh/id_rsa`, `/home/me/.ssh/id_rsa` and `../.ssh/id_rsa` all hit the `ssh` rule, while `.gitignore` and `.github` are not `.git`. A `.` segment is dropped and a `..` is resolved against the one before it, so `/etc/./shadow` and `~/.aws/x/../credentials` hit while `.ssh/../notes` does not. Each token is read three ways — as written, with `\` as a Windows separator, and with `\x` read as the POSIX escape `x` — so `~/.s\sh/id_rsa` is caught. A glob segment is matched backwards, the denylisted name against the token as the pattern, so `~/.ss?/id_rsa`, `~/.[^x]sh/id_rsa` and `.e*` are caught too. `spelling` reports which reading matched: `literal`, `posix_escape`, `windows` or `glob`.

Nothing here reads the environment of the process doing the classifying. `~` and `$HOME` stay unresolved segments, so the verdict depends on the expression alone and a hook gives the same answer whatever `HOME` it runs under.

A rule can exempt specific files. The bundled `dotenv` rule exempts the template names projects commit on purpose — `.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.defaults` — while `.env`, `.env.local` and `.env.production` stay hits.

Bare `env` and `printenv` are reported with `source: "env_dump"`. They print every variable, which is where an agent's API keys live. An argument that names a secret-bearing variable is reported as well: `$GITHUB_TOKEN` and `${ANTHROPIC_API_KEY}` under any command, and a bare `ANTHROPIC_API_KEY` only under `env`, `printenv`, `export` and `unset`. Everywhere else a bare all-caps word is a search string, so `grep -rn TOKEN src` is not a hit.

**This is a speed bump against an agent being careless, not a control against one being evaded.** It raises the cost of an accident. Anyone who knows the rule can walk around it, and [SPEC.md](SPEC.md) lists how. The short version: a path computed at runtime is invisible to a static matcher, and a glob with fewer than two literal characters is ignored on purpose, so `cat .*` is not reported — a rule that fires on `ls *` is a rule people switch off.

It also over-reports in one direction. A token that only *mentions* a path is a hit, so `git commit -m "document ~/.ssh/config setup"` and `grep -rn "\.ssh/config" docs/` are both reported. This is the opposite of what `match` mode does, where a `grep` pattern that names a command is not an invocation. The two are not alike: telling a path a command opens from one it merely carries needs per-command argument knowledge that the database does not have.

Extend the denylist at `~/.config/bash-classify/sensitive-paths.yaml`, or at `$BASH_CLASSIFY_CONFIG_DIR/sensitive-paths.yaml`. It uses the same format as the bundled `src/bash_classify/sensitive-paths.yaml`, validated against a [JSON Schema](schemas/sensitive-paths.schema.json):

```yaml
rules:
  - name: company-vault
    paths:
      - .acme/vault
      - .config/acme/token
    except_paths:
      - .acme/vault/README
```

A rule there is added to the bundled set. A rule that reuses a bundled name replaces it, which is how you narrow or drop one.

## Claude Code plugin

The repo includes a Claude Code plugin that auto-allows low-risk bash commands via a `PreToolUse` hook.

```bash
# Install the bash-classify CLI
uv tool install bash-classify

# Add the marketplace and install the plugin
claude plugin marketplace add fprochazka/bash-classify
claude plugin install bash-classify-hook@fprochazka-bash-classify
```

To upgrade after a new release:

```bash
uv tool install --force bash-classify
claude plugin marketplace update fprochazka-bash-classify
claude plugin update bash-classify-hook@fprochazka-bash-classify
```

Once installed, any Bash tool call with `risk: LOW` is auto-approved — no permission prompt. This includes all `READONLY` commands plus safe routine operations like `git add`, `git commit`, `mkdir`, package installs, code formatters, and more. Commands with `MEDIUM` or `HIGH` risk still require confirmation. A command that names a sensitive path is never `LOW`, so the hook prompts for it even when the command itself is read-only.

## Command database

bash-classify loads command definitions from two locations:

- **Built-in database** — 150+ command definitions bundled with the package, covering common Unix utilities, package managers, container tools, cloud CLIs, and more. Lives in `src/bash_classify/commands/*.yaml`.
- **User database** — your own command definitions at `~/.config/bash-classify/commands/*.yaml` (override the location with the `BASH_CLASSIFY_CONFIG_DIR` env var, which resolves to `$BASH_CLASSIFY_CONFIG_DIR/commands/`). A file here lets you classify internal tools, company-specific wrappers or personal CLIs without forking the repo.

A user file that shares its name with a built-in one picks one of two things to do with it.

**Replace it** — the default, and what a file with no `extends` key has always done. The bundled definition is discarded whole: its subcommands, its options, its base classification, all of it. Use this when your binary has nothing to do with the bundled one of the same name, or when you need the bundled file's answer *gone* rather than adjusted.

**Extend it** — `extends: builtin` merges your file over the bundled one, so you declare only what you are adding or changing. Use this to teach a command the subcommands your own setup gives it, which is otherwise a fork of a file that keeps growing underneath you:

```yaml
# ~/.config/bash-classify/commands/git.yaml
command: git
extends: builtin
subcommands:
  tally:  {classification: LOCAL_EFFECTS, risk: LOW}
  ledger: {classification: READONLY}
```

Everything the bundled `git` knows is still there; `git tally` and `git ledger` are now two more subcommands it knows. Mappings merge key by key at every depth, so naming one field of a subcommand changes that field and leaves the rest of the bundled entry alone — `push: {risk: LOW}` keeps `git push` at `EXTERNAL_EFFECTS` and keeps `--force` dangerous. A scalar, a list, and a `delegates_to` block replace their bundled counterpart outright.

The guarantee is that **merging never drops a bundled subcommand or option.** It is not a blanket "cannot remove": a list is replaced rather than merged, so `aliases: []` does drop the bundled aliases, and that is the one deliberate way to take something away. A file that has to drop a bundled subcommand or option leaves `extends` off and replaces the definition instead.

Four things are load errors rather than quiet surprises, because a user database is never schema-validated at load and every one of these fails towards auto-approval:

- **`extends: builtin` on a command the bundled database does not define.** There is nothing to merge. The message names the file and the command, and no fallback to replacement happens.
- **Any key the loader does not recognise,** at every level of the file. `extends: bultin` is caught this way, and so is `clasification: READONLY`.
- **A key written with no value.** YAML reads `classification:` with nothing after it as `null`, not as an absent key, and it would replace the bundled value with the default — which is always the weaker answer. Typing half a line, or commenting a value out while you think about it, would otherwise turn `git <anything unrecognised>` from `DANGEROUS` into `READONLY` without a word. For a *subcommand or option entry* you mean to leave at its bundled settings, write `{}`; that merges as the no-op it looks like. `delegates_to: {}` is not in that class and is refused too, because an empty block parses as "does not delegate" — omit the key instead.
- **An option written under a name the bundled file spells as an alias of another option.** `kubectl.yaml` files `-n` only as an alias of `--namespace`, and aliases are expanded after the merge, so an entry added under `-n` shadows the bundled option rather than changing it — it loses `takes_value`, and `kubectl -n prod delete pod x` stops resolving `delete`. Write the entry under `--namespace`.

Both directories use the same YAML format. See [docs/classification-guidance.md](docs/classification-guidance.md) for how to add new commands. YAML definitions are validated against a [JSON Schema](schemas/command.schema.json) for IDE autocomplete and CI checks.

## Classification levels

| Level | Description | Examples |
|---|---|---|
| `READONLY` | No side effects | `ls`, `cat`, `grep`, `kubectl get` |
| `LOCAL_EFFECTS` | Modifies local files or state only | `git add`, `git commit`, `cp`, `mkdir`, `pytest` |
| `EXTERNAL_EFFECTS` | Interacts with external systems | `git push`, `kubectl apply`, `curl -d` |
| `DANGEROUS` | Destructive, system-wide, or irreversible | `rm -rf`, `git push --force`, `chmod` |
| `UNKNOWN` | Command not in database | Any unrecognized command |

## Risk levels

Each command also gets a **risk** rating, orthogonal to classification:

| Risk | Description | Examples |
|---|---|---|
| `LOW` | Safe, routine operation — auto-approved | `ls`, `git add`, `git commit`, `mkdir`, `ruff format` |
| `MEDIUM` | Normal caution warranted | `git push`, `cp`, `npm run`, `git rebase` |
| `HIGH` | Dangerous or unknown — always requires confirmation | `rm -rf`, `git push --force`, unknown commands |

Risk defaults are derived from classification (`READONLY`→LOW, `LOCAL_EFFECTS`→MEDIUM, `EXTERNAL_EFFECTS`→MEDIUM, `DANGEROUS`/`UNKNOWN`→HIGH) but can be overridden per command, subcommand, or option in the YAML database.

## How it works

- **Tree-sitter parsing** -- bash expressions are parsed into an AST for accurate command extraction, handling pipes, subshells, and command substitution
- **YAML command database** -- each command has classification rules with subcommand and option matching
- **Subcommand matching** -- `kubectl get` and `kubectl delete` can have different classifications
- **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
- **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
- **Delegation for wrappers** -- commands like `xargs`, `sudo`, and `env` delegate classification to the inner command
- **File path detection** -- `write_paths` holds redirect targets and the values of options the database marks as naming an output (`curl -o`, `wget -O`, `cp -t`, `sort -o`), `read_paths` holds input redirect targets; a write is `>` or `>>` with the optional file descriptor and `|` no-clobber override bash allows (`1>`, `2>>`, `>|`), plus `&>`, `&>>` and `>&`; writes to `/tmp` and `/var/tmp` stay at LOW risk. A destination named by a plain positional (`cp a b`, `tee out.txt`) is in neither field
- **Sensitive path detection** -- argv tokens and redirect targets are matched against a denylist of credential paths; a hit floors risk at HIGH and is reported in `sensitive_paths`

## Python API

```python
from bash_classify import classify_expression

result = classify_expression("kubectl get pods")
print(result.classification)  # Classification.READONLY
print(result.risk)            # Risk.LOW
```

Each command result carries the options it actually uses and the positionals left after parsing. Option values
are stripped, so `--key=value` shows up as `--key` and `-fvalue` as `-f`:

```python
command = classify_expression("git commit --amend -m 'wip'").commands[0]
print(command.command)      # ['git', 'commit']
print(command.options)      # ['--amend', '-m']
print(command.positionals)  # []
```

`iter_invocations` walks every invocation in an expression depth-first — top-level commands and, recursively,
the inner commands that wrappers such as `sudo`, `timeout` or `bash -c` delegate to. It yields each invocation
with its `via` chain: the enclosing wrappers, outermost first, empty at the top level.

```python
from bash_classify import classify_expression, iter_invocations

for invocation, via in iter_invocations(classify_expression("sudo timeout 5 ls")):
    print(via, invocation.command)
# [] ['sudo']
# ['sudo'] ['timeout']
# ['sudo', 'timeout'] ['ls']
```

`load_rules` and `match_expression` are the same thing from Python:

```python
from bash_classify import load_rules, match_expression

rules = load_rules("blocked-commands.yaml")
result = match_expression('cat > brief.md <<"EOF"\nmentions glab mr note\nEOF', rules)
print(result.matches)         # [] - the heredoc body is data, not a command
print(result.parse_warnings)  # []
```

See [SPEC.md](SPEC.md) for the full specification.

## Development

```bash
git clone https://github.com/fprochazka/bash-classify.git
cd bash-classify
uv sync --dev
```

Run tests and linting before committing:

```bash
uv run ruff format .
uv run ruff check .
uv run pytest
```

To add or modify command definitions, see [docs/classification-guidance.md](docs/classification-guidance.md). All YAML files in `src/bash_classify/commands/` are validated against a [JSON Schema](schemas/command.schema.json) — your IDE will provide autocomplete if it supports the `# $schema:` comment.

## Releasing

Version is derived automatically from git tags via `hatch-vcs` — no manual version bumping needed.

Before tagging, bump the version in both plugin manifest files:

- `coding-agent-plugins/claude-code/.claude-plugin/plugin.json`
- `.claude-plugin/marketplace.json`

Wait for CI to pass on master, then tag, push, and create a GitHub release:

```bash
# Review changes since last release
git log $(git describe --tags --abbrev=0)..HEAD --oneline

git tag v<version>
git push origin v<version>
gh release create v<version> --title "v<version>" --notes "..."
```

The `publish.yml` GitHub Action builds and publishes to PyPI automatically via trusted publishing.

## License

[MIT](LICENSE)
