1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# 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