formwork-kit 0.1.0__py3-none-any.whl
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.
- formwork_cli/__init__.py +326 -0
- formwork_cli/kit/COSTS.md +111 -0
- formwork_cli/kit/adapters/claude-code/README.md +53 -0
- formwork_cli/kit/adapters/claude-code/settings.json +46 -0
- formwork_cli/kit/adapters/codex/README.md +43 -0
- formwork_cli/kit/adapters/cursor/README.md +45 -0
- formwork_cli/kit/adapters/gemini-cli/README.md +47 -0
- formwork_cli/kit/build +410 -0
- formwork_cli/kit/check/checks/config-shape +123 -0
- formwork_cli/kit/check/checks/decision-ids +159 -0
- formwork_cli/kit/check/checks/doc-links +133 -0
- formwork_cli/kit/check/checks/generated-current +74 -0
- formwork_cli/kit/check/checks/guard-wired +139 -0
- formwork_cli/kit/check/checks/kit-integrity +199 -0
- formwork_cli/kit/check/checks/predictions-first +127 -0
- formwork_cli/kit/check/checks/role-shape +172 -0
- formwork_cli/kit/check/checks/rule-labels +135 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/.formwork.toml +5 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/formwork/guide.md +13 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/rules-as-a-switchboard/.formwork.toml +8 -0
- formwork_cli/kit/check/fixtures/config-shape/must-pass/layers-kept-apart/.formwork.toml +5 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/a-placeholder-shipped/docs/decisions/0003-still-pending.md +7 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/superseded-by-nothing/docs/decisions/0002-old.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-first.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-second.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0001-the-first.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0002-the-second.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0003-the-third.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/nothing-recorded-yet/docs/decisions/README.md +3 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/never-written/index.md +7 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/architecture-notes.md +3 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/guide.md +8 -0
- formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/architecture-notes.md +1 -0
- formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/guide.md +5 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.claude/agents/sample.md +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.codex/agents/sample.toml +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.gemini/agents/sample.md +23 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/build +349 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/roles/method/sample.md +18 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.claude/agents/sample.md +20 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.codex/agents/sample.toml +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.gemini/agents/sample.md +23 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/build +349 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/roles/method/sample.md +18 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/nothing-is-generated-here/README.md +3 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/declared-but-no-file/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.claude/settings.json +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.claude/settings.json +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/nothing-declared/README.md +1 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/formwork/check/checks/still-here +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/state/fingerprints.txt +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/formwork/guard/git-boundary +3 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/state/fingerprints.txt +1 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/formwork/guard/git-boundary +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/state/fingerprints.txt +1 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/architect.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/researcher.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/round.md +4 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/a-round-that-has-not-argued-yet/docs/rounds/0006-not-started/round.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/no-rounds-at-all/docs/README.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/architect.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/predictions.md +4 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/researcher.md +3 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/README.md +6 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/complete.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/missing-a-section/formwork/roles/vague.md +16 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/spawn-without-being-lead/formwork/roles/eager.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/first.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/second.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-pass/well-formed/formwork/roles/complete.md +18 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/claims-enforcement-that-does-not-exist/formwork/rules/core.md +9 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/no-catches/formwork/rules/core.md +9 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/unlabelled/formwork/rules/core.md +7 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-pass/well-formed/formwork/rules/core.md +10 -0
- formwork_cli/kit/check/run +340 -0
- formwork_cli/kit/check/test_gate.py +222 -0
- formwork_cli/kit/first-run.md +204 -0
- formwork_cli/kit/fw +121 -0
- formwork_cli/kit/glossary.md +160 -0
- formwork_cli/kit/guard/git-boundary +627 -0
- formwork_cli/kit/guard/protected-files +748 -0
- formwork_cli/kit/guard/quality-gate +260 -0
- formwork_cli/kit/guard/test_boundary.py +273 -0
- formwork_cli/kit/guard/test_protection.py +254 -0
- formwork_cli/kit/guard/test_quality_gate.py +156 -0
- formwork_cli/kit/install +395 -0
- formwork_cli/kit/limits.md +141 -0
- formwork_cli/kit/loop.md +82 -0
- formwork_cli/kit/roles/HOW-TO-ADD-A-ROLE.md +105 -0
- formwork_cli/kit/roles/TEMPLATE.md +26 -0
- formwork_cli/kit/roles/method/architect.md +269 -0
- formwork_cli/kit/roles/method/challenger.md +243 -0
- formwork_cli/kit/roles/method/lead.md +280 -0
- formwork_cli/kit/roles/method/record-keeper.md +206 -0
- formwork_cli/kit/roles/method/researcher.md +246 -0
- formwork_cli/kit/roles/method/reviewer.md +207 -0
- formwork_cli/kit/roles/packs/accessibility.md +236 -0
- formwork_cli/kit/roles/packs/ai.md +248 -0
- formwork_cli/kit/roles/packs/analyst.md +233 -0
- formwork_cli/kit/roles/packs/backend.md +425 -0
- formwork_cli/kit/roles/packs/brainstormer.md +190 -0
- formwork_cli/kit/roles/packs/data.md +212 -0
- formwork_cli/kit/roles/packs/devops.md +203 -0
- formwork_cli/kit/roles/packs/frontend.md +224 -0
- formwork_cli/kit/roles/packs/integrations.md +215 -0
- formwork_cli/kit/roles/packs/legal.md +251 -0
- formwork_cli/kit/roles/packs/marketing.md +206 -0
- formwork_cli/kit/roles/packs/mobile.md +202 -0
- formwork_cli/kit/roles/packs/performance.md +192 -0
- formwork_cli/kit/roles/packs/product.md +217 -0
- formwork_cli/kit/roles/packs/security.md +267 -0
- formwork_cli/kit/roles/packs/sre.md +203 -0
- formwork_cli/kit/roles/packs/tester.md +246 -0
- formwork_cli/kit/roles/packs/user-researcher.md +218 -0
- formwork_cli/kit/roles/packs/ux.md +205 -0
- formwork_cli/kit/roles/packs/visual.md +199 -0
- formwork_cli/kit/roles/packs/writer.md +198 -0
- formwork_cli/kit/round.md +131 -0
- formwork_cli/kit/rules/core.md +195 -0
- formwork_cli/kit/rules/full.md +493 -0
- formwork_cli/kit/templates/brief.md +68 -0
- formwork_cli/kit/templates/decision.md +93 -0
- formwork_cli/kit/templates/predictions.md +54 -0
- formwork_cli/kit/templates/report.md +52 -0
- formwork_cli/kit/templates/round.md +77 -0
- formwork_cli/kit/test_install.py +165 -0
- formwork_cli/kit/troubleshooting.md +247 -0
- formwork_cli/kit-page/FORMWORK.md +182 -0
- formwork_kit-0.1.0.dist-info/METADATA +308 -0
- formwork_kit-0.1.0.dist-info/RECORD +137 -0
- formwork_kit-0.1.0.dist-info/WHEEL +4 -0
- formwork_kit-0.1.0.dist-info/entry_points.txt +2 -0
- formwork_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The self-protection boundary. The kit refusing to be quietly disarmed.
|
|
3
|
+
|
|
4
|
+
protected-files --format claude-code < hook payload on stdin
|
|
5
|
+
protected-files --command "sed -i ..." decide a single shell command
|
|
6
|
+
protected-files --path formwork/check/run decide a single file write
|
|
7
|
+
|
|
8
|
+
Exit status, the same contract as every other guard:
|
|
9
|
+
|
|
10
|
+
0 allow
|
|
11
|
+
1 warn. The write happens; the human is told
|
|
12
|
+
2 refuse, with the reason on stderr
|
|
13
|
+
|
|
14
|
+
WHY THIS EXISTS
|
|
15
|
+
---------------
|
|
16
|
+
Every rule in this kit is enforced by a program, and every one of those
|
|
17
|
+
programs is an ordinary file. Turning the strongest rule off was four
|
|
18
|
+
characters — `block` becomes `off` — and nothing anywhere noticed.
|
|
19
|
+
|
|
20
|
+
So the things that do the enforcing are themselves protected: the guards, the
|
|
21
|
+
gate, the checks, the wiring, and the word lists.
|
|
22
|
+
|
|
23
|
+
THE LIMIT, STATED PLAINLY
|
|
24
|
+
-------------------------
|
|
25
|
+
**This is not a lock. Calling it one would be a lie.**
|
|
26
|
+
|
|
27
|
+
Anything with shell access can do what a person can do. A determined agent can
|
|
28
|
+
assemble a command this cannot read, or edit a file through an interpreter.
|
|
29
|
+
|
|
30
|
+
What the three layers together buy is that **it cannot happen quietly**:
|
|
31
|
+
|
|
32
|
+
* this guard refuses the direct routes
|
|
33
|
+
* the integrity check notices anything that got through, including edits
|
|
34
|
+
made by a human in an editor
|
|
35
|
+
* both leave the change visible in the report
|
|
36
|
+
|
|
37
|
+
Prevention is not available. Silence is what is being removed.
|
|
38
|
+
|
|
39
|
+
Python 3, standard library only, no dependencies.
|
|
40
|
+
"""
|
|
41
|
+
import fnmatch
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import shlex
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
ALLOW, WARN, REFUSE = 0, 1, 2
|
|
49
|
+
STRENGTHS = ("block", "warn", "off")
|
|
50
|
+
|
|
51
|
+
# Everything that does the enforcing. A path prefix matches its whole subtree.
|
|
52
|
+
PROTECTED = (
|
|
53
|
+
"formwork/guard/",
|
|
54
|
+
"formwork/check/run",
|
|
55
|
+
"formwork/check/checks/",
|
|
56
|
+
"formwork/check/fixtures/",
|
|
57
|
+
"formwork/adapters/",
|
|
58
|
+
".formwork.toml",
|
|
59
|
+
".claude/settings.json",
|
|
60
|
+
".codex/hooks.json",
|
|
61
|
+
".cursor/hooks.json",
|
|
62
|
+
".gemini/settings.json",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Where state actually lives, if it has been moved. Honoured by the checks and
|
|
66
|
+
# the installer, and ignored here — so `rm $FORMWORK_STATE_DIR/fingerprints.txt`
|
|
67
|
+
# was allowed for every user who relocated it.
|
|
68
|
+
STATE_DIR = os.environ.get("FORMWORK_STATE_DIR", "")
|
|
69
|
+
|
|
70
|
+
# The word lists live outside the repository, so they are named by home path.
|
|
71
|
+
PROTECTED_HOME = (
|
|
72
|
+
".formwork/denylist-anycase.txt",
|
|
73
|
+
".formwork/denylist-exact.txt",
|
|
74
|
+
".formwork/denylist-capitalised.txt",
|
|
75
|
+
".formwork/allowlist.txt",
|
|
76
|
+
# Both shapes. The record moved to one file per project under
|
|
77
|
+
# fingerprints/, and for a while the guard was still watching only the old
|
|
78
|
+
# flat file, so `rm -rf ~/.formwork/fingerprints` was allowed and wiped
|
|
79
|
+
# every record on the machine.
|
|
80
|
+
".formwork/fingerprints.txt",
|
|
81
|
+
".formwork/fingerprints",
|
|
82
|
+
".formwork/source-repos.txt",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Programs that always write when pointed at a path.
|
|
86
|
+
# Of the writers, the ones that take something away rather than add to it.
|
|
87
|
+
# Only these make the directory HOLDING a protected file protected too.
|
|
88
|
+
DESTROYERS = {"rm", "mv", "shred", "rmdir", "trash", "unlink", "gio"}
|
|
89
|
+
|
|
90
|
+
WRITERS = {
|
|
91
|
+
"tee", "cp", "mv", "rm", "rmdir", "install", "truncate", "dd", "ln",
|
|
92
|
+
"unlink", "rsync", "gio",
|
|
93
|
+
# Editors driven by a script write files without anybody watching.
|
|
94
|
+
"ex", "vim", "vi", "nvim", "emacs", "gsed",
|
|
95
|
+
"touch", "chown", "shred", "patch", "ed", "sponge",
|
|
96
|
+
"python", "python3", "perl", "ruby", "node",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Programs that only write when told to. Refusing them unconditionally was a
|
|
100
|
+
# false positive three times in one session, and a guard that is wrong about
|
|
101
|
+
# ordinary work gets switched off by somebody busy.
|
|
102
|
+
CONDITIONAL_WRITERS = {
|
|
103
|
+
# a stream editor prints to standard output unless asked to edit in place
|
|
104
|
+
"sed": ("-i", "--in-place"), # the long form is the Linux spelling
|
|
105
|
+
# The Homebrew GNU sed, which is on a great many Macs.
|
|
106
|
+
"gsed": ("-i", "--in-place"),
|
|
107
|
+
"awk": ("-i", "-i.bak", "--in-place"),
|
|
108
|
+
"gawk": ("-i", "--in-place"),
|
|
109
|
+
# perl is deliberately NOT here: it is in WRITERS, and being in both
|
|
110
|
+
# meant this branch returned None before the inline-script rule ran.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def chmod_disarms(words):
|
|
115
|
+
"""True when a chmod would remove permission rather than add it.
|
|
116
|
+
|
|
117
|
+
Adding the executable bit is how a new check gets finished; it cannot
|
|
118
|
+
disarm anything. Removing permissions is how a guard gets disabled without
|
|
119
|
+
editing a byte of it.
|
|
120
|
+
"""
|
|
121
|
+
for w in words[1:]:
|
|
122
|
+
# `chmod --reference=FILE` copies another file's mode. It can remove
|
|
123
|
+
# execute without ever naming a mode this function understands.
|
|
124
|
+
if w.startswith("--reference"):
|
|
125
|
+
return True
|
|
126
|
+
if w.startswith("-") and not w.startswith("--"):
|
|
127
|
+
if "x" in w or "r" in w or "w" in w:
|
|
128
|
+
return True # -x, -rwx and friends
|
|
129
|
+
if w.startswith("+"):
|
|
130
|
+
continue # +x adds, and adding is safe
|
|
131
|
+
# Symbolic modes name a class first: a-x, u-x, go-rwx, a=r. An audit
|
|
132
|
+
# removed a check with `chmod a-x` and the gate reported green over
|
|
133
|
+
# the remaining eight, so any spelling that drops or omits execute
|
|
134
|
+
# counts as disarming.
|
|
135
|
+
m = re.fullmatch(r"[ugoa]*([-+=])([rwxXst]*)", w)
|
|
136
|
+
if m:
|
|
137
|
+
op, bits = m.group(1), m.group(2)
|
|
138
|
+
if op == "-" and ("x" in bits or "r" in bits or "w" in bits):
|
|
139
|
+
return True
|
|
140
|
+
if op == "=" and "x" not in bits:
|
|
141
|
+
return True
|
|
142
|
+
if re.fullmatch(r"[0-7]{3,4}", w):
|
|
143
|
+
owner = int(w[-3])
|
|
144
|
+
# The gate only runs checks that are executable, so a mode without
|
|
145
|
+
# the owner's execute bit disables one without editing a byte of
|
|
146
|
+
# it. 644 looked harmless and is exactly that.
|
|
147
|
+
if not owner & 1 or not owner & 4:
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
# A heredoc body is data. git-boundary already strips these; this guard did
|
|
152
|
+
# not, so a protected path mentioned inside one was never examined. Found by
|
|
153
|
+
# accident, during the audit that produced this comment.
|
|
154
|
+
HEREDOC = re.compile(r"<<-?\s*'?\"?([A-Za-z_][A-Za-z0-9_]*)'?\"?.*?^\1",
|
|
155
|
+
re.S | re.M)
|
|
156
|
+
|
|
157
|
+
# Programs that only read. A protected path in one of these is fine.
|
|
158
|
+
READERS = {
|
|
159
|
+
"cat", "head", "tail", "less", "more", "grep", "egrep", "rg", "wc",
|
|
160
|
+
"diff", "ls", "file", "stat", "md5", "shasum", "sha256sum", "cmp",
|
|
161
|
+
"sort", "uniq", "cut", "find", "which", "realpath",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
PREFIXES = {"sudo", "env", "time", "nohup", "nice", "command", "exec",
|
|
165
|
+
"doas", "stdbuf", "timeout"}
|
|
166
|
+
|
|
167
|
+
# Which flags each wrapper takes a VALUE for. One shared set was wrong: `sudo
|
|
168
|
+
# -n` and `env -i` take no value, so the program name after them was eaten and
|
|
169
|
+
# `sudo -n rm <a guard>` was allowed.
|
|
170
|
+
WRAPPER_VALUE_FLAGS = {
|
|
171
|
+
"nice": {"-n", "--adjustment"},
|
|
172
|
+
"timeout": {"-k", "-s", "--kill-after", "--signal"},
|
|
173
|
+
"env": {"-u", "-C", "--unset", "--chdir"},
|
|
174
|
+
"sudo": {"-u", "-g", "-p", "-C", "-r", "-t", "--user", "--group"},
|
|
175
|
+
"doas": {"-u", "-C"},
|
|
176
|
+
"stdbuf": {"-i", "-o", "-e"},
|
|
177
|
+
"command": set(),
|
|
178
|
+
"exec": set(),
|
|
179
|
+
"time": set(),
|
|
180
|
+
"nohup": set(),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Given a protected file as their first argument, these run it. They do not
|
|
184
|
+
# write to it.
|
|
185
|
+
INTERPRETERS = {"python", "python3", "sh", "bash", "zsh", "node", "ruby",
|
|
186
|
+
"perl5", "uv"}
|
|
187
|
+
|
|
188
|
+
# Pipes and backgrounding separate commands too. Leaving `|` out meant one
|
|
189
|
+
# pipe character walked past this entire guard: `echo x | tee <protected>`.
|
|
190
|
+
# Grouping characters are punctuation, not part of the program name. Without
|
|
191
|
+
# this, "(rm <guard>)" tokenised as "(rm" and matched nothing.
|
|
192
|
+
GROUPING_EDGE = re.compile(r"(?<![\w-])([(){}])(?![\w-])|([(){}])")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def ungroup(text):
|
|
196
|
+
return GROUPING_EDGE.sub(lambda m: " %s " % (m.group(1) or m.group(2)), text)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
SPLIT = re.compile(r"(?:&&|\|\||[;&|\n])")
|
|
200
|
+
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
201
|
+
REDIRECT = re.compile(r"(\d?>>?|>\|)\s*([^\s;|&]+)")
|
|
202
|
+
|
|
203
|
+
# Does this filesystem treat two spellings as the same file? Asked once, of
|
|
204
|
+
# the filesystem itself, rather than guessed from the platform name.
|
|
205
|
+
try:
|
|
206
|
+
_CASE_BLIND = os.path.exists(__file__.upper()) or os.path.exists(
|
|
207
|
+
__file__.lower()) and __file__ != __file__.lower() and os.path.exists(
|
|
208
|
+
__file__.lower())
|
|
209
|
+
except OSError:
|
|
210
|
+
_CASE_BLIND = False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def project_root():
|
|
214
|
+
"""The project this guard is guarding.
|
|
215
|
+
|
|
216
|
+
Without this, the guard matched on the path tail anywhere on the disk. A
|
|
217
|
+
kit installed in one project then protected every `.claude/settings.json`
|
|
218
|
+
on the machine, in every other project, forever. A tester hit it while
|
|
219
|
+
trying to create an unrelated project and could not.
|
|
220
|
+
"""
|
|
221
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
222
|
+
if root:
|
|
223
|
+
return os.path.realpath(root)
|
|
224
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
225
|
+
return os.path.realpath(os.path.dirname(os.path.dirname(here)))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
PROJECT = project_root()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def inside_project(absolute):
|
|
232
|
+
"""Is this path inside the project this guard belongs to?"""
|
|
233
|
+
try:
|
|
234
|
+
real = os.path.realpath(absolute)
|
|
235
|
+
except OSError:
|
|
236
|
+
real = absolute
|
|
237
|
+
if _CASE_BLIND:
|
|
238
|
+
real, root = real.lower(), PROJECT.lower()
|
|
239
|
+
else:
|
|
240
|
+
root = PROJECT
|
|
241
|
+
return real == root or real.startswith(root.rstrip("/") + "/")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def is_protected(path, holder=False):
|
|
245
|
+
"""Which protected thing this path is, or None.
|
|
246
|
+
|
|
247
|
+
A path that does not exist yet is not protected. Disarming means altering
|
|
248
|
+
or removing enforcement that is already there — creating a new guard or a
|
|
249
|
+
new check removes nothing, and the integrity check reports the addition, so
|
|
250
|
+
it is never silent.
|
|
251
|
+
|
|
252
|
+
This was relaxed after the guard refused the creation of the next guard.
|
|
253
|
+
Left strict, the protection would have had to be switched off during
|
|
254
|
+
exactly the work most likely to damage it.
|
|
255
|
+
"""
|
|
256
|
+
if not path:
|
|
257
|
+
return None
|
|
258
|
+
p = path.strip().strip("'\"")
|
|
259
|
+
|
|
260
|
+
# A glob never exists on disk, so every path test below said "not
|
|
261
|
+
# protected" — and `rm formwork/guard/*` deleted every guard. Match the
|
|
262
|
+
# pattern against the protected list before asking the filesystem.
|
|
263
|
+
if any(ch in p for ch in "*?["):
|
|
264
|
+
# `rm -rf *` and `rm -rf /abs/path/formwork/guard/*` were both
|
|
265
|
+
# allowed: the first has no stem to compare, the second never matched
|
|
266
|
+
# a relative entry. Match the pattern itself against each protected
|
|
267
|
+
# path and every parent of it.
|
|
268
|
+
pat = p.lstrip("./")
|
|
269
|
+
for prefix in PROTECTED + PROTECTED_HOME:
|
|
270
|
+
tail = prefix.rstrip("/")
|
|
271
|
+
parts = tail.split("/")
|
|
272
|
+
candidates = ["/".join(parts[:i + 1]) for i in range(len(parts))]
|
|
273
|
+
candidates.append(os.path.basename(tail))
|
|
274
|
+
for c in candidates:
|
|
275
|
+
if fnmatch.fnmatch(c, pat) or fnmatch.fnmatch(c, pat.lstrip("/")):
|
|
276
|
+
return prefix
|
|
277
|
+
# An absolute or deeper pattern: compare on the tail.
|
|
278
|
+
if pat.endswith("/*") and (pat[:-2].endswith("/" + c)
|
|
279
|
+
or pat[:-2].endswith(c)):
|
|
280
|
+
return prefix
|
|
281
|
+
stem = re.split(r"[*?\[]", pat, maxsplit=1)[0]
|
|
282
|
+
if stem and (tail.startswith(stem.lstrip("/"))
|
|
283
|
+
or stem.lstrip("/").startswith(tail)):
|
|
284
|
+
return prefix
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
# The word lists and the integrity record are protected by name, whether
|
|
288
|
+
# or not they exist yet. A record directory that has not been created is
|
|
289
|
+
# still the thing a later command would destroy.
|
|
290
|
+
expanded = os.path.abspath(os.path.expanduser(p)).replace("\\", "/")
|
|
291
|
+
for h in PROTECTED_HOME:
|
|
292
|
+
hn = h.replace("/", "/")
|
|
293
|
+
if expanded.endswith("/" + hn) or expanded.rstrip("/").endswith("/" + hn) \
|
|
294
|
+
or p.rstrip("/").endswith(hn):
|
|
295
|
+
return h
|
|
296
|
+
|
|
297
|
+
if not os.path.exists(os.path.abspath(os.path.expanduser(p))):
|
|
298
|
+
return None
|
|
299
|
+
home = os.path.expanduser("~")
|
|
300
|
+
absolute = os.path.abspath(os.path.expanduser(p))
|
|
301
|
+
|
|
302
|
+
# Matched by suffix rather than against this user's home. The word lists
|
|
303
|
+
# are protected wherever they live, including in somebody else's home on a
|
|
304
|
+
# shared machine, and including when reached by a relative path.
|
|
305
|
+
if STATE_DIR:
|
|
306
|
+
state = os.path.abspath(os.path.expanduser(STATE_DIR))
|
|
307
|
+
for h in PROTECTED_HOME:
|
|
308
|
+
leaf = os.path.basename(h)
|
|
309
|
+
if absolute == os.path.join(state, leaf) or \
|
|
310
|
+
absolute.startswith(state + os.sep) and \
|
|
311
|
+
os.path.basename(absolute) == leaf:
|
|
312
|
+
return h
|
|
313
|
+
for h in PROTECTED_HOME:
|
|
314
|
+
if absolute.endswith(os.sep + h.replace("/", os.sep)) or p.endswith(h):
|
|
315
|
+
return h
|
|
316
|
+
if absolute == os.path.join(home, h):
|
|
317
|
+
return h
|
|
318
|
+
# Compare on the tail, so both a relative and an absolute path match.
|
|
319
|
+
#
|
|
320
|
+
# Case-folded. On macOS and Windows the filesystem does not care about
|
|
321
|
+
# case, so `formwork/GUARD/git-boundary` opens the real guard while a
|
|
322
|
+
# case-sensitive string compare said it was something else entirely. That
|
|
323
|
+
# was a master key to every protected file.
|
|
324
|
+
norm = absolute.replace("\\", "/")
|
|
325
|
+
if _CASE_BLIND:
|
|
326
|
+
norm = norm.lower()
|
|
327
|
+
p = p.lower()
|
|
328
|
+
# Only files inside this project. The word lists above are matched
|
|
329
|
+
# wherever they live, because they are one person's and follow them.
|
|
330
|
+
if not inside_project(absolute):
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
for prefix in PROTECTED:
|
|
334
|
+
tail = prefix.rstrip("/")
|
|
335
|
+
cmp_tail = tail.lower() if _CASE_BLIND else tail
|
|
336
|
+
# Boundary aware. `.formwork.toml.bak` and `run.orig` are the files a
|
|
337
|
+
# merge leaves behind, and refusing them was a false positive.
|
|
338
|
+
if norm == cmp_tail or norm.endswith("/" + cmp_tail) \
|
|
339
|
+
or ("/" + cmp_tail + "/") in norm + "/":
|
|
340
|
+
return prefix
|
|
341
|
+
# Boundary-aware: a file whose name merely starts with a protected
|
|
342
|
+
# path is a different file.
|
|
343
|
+
cmp_prefix = prefix.lower() if _CASE_BLIND else prefix
|
|
344
|
+
if p == cmp_prefix or p.rstrip("/") == cmp_prefix.rstrip("/") \
|
|
345
|
+
or p.startswith(cmp_prefix.rstrip("/") + "/"):
|
|
346
|
+
return prefix
|
|
347
|
+
|
|
348
|
+
# A directory that CONTAINS protected things is protected too — but only
|
|
349
|
+
# against being destroyed or moved. Copying FROM it, or listing it, is
|
|
350
|
+
# ordinary work, and refusing `cp -R . /tmp/copy` was a false positive
|
|
351
|
+
# this guard produced within a minute of the rule being added.
|
|
352
|
+
if holder and os.path.isdir(absolute):
|
|
353
|
+
for prefix in PROTECTED:
|
|
354
|
+
candidate = os.path.join(absolute, *prefix.rstrip("/").split("/"))
|
|
355
|
+
if os.path.exists(candidate):
|
|
356
|
+
return "the directory holding %s" % prefix
|
|
357
|
+
for prefix in PROTECTED:
|
|
358
|
+
tail = prefix.rstrip("/").split("/")
|
|
359
|
+
for i in range(1, len(tail)):
|
|
360
|
+
if norm.endswith("/" + "/".join(tail[:i])):
|
|
361
|
+
return "the directory holding %s" % prefix
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def tokens(segment):
|
|
366
|
+
return [t for t in segment.split() if t]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def strip_prefixes(words):
|
|
370
|
+
"""Drop leading wrappers and shell grouping to reach the real command.
|
|
371
|
+
|
|
372
|
+
`nice -n 5 rm <guard>` was allowed, because this stopped at `-n`.
|
|
373
|
+
git-boundary was hardened against that and this file was not — the tests
|
|
374
|
+
only covered the other one, which is how the fix landed in one place.
|
|
375
|
+
"""
|
|
376
|
+
i = 0
|
|
377
|
+
while i < len(words):
|
|
378
|
+
w = words[i]
|
|
379
|
+
if w in GROUPING:
|
|
380
|
+
i += 1
|
|
381
|
+
continue
|
|
382
|
+
if ASSIGNMENT.match(w) or w in PREFIXES:
|
|
383
|
+
i += 1
|
|
384
|
+
takes = WRAPPER_VALUE_FLAGS.get(w, set())
|
|
385
|
+
while i < len(words) and words[i].startswith("-"):
|
|
386
|
+
takes_value = words[i] in takes
|
|
387
|
+
i += 1
|
|
388
|
+
if takes_value and i < len(words):
|
|
389
|
+
i += 1
|
|
390
|
+
if i < len(words) and words[i].isdigit():
|
|
391
|
+
i += 1
|
|
392
|
+
continue
|
|
393
|
+
break
|
|
394
|
+
return words[i:]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def verdict_for(segment):
|
|
398
|
+
"""A reason to refuse this segment, or None."""
|
|
399
|
+
# A redirect into a protected path is a write whatever the program is.
|
|
400
|
+
for _, target in REDIRECT.findall(segment):
|
|
401
|
+
hit = is_protected(target)
|
|
402
|
+
if hit:
|
|
403
|
+
return "writing to %s" % hit
|
|
404
|
+
|
|
405
|
+
words = strip_prefixes(tokens(segment))
|
|
406
|
+
if not words:
|
|
407
|
+
return None
|
|
408
|
+
program = os.path.basename(words[0].strip("'\""))
|
|
409
|
+
|
|
410
|
+
# Running a protected program is exactly what it is for — with one
|
|
411
|
+
# exception. The integrity check can rewrite the record it checks
|
|
412
|
+
# against, which disarms it, so that one is a decision for the human.
|
|
413
|
+
# Re-recording, however it is spelled. This runs before every exemption
|
|
414
|
+
# below, because `python3 formwork/fw record` and
|
|
415
|
+
# `uv run <kit>/kit-integrity --record .` both reached the fingerprints
|
|
416
|
+
# through the interpreter exemption.
|
|
417
|
+
rerecords = ("record" in words[1:] and
|
|
418
|
+
any(os.path.basename(w.rstrip("/")) in ("formwork", "fw")
|
|
419
|
+
for w in words)) or \
|
|
420
|
+
("--record" in words[1:] and
|
|
421
|
+
any(os.path.basename(w.rstrip("/")) == "kit-integrity"
|
|
422
|
+
for w in words))
|
|
423
|
+
if rerecords:
|
|
424
|
+
return ("re-recording the integrity fingerprints. That tells the kit "
|
|
425
|
+
"every current file is the intended one, so it is a decision, "
|
|
426
|
+
"not a step. Run it yourself")
|
|
427
|
+
|
|
428
|
+
if program in ("formwork", "fw") and "record" in words[1:]:
|
|
429
|
+
return ("re-recording the integrity fingerprints. That tells the kit "
|
|
430
|
+
"every current file is the intended one, so it is a decision, "
|
|
431
|
+
"not a step. Run it yourself")
|
|
432
|
+
|
|
433
|
+
if is_protected(words[0]):
|
|
434
|
+
if os.path.basename(words[0].rstrip("/")) == "kit-integrity" and \
|
|
435
|
+
"--record" in words[1:]:
|
|
436
|
+
return ("re-recording the integrity fingerprints. That tells the "
|
|
437
|
+
"kit every current file is the intended one, so it is a "
|
|
438
|
+
"decision, not a step. The installer takes the FIRST "
|
|
439
|
+
"record; every later one is yours to run by hand")
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
# An interpreter given a protected file as its argument is running it, not
|
|
443
|
+
# writing to it. `python3 formwork/check/run` was refused as a write.
|
|
444
|
+
if program in INTERPRETERS:
|
|
445
|
+
for w in words[1:]:
|
|
446
|
+
if w.startswith("-"):
|
|
447
|
+
continue
|
|
448
|
+
if is_protected(w):
|
|
449
|
+
return None
|
|
450
|
+
break
|
|
451
|
+
if program in READERS:
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
if program == "chmod":
|
|
455
|
+
if not chmod_disarms(words):
|
|
456
|
+
return None
|
|
457
|
+
for w in words[1:]:
|
|
458
|
+
hit = is_protected(w)
|
|
459
|
+
if hit:
|
|
460
|
+
return "chmod would take permissions away from %s" % hit
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
if program in CONDITIONAL_WRITERS:
|
|
464
|
+
in_place = any(w == f or w.startswith(f)
|
|
465
|
+
for w in words[1:]
|
|
466
|
+
for f in CONDITIONAL_WRITERS[program])
|
|
467
|
+
if not in_place:
|
|
468
|
+
return None # reading, not writing
|
|
469
|
+
for w in words[1:]:
|
|
470
|
+
hit = is_protected(w)
|
|
471
|
+
if hit:
|
|
472
|
+
return "%s in place would change %s" % (program, hit)
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
if program in WRITERS:
|
|
476
|
+
# Only these remove or relocate what is already there.
|
|
477
|
+
destroys = program in DESTROYERS
|
|
478
|
+
operands = [w for w in words[1:] if not w.startswith("-")]
|
|
479
|
+
# An interpreter handed a protected FILE is running it. The inline
|
|
480
|
+
# case is caught below, where it belongs. Without this,
|
|
481
|
+
# `python3 -m pytest <a guard test>` was refused as a write.
|
|
482
|
+
if program in INTERPRETERS and not any(
|
|
483
|
+
w in ("-c", "-e", "--command", "--eval") for w in words[1:]):
|
|
484
|
+
operands = []
|
|
485
|
+
# For a copy, only the destination is written. Refusing
|
|
486
|
+
# `cp <a guard> /tmp/backup` refused taking a backup before editing,
|
|
487
|
+
# which is the thing this guard most wants somebody to do.
|
|
488
|
+
if program == "cp" and len(operands) >= 2:
|
|
489
|
+
operands = operands[-1:]
|
|
490
|
+
# `dd of=<path>` hides its target inside a token, so the plain
|
|
491
|
+
# operand scan never saw it.
|
|
492
|
+
for w in words[1:]:
|
|
493
|
+
if "=" in w and w.split("=", 1)[0] in ("of", "out", "output"):
|
|
494
|
+
hit = is_protected(w.split("=", 1)[1])
|
|
495
|
+
if hit:
|
|
496
|
+
return "%s would write to %s" % (program, hit)
|
|
497
|
+
for w in operands:
|
|
498
|
+
hit = is_protected(w, holder=destroys)
|
|
499
|
+
if hit:
|
|
500
|
+
return "%s would change %s" % (program, hit)
|
|
501
|
+
# An interpreter given a script INLINE can write anything, so a
|
|
502
|
+
# protected path inside one is refused. Running a FILE is not that:
|
|
503
|
+
# `python3 -m pytest <a test file>` was refused, which meant the
|
|
504
|
+
# guards could not be tested.
|
|
505
|
+
inline = any(w in ("-c", "-e", "--command", "--eval") for w in words[1:])
|
|
506
|
+
if inline and program in ("python", "python3", "perl", "ruby", "node"):
|
|
507
|
+
for prefix in PROTECTED + PROTECTED_HOME:
|
|
508
|
+
if prefix.rstrip("/") in segment:
|
|
509
|
+
return "%s mentions %s in an inline script" % (
|
|
510
|
+
program, prefix.rstrip("/"))
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# A heredoc body is data only when it is being written somewhere. Fed to a
|
|
515
|
+
# shell or an interpreter it is code, and stripping it hid the command
|
|
516
|
+
# completely. This was introduced by the fix for the opposite false positive,
|
|
517
|
+
# which is a good reminder that a guard change needs its own test.
|
|
518
|
+
# Does this line hand the heredoc to something that will RUN it?
|
|
519
|
+
#
|
|
520
|
+
# The first version was a regular expression with an unparenthesised
|
|
521
|
+
# alternation. Only the shell branch was anchored, so `cat > retrieval.md` was
|
|
522
|
+
# read as `eval` and refused, while `sudo -n bash` was not read as a shell at
|
|
523
|
+
# all and walked through. Tokenising is slower and correct.
|
|
524
|
+
EXECUTES_STDIN = {"sh", "bash", "zsh", "ksh", "dash", "eval", "python",
|
|
525
|
+
"python3", "perl", "ruby", "node", "uv", "xargs"}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _sink_runs_it(line):
|
|
529
|
+
try:
|
|
530
|
+
words = shlex.split(line)
|
|
531
|
+
except ValueError:
|
|
532
|
+
words = tokens(line)
|
|
533
|
+
words = strip_prefixes(words)
|
|
534
|
+
if not words:
|
|
535
|
+
return False
|
|
536
|
+
return os.path.basename(words[0].strip("'\"")) in EXECUTES_STDIN
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def strip_heredocs(command):
|
|
540
|
+
"""Remove heredoc bodies, unless the receiving program can execute them."""
|
|
541
|
+
def keep_or_drop(m):
|
|
542
|
+
before = command[:m.start()]
|
|
543
|
+
line = before.rsplit("\n", 1)[-1]
|
|
544
|
+
# Split on the last separator: `x && bash <<EOF` is a shell sink.
|
|
545
|
+
for sep in ("&&", "||", ";", "|"):
|
|
546
|
+
if sep in line:
|
|
547
|
+
line = line.rsplit(sep, 1)[-1]
|
|
548
|
+
if _sink_runs_it(line):
|
|
549
|
+
return m.group(0) # it is code. Leave it to be inspected.
|
|
550
|
+
return "<<REDACTED"
|
|
551
|
+
return HEREDOC.sub(keep_or_drop, command)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
SUBSHELL = re.compile(r"\$\(([^()]*)\)|`([^`]*)`")
|
|
555
|
+
|
|
556
|
+
# Shells and runners that take a command as an ARGUMENT, and shell grouping
|
|
557
|
+
# words that are not programs at all. git-boundary learned both; this file did
|
|
558
|
+
# not, so `bash -c 'rm <guard>'` and `for f in x; do rm <guard>; done` walked
|
|
559
|
+
# straight through.
|
|
560
|
+
INDIRECT = {"bash", "sh", "zsh", "ksh", "dash", "eval", "xargs", "watch"}
|
|
561
|
+
GROUPING = {"(", "{", "}", ")", "then", "else", "do", "done", "fi", "!",
|
|
562
|
+
"&&", "||", ";", "for", "while", "until", "if", "in", "case",
|
|
563
|
+
"esac", "elif"}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def split_all(command, depth=0):
|
|
567
|
+
"""Every command line in this string, including nested ones."""
|
|
568
|
+
if depth > 4:
|
|
569
|
+
return
|
|
570
|
+
for m in SUBSHELL.finditer(command):
|
|
571
|
+
for seg in split_all(m.group(1) or m.group(2) or "", depth + 1):
|
|
572
|
+
yield seg
|
|
573
|
+
stripped = ungroup(SUBSHELL.sub(" ", command))
|
|
574
|
+
for segment in SPLIT.split(stripped):
|
|
575
|
+
yield segment
|
|
576
|
+
try:
|
|
577
|
+
quoted = shlex.split(segment)
|
|
578
|
+
except ValueError:
|
|
579
|
+
quoted = tokens(segment)
|
|
580
|
+
words = strip_prefixes(quoted)
|
|
581
|
+
if not words:
|
|
582
|
+
continue
|
|
583
|
+
head = os.path.basename(words[0])
|
|
584
|
+
|
|
585
|
+
# `find . -exec <command> ;` runs whatever follows -exec. It was not
|
|
586
|
+
# inspected at all.
|
|
587
|
+
if head == "find":
|
|
588
|
+
for k, w in enumerate(words):
|
|
589
|
+
if w in ("-exec", "-execdir", "-ok", "-okdir"):
|
|
590
|
+
rest = []
|
|
591
|
+
for t in words[k + 1:]:
|
|
592
|
+
if t in (";", "\\\\;", "+"):
|
|
593
|
+
break
|
|
594
|
+
rest.append(t)
|
|
595
|
+
if rest:
|
|
596
|
+
for seg in split_all(" ".join(rest), depth + 1):
|
|
597
|
+
yield seg
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
if head in INDIRECT:
|
|
601
|
+
rest = [w for w in words[1:]]
|
|
602
|
+
# A quoted single argument, as in `sh -c "git push"`.
|
|
603
|
+
first = None
|
|
604
|
+
for w in rest:
|
|
605
|
+
if not w.startswith("-"):
|
|
606
|
+
first = w
|
|
607
|
+
break
|
|
608
|
+
if first is not None:
|
|
609
|
+
for seg in split_all(first, depth + 1):
|
|
610
|
+
yield seg
|
|
611
|
+
# And the same command spread across argv, as in `xargs git push`
|
|
612
|
+
# or `watch -n1 git push`, which was only half read.
|
|
613
|
+
spread = [w for w in rest if not w.startswith("-")]
|
|
614
|
+
if len(spread) > 1:
|
|
615
|
+
for seg in split_all(" ".join(spread), depth + 1):
|
|
616
|
+
yield seg
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def decide(command):
|
|
620
|
+
# The body of a heredoc is text being written, not commands being run.
|
|
621
|
+
# It is stripped so its contents are never read as a command line.
|
|
622
|
+
command = strip_heredocs(command)
|
|
623
|
+
for segment in split_all(command):
|
|
624
|
+
reason = verdict_for(segment)
|
|
625
|
+
if reason:
|
|
626
|
+
return True, reason
|
|
627
|
+
return False, None
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def strength():
|
|
631
|
+
env = os.environ.get("FORMWORK_PROTECT_FILES", "").strip().lower()
|
|
632
|
+
if env in STRENGTHS:
|
|
633
|
+
return env
|
|
634
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
635
|
+
config = os.path.join(root, ".formwork.toml")
|
|
636
|
+
if os.path.exists(config):
|
|
637
|
+
try:
|
|
638
|
+
text = open(config, encoding="utf-8", errors="ignore").read()
|
|
639
|
+
except OSError:
|
|
640
|
+
return "block"
|
|
641
|
+
m = re.search(r'^\s*protect_files\s*=\s*["\']([^"\']+)["\']', text, re.M)
|
|
642
|
+
if m and m.group(1).strip().lower() in STRENGTHS:
|
|
643
|
+
return m.group(1).strip().lower()
|
|
644
|
+
return "block"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
FORMATS = ("claude-code", "codex", "cursor", "gemini-cli")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def from_payload(payload):
|
|
651
|
+
"""(command, file_path), or None when the payload is not a shape we know.
|
|
652
|
+
|
|
653
|
+
None means refuse. A guard that cannot read its input has not established
|
|
654
|
+
that the call is safe, and reporting success would be a lie about work it
|
|
655
|
+
did not do.
|
|
656
|
+
"""
|
|
657
|
+
if not isinstance(payload, dict):
|
|
658
|
+
return None
|
|
659
|
+
box = payload.get("tool_input")
|
|
660
|
+
if box is None:
|
|
661
|
+
box = payload # runtimes that put the fields at the top level
|
|
662
|
+
if not isinstance(box, dict):
|
|
663
|
+
return None
|
|
664
|
+
path = box.get("file_path") or box.get("path") or box.get("notebook_path")
|
|
665
|
+
command = box.get("command")
|
|
666
|
+
if command is None:
|
|
667
|
+
command = box.get("cmd")
|
|
668
|
+
# Present and not a string means a shape this guard does not understand.
|
|
669
|
+
# Coercing it to "" meant an argv array was waved through in silence.
|
|
670
|
+
if command is not None and not isinstance(command, str):
|
|
671
|
+
return None
|
|
672
|
+
if path is not None and not isinstance(path, str):
|
|
673
|
+
return None
|
|
674
|
+
return command or "", path or ""
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def main(argv):
|
|
678
|
+
fmt = command = path = None
|
|
679
|
+
i = 1
|
|
680
|
+
while i < len(argv):
|
|
681
|
+
if argv[i] == "--format" and i + 1 < len(argv):
|
|
682
|
+
fmt = argv[i + 1]; i += 2; continue
|
|
683
|
+
if argv[i] == "--command" and i + 1 < len(argv):
|
|
684
|
+
command = argv[i + 1]; i += 2; continue
|
|
685
|
+
if argv[i] == "--path" and i + 1 < len(argv):
|
|
686
|
+
path = argv[i + 1]; i += 2; continue
|
|
687
|
+
i += 1
|
|
688
|
+
|
|
689
|
+
if command is None and path is None:
|
|
690
|
+
if fmt is None:
|
|
691
|
+
print("ERROR: give --command, --path, or --format with a payload",
|
|
692
|
+
file=sys.stderr)
|
|
693
|
+
return REFUSE
|
|
694
|
+
if fmt not in FORMATS:
|
|
695
|
+
print("ERROR: unknown runtime format: %s" % fmt, file=sys.stderr)
|
|
696
|
+
print(" Refusing rather than guessing.", file=sys.stderr)
|
|
697
|
+
return REFUSE
|
|
698
|
+
raw = sys.stdin.read()
|
|
699
|
+
if not raw.strip():
|
|
700
|
+
print("ERROR: no payload arrived on stdin.", file=sys.stderr)
|
|
701
|
+
print(" Refusing. This usually means the hook is wired up "
|
|
702
|
+
"wrongly.", file=sys.stderr)
|
|
703
|
+
return REFUSE
|
|
704
|
+
try:
|
|
705
|
+
payload = json.loads(raw)
|
|
706
|
+
except ValueError as e:
|
|
707
|
+
print("ERROR: payload is not readable: %s" % e, file=sys.stderr)
|
|
708
|
+
return REFUSE
|
|
709
|
+
got = from_payload(payload)
|
|
710
|
+
if got is None:
|
|
711
|
+
print("ERROR: payload is not a shape this guard understands.",
|
|
712
|
+
file=sys.stderr)
|
|
713
|
+
print(" Refusing rather than allowing something unread.",
|
|
714
|
+
file=sys.stderr)
|
|
715
|
+
return REFUSE
|
|
716
|
+
command, path = got
|
|
717
|
+
|
|
718
|
+
reason = None
|
|
719
|
+
if path:
|
|
720
|
+
hit = is_protected(path)
|
|
721
|
+
if hit:
|
|
722
|
+
reason = "editing %s" % hit
|
|
723
|
+
if reason is None and command:
|
|
724
|
+
refuse, why = decide(command)
|
|
725
|
+
reason = why if refuse else None
|
|
726
|
+
|
|
727
|
+
if reason is None:
|
|
728
|
+
return ALLOW
|
|
729
|
+
|
|
730
|
+
level = strength()
|
|
731
|
+
if level == "off":
|
|
732
|
+
return ALLOW
|
|
733
|
+
if level == "warn":
|
|
734
|
+
print("Self-protection would have refused this: %s." % reason,
|
|
735
|
+
file=sys.stderr)
|
|
736
|
+
print("It is set to warn, so the change went ahead.", file=sys.stderr)
|
|
737
|
+
return WARN
|
|
738
|
+
|
|
739
|
+
print("REFUSED by self-protection: %s." % reason, file=sys.stderr)
|
|
740
|
+
print("These files are what enforce every other rule. Changing one is a "
|
|
741
|
+
"decision, so the human makes it.", file=sys.stderr)
|
|
742
|
+
print("To change this for one session: FORMWORK_PROTECT_FILES=warn, or off.",
|
|
743
|
+
file=sys.stderr)
|
|
744
|
+
return REFUSE
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
if __name__ == "__main__":
|
|
748
|
+
sys.exit(main(sys.argv))
|