termaxa 0.14.2

A cooperative gate for the shell commands AI coding agents run — command previews, automatic backups, allow/ask/deny policy, and audit logging.
# Termaxa policy — first matching rule wins; `*` is a wildcard.
# Actions: allow (run silently) | ask (require approval) | deny (block)
#
# ORDER MATTERS, and the hard stops come first on purpose. Until v0.14.1 the
# read-only allows sat at the top, so a broad prefix shadowed the stop below
# it: `git branch -D main` matched `git branch*` and was ALLOWED, and
# `echo $(rm -rf /)` matched `echo *` before `*rm -rf*` could deny it. A rule
# that can never be reached is not a rule. Put your own exceptions ABOVE the
# deny you want them to override — that is what first-match-wins is for.
#
# Matching is case-insensitive, so a rule cannot distinguish `-D` from `-d`.
# Where a flag's case carries the meaning (git branch -D, rm -R), the rule
# covers both and the action is chosen for the safer of the two.
version: 1
default: ask

rules:
  # ---- self-defence: the gate's own configuration ----
  # A gate that will happily rewrite its own rules is a suggestion. These
  # come FIRST because first-match-wins: `echo *` at the bottom of this file
  # would otherwise allow
  #     echo 'default: allow' > .termaxa/policy.yaml
  # and every command after it is judged by the agent's own policy.
  #
  # `*` matches any run of characters, so one rule covers both path
  # separators: `.termaxa/policy.yaml` and `.termaxa\policy.yaml`.
  #
  # This closes the SHELL path only. An agent's file-writing tool (Write,
  # Edit, the editor's apply-patch) never reaches the hook — the Claude Code
  # hook is registered with `"matcher": "Bash"` — so no rule here can see it.
  # `termaxa doctor` fingerprints the policy for exactly that reason: what
  # cannot be blocked can at least be noticed.
  #
  # Reads are denied too, except for the handful listed below, because
  # separating reads from writes in general would mean enumerating every read
  # command. To add one, put it in that group — NOT at the top of the file.
  - match: "*.claude*settings*"
    action: deny
    reason: "Agent hook configuration is off limits — editing it unhooks the gate."
  - match: "*.cursor*hooks*"
    action: deny
    reason: "Agent hook configuration is off limits — editing it unhooks the gate."
  - match: "*.codex*hooks*"
    action: deny
    reason: "Agent hook configuration is off limits — editing it unhooks the gate."
  - match: "*.github*hooks*"
    action: deny
    reason: "Agent hook configuration is off limits — editing it unhooks the gate."

  # The policy is an in-repo artifact, reviewable in PRs, and the deny below
  # would otherwise make that workflow impossible: `git add .termaxa/…`,
  # `git diff .termaxa/…` and `cp .termaxa/policy.yaml backup.yaml` are all
  # blocked by it. These exceptions give the workflow back.
  #
  # The test for inclusion is that the `.termaxa` path can only be READ, never
  # written. diff/status/log/show/cat read it; add/commit stage what is
  # already on disk. `checkout`, `restore` and `config` are absent on purpose
  # — overwriting the working tree from a ref is exactly what the deny is for,
  # so restoring a clobbered policy stays a thing you do yourself.
  #
  # `cat` and `cp` are anchored on the SOURCE path so the copy can only go the
  # safe way: `cp .termaxa/policy.yaml backup.yaml` matches,
  # `cp backup.yaml .termaxa/policy.yaml` does not.
  #
  # Position matters twice over. Above the deny, or these never fire. Below
  # the four denies above, because a trailing `*` swallows a redirect —
  #     cat .termaxa/policy.yaml > .claude/settings.json
  # matches `cat .termaxa*` too, and at the top of the file it would allow
  # that and shadow the rule that exists to stop it.
  - match: "git diff *.termaxa*"
    action: allow
  - match: "git status *.termaxa*"
    action: allow
  - match: "git log *.termaxa*"
    action: allow
  - match: "git show *.termaxa*"
    action: allow
  - match: "git add *.termaxa*"
    action: allow
  - match: "git commit *.termaxa*"
    action: allow
  - match: "cat .termaxa*"
    action: allow
  - match: "cp .termaxa*"
    action: allow
  - match: "*.termaxa*"
    action: deny
    reason: "Termaxa's own config is off limits — that is the gate. Edit it yourself."

  # ---- destructive: hard stops ----
  - match: "git push*--force*"
    action: deny
    reason: "Force pushes are blocked by policy. Open a PR instead."
  - match: "rm -rf /*"
    action: deny
    reason: "Recursive delete from root is blocked."
  # GNU rm refuses `rm -rf /` on its own; --no-preserve-root is the one
  # spelling it obeys. The rule above is named for the command everybody
  # quotes, this one is named for the command that actually works.
  - match: "*--no-preserve-root*"
    action: deny
    reason: "--no-preserve-root is the only spelling `rm` obeys at `/`. Blocked."
  # Broad recursive-force deletes (any target), Unix + PowerShell + cmd
  # forms. DENY by default: with auto-approving agent UIs, `ask` silently
  # degrades to `allow`. Relax deliberately, per project, if you need to.
  - match: "*rm -rf*"
    action: deny
    reason: "Recursive force delete blocked by default policy."
  - match: "*rm -fr*"
    action: deny
    reason: "Recursive force delete blocked by default policy."
  - match: "*Remove-Item*-Recurse*"
    action: deny
    reason: "Recursive delete (PowerShell) blocked by default policy."
  - match: "*Remove-Item*-Force*"
    action: deny
    reason: "Forced delete (PowerShell) blocked by default policy."
  - match: "*Get-ChildItem*Remove-Item*"
    action: deny
    reason: "Bulk delete pipeline (PowerShell) blocked by default policy."
  - match: "*del /s*"
    action: deny
    reason: "Recursive delete (cmd) blocked by default policy."
  - match: "*rmdir /s*"
    action: deny
    reason: "Recursive delete (cmd) blocked by default policy."
  - match: "*rd /s*"
    action: deny
    reason: "Recursive delete (cmd) blocked by default policy."
  - match: "kubectl delete*"
    action: deny
    reason: "kubectl delete is blocked. Use a manifest change + apply."
  - match: "*drop table*"
    action: deny
    reason: "DROP TABLE is blocked. Archive or rename instead."
  - match: "*drop database*"
    action: deny
    reason: "DROP DATABASE is blocked."
  - match: "terraform destroy*"
    action: deny
    reason: "terraform destroy is blocked by policy."
  - match: "tofu destroy*"
    action: deny
    reason: "tofu destroy is blocked by policy."

  # ---- consequential: human in the loop ----
  # `git branch -D` force-deletes an unmerged branch. Case-insensitive
  # matching cannot separate it from the safe `-d`, so this asks rather than
  # denies; the commits remain in the reflog either way.
  - match: "git branch*-d*"
    action: ask
    reason: "Deleting a branch. `-D` force-deletes even if unmerged."
  - match: "git push*"
    action: ask
  - match: "terraform apply*"
    action: ask
  - match: "tofu apply*"
    action: ask
  - match: "docker rm*"
    action: ask
  - match: "docker system prune*"
    action: ask
  - match: "npm publish*"
    action: ask
  - match: "cargo publish*"
    action: ask
  - match: "gh pr merge*"
    action: ask
  - match: "aws *"
    action: ask
  - match: "curl*"
    action: ask
  - match: "ssh *"
    action: ask

  # ---- read-only operations: let the agent work ----
  - match: "git status*"
    action: allow
  - match: "git diff*"
    action: allow
  - match: "git log*"
    action: allow
  - match: "git branch*"
    action: allow
  - match: "git commit*"
    action: allow
  # A prefix without its trailing space is a prefix, not a command: `ls*`
  # also matched `lsof` and `lsblk`, `grep*` also matched `grepdiff`.
  # `cat *` and `echo *` below always had this right. Bare `ls` needs its own
  # rule because `ls *` requires the space; bare `cat`/`grep` just read stdin,
  # so they stay on the default.
  - match: "ls"
    action: allow
  - match: "ls *"
    action: allow
  - match: "cat *"
    action: allow
  - match: "grep *"
    action: allow
  - match: "echo *"
    action: allow
  - match: "git remote -v"
    action: allow
  - match: "git fetch*"
    action: allow
  - match: "terraform plan*"
    action: allow
  - match: "terraform init*"
    action: allow
  - match: "tofu plan*"
    action: allow
  - match: "kubectl get*"
    action: allow
  - match: "kubectl describe*"
    action: allow
  - match: "docker ps*"
    action: allow

# Session circuit breaker (v0.11): if the same destructive intent
# (file delete / db destroy / git force / infra destroy) is asked or
# denied `threshold` times in one agent session, further variants are
# DENIED automatically. Human-approved commands don't count.
circuit_breaker:
  enabled: true
  threshold: 2   # trip on the 3rd attempt