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.
Files changed (137) hide show
  1. formwork_cli/__init__.py +326 -0
  2. formwork_cli/kit/COSTS.md +111 -0
  3. formwork_cli/kit/adapters/claude-code/README.md +53 -0
  4. formwork_cli/kit/adapters/claude-code/settings.json +46 -0
  5. formwork_cli/kit/adapters/codex/README.md +43 -0
  6. formwork_cli/kit/adapters/cursor/README.md +45 -0
  7. formwork_cli/kit/adapters/gemini-cli/README.md +47 -0
  8. formwork_cli/kit/build +410 -0
  9. formwork_cli/kit/check/checks/config-shape +123 -0
  10. formwork_cli/kit/check/checks/decision-ids +159 -0
  11. formwork_cli/kit/check/checks/doc-links +133 -0
  12. formwork_cli/kit/check/checks/generated-current +74 -0
  13. formwork_cli/kit/check/checks/guard-wired +139 -0
  14. formwork_cli/kit/check/checks/kit-integrity +199 -0
  15. formwork_cli/kit/check/checks/predictions-first +127 -0
  16. formwork_cli/kit/check/checks/role-shape +172 -0
  17. formwork_cli/kit/check/checks/rule-labels +135 -0
  18. formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/.formwork.toml +5 -0
  19. formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/formwork/guide.md +13 -0
  20. formwork_cli/kit/check/fixtures/config-shape/must-fail/rules-as-a-switchboard/.formwork.toml +8 -0
  21. formwork_cli/kit/check/fixtures/config-shape/must-pass/layers-kept-apart/.formwork.toml +5 -0
  22. formwork_cli/kit/check/fixtures/decision-ids/must-fail/a-placeholder-shipped/docs/decisions/0003-still-pending.md +7 -0
  23. formwork_cli/kit/check/fixtures/decision-ids/must-fail/superseded-by-nothing/docs/decisions/0002-old.md +6 -0
  24. formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-first.md +6 -0
  25. formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-second.md +6 -0
  26. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0001-the-first.md +6 -0
  27. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0002-the-second.md +6 -0
  28. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0003-the-third.md +6 -0
  29. formwork_cli/kit/check/fixtures/decision-ids/must-pass/nothing-recorded-yet/docs/decisions/README.md +3 -0
  30. formwork_cli/kit/check/fixtures/doc-links/must-fail/never-written/index.md +7 -0
  31. formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/architecture-notes.md +3 -0
  32. formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/guide.md +8 -0
  33. formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/architecture-notes.md +1 -0
  34. formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/guide.md +5 -0
  35. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.claude/agents/sample.md +22 -0
  36. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.codex/agents/sample.toml +22 -0
  37. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.formwork.toml +1 -0
  38. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.gemini/agents/sample.md +23 -0
  39. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/build +349 -0
  40. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/roles/method/sample.md +18 -0
  41. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.claude/agents/sample.md +20 -0
  42. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.codex/agents/sample.toml +22 -0
  43. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.formwork.toml +1 -0
  44. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.gemini/agents/sample.md +23 -0
  45. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/build +349 -0
  46. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/roles/method/sample.md +18 -0
  47. formwork_cli/kit/check/fixtures/generated-current/must-pass/nothing-is-generated-here/README.md +3 -0
  48. formwork_cli/kit/check/fixtures/guard-wired/must-fail/declared-but-no-file/.formwork.toml +1 -0
  49. formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.claude/settings.json +1 -0
  50. formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.formwork.toml +1 -0
  51. formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.claude/settings.json +1 -0
  52. formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.formwork.toml +1 -0
  53. formwork_cli/kit/check/fixtures/guard-wired/must-pass/nothing-declared/README.md +1 -0
  54. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/formwork/check/checks/still-here +2 -0
  55. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/state/fingerprints.txt +2 -0
  56. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/formwork/guard/git-boundary +3 -0
  57. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/state/fingerprints.txt +1 -0
  58. formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/formwork/guard/git-boundary +2 -0
  59. formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/state/fingerprints.txt +1 -0
  60. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/architect.md +3 -0
  61. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/researcher.md +3 -0
  62. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/round.md +4 -0
  63. 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
  64. formwork_cli/kit/check/fixtures/predictions-first/must-pass/no-rounds-at-all/docs/README.md +3 -0
  65. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/architect.md +3 -0
  66. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/predictions.md +4 -0
  67. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/researcher.md +3 -0
  68. formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/README.md +6 -0
  69. formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/complete.md +18 -0
  70. formwork_cli/kit/check/fixtures/role-shape/must-fail/missing-a-section/formwork/roles/vague.md +16 -0
  71. formwork_cli/kit/check/fixtures/role-shape/must-fail/spawn-without-being-lead/formwork/roles/eager.md +18 -0
  72. formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/first.md +18 -0
  73. formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/second.md +18 -0
  74. formwork_cli/kit/check/fixtures/role-shape/must-pass/well-formed/formwork/roles/complete.md +18 -0
  75. formwork_cli/kit/check/fixtures/rule-labels/must-fail/claims-enforcement-that-does-not-exist/formwork/rules/core.md +9 -0
  76. formwork_cli/kit/check/fixtures/rule-labels/must-fail/no-catches/formwork/rules/core.md +9 -0
  77. formwork_cli/kit/check/fixtures/rule-labels/must-fail/unlabelled/formwork/rules/core.md +7 -0
  78. formwork_cli/kit/check/fixtures/rule-labels/must-pass/well-formed/formwork/rules/core.md +10 -0
  79. formwork_cli/kit/check/run +340 -0
  80. formwork_cli/kit/check/test_gate.py +222 -0
  81. formwork_cli/kit/first-run.md +204 -0
  82. formwork_cli/kit/fw +121 -0
  83. formwork_cli/kit/glossary.md +160 -0
  84. formwork_cli/kit/guard/git-boundary +627 -0
  85. formwork_cli/kit/guard/protected-files +748 -0
  86. formwork_cli/kit/guard/quality-gate +260 -0
  87. formwork_cli/kit/guard/test_boundary.py +273 -0
  88. formwork_cli/kit/guard/test_protection.py +254 -0
  89. formwork_cli/kit/guard/test_quality_gate.py +156 -0
  90. formwork_cli/kit/install +395 -0
  91. formwork_cli/kit/limits.md +141 -0
  92. formwork_cli/kit/loop.md +82 -0
  93. formwork_cli/kit/roles/HOW-TO-ADD-A-ROLE.md +105 -0
  94. formwork_cli/kit/roles/TEMPLATE.md +26 -0
  95. formwork_cli/kit/roles/method/architect.md +269 -0
  96. formwork_cli/kit/roles/method/challenger.md +243 -0
  97. formwork_cli/kit/roles/method/lead.md +280 -0
  98. formwork_cli/kit/roles/method/record-keeper.md +206 -0
  99. formwork_cli/kit/roles/method/researcher.md +246 -0
  100. formwork_cli/kit/roles/method/reviewer.md +207 -0
  101. formwork_cli/kit/roles/packs/accessibility.md +236 -0
  102. formwork_cli/kit/roles/packs/ai.md +248 -0
  103. formwork_cli/kit/roles/packs/analyst.md +233 -0
  104. formwork_cli/kit/roles/packs/backend.md +425 -0
  105. formwork_cli/kit/roles/packs/brainstormer.md +190 -0
  106. formwork_cli/kit/roles/packs/data.md +212 -0
  107. formwork_cli/kit/roles/packs/devops.md +203 -0
  108. formwork_cli/kit/roles/packs/frontend.md +224 -0
  109. formwork_cli/kit/roles/packs/integrations.md +215 -0
  110. formwork_cli/kit/roles/packs/legal.md +251 -0
  111. formwork_cli/kit/roles/packs/marketing.md +206 -0
  112. formwork_cli/kit/roles/packs/mobile.md +202 -0
  113. formwork_cli/kit/roles/packs/performance.md +192 -0
  114. formwork_cli/kit/roles/packs/product.md +217 -0
  115. formwork_cli/kit/roles/packs/security.md +267 -0
  116. formwork_cli/kit/roles/packs/sre.md +203 -0
  117. formwork_cli/kit/roles/packs/tester.md +246 -0
  118. formwork_cli/kit/roles/packs/user-researcher.md +218 -0
  119. formwork_cli/kit/roles/packs/ux.md +205 -0
  120. formwork_cli/kit/roles/packs/visual.md +199 -0
  121. formwork_cli/kit/roles/packs/writer.md +198 -0
  122. formwork_cli/kit/round.md +131 -0
  123. formwork_cli/kit/rules/core.md +195 -0
  124. formwork_cli/kit/rules/full.md +493 -0
  125. formwork_cli/kit/templates/brief.md +68 -0
  126. formwork_cli/kit/templates/decision.md +93 -0
  127. formwork_cli/kit/templates/predictions.md +54 -0
  128. formwork_cli/kit/templates/report.md +52 -0
  129. formwork_cli/kit/templates/round.md +77 -0
  130. formwork_cli/kit/test_install.py +165 -0
  131. formwork_cli/kit/troubleshooting.md +247 -0
  132. formwork_cli/kit-page/FORMWORK.md +182 -0
  133. formwork_kit-0.1.0.dist-info/METADATA +308 -0
  134. formwork_kit-0.1.0.dist-info/RECORD +137 -0
  135. formwork_kit-0.1.0.dist-info/WHEEL +4 -0
  136. formwork_kit-0.1.0.dist-info/entry_points.txt +2 -0
  137. 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))