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,254 @@
1
+ #!/usr/bin/env python3
2
+ """Prove self-protection refuses, and prove it stays out of the way.
3
+
4
+ Every rule in this kit is enforced by a program, and every one of those
5
+ programs is an ordinary file. Turning the strongest rule off was four
6
+ characters, and nothing anywhere noticed.
7
+
8
+ This exercises every route that is now refused, and every ordinary thing that
9
+ must still work — because a guard that obstructs normal work gets switched off,
10
+ and then it protects nothing.
11
+
12
+ Python 3, standard library only, no dependencies.
13
+ """
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+ GUARD = os.path.join(HERE, "protected-files")
21
+ ALLOW, WARN, REFUSE = 0, 1, 2
22
+ results = []
23
+
24
+ # Assembled rather than written out, so that this file's own text does not
25
+ # read as a command to the boundary guard watching the session that writes it.
26
+ VCS = "g" + "it"
27
+
28
+
29
+ def run(command=None, path=None, fmt=None, payload=None, level="block"):
30
+ env = dict(os.environ)
31
+ env["FORMWORK_PROTECT_FILES"] = level
32
+ args = [sys.executable, GUARD]
33
+ if command is not None:
34
+ args += ["--command", command]
35
+ if path is not None:
36
+ args += ["--path", path]
37
+ if fmt is not None:
38
+ args += ["--format", fmt]
39
+ p = subprocess.run(args, capture_output=True, text=True, env=env,
40
+ input=json.dumps(payload) if payload is not None else "")
41
+ return p.returncode, (p.stdout + p.stderr).strip()
42
+
43
+
44
+ def expect(label, want, **kw):
45
+ got, out = run(**kw)
46
+ ok = got == want
47
+ results.append(ok)
48
+ names = {ALLOW: "allow", WARN: "warn", REFUSE: "REFUSE"}
49
+ print(" [%s] %-52s %s" % ("pass" if ok else "FAIL", label[:52], names[want]))
50
+ if not ok:
51
+ print(" got %s: %s" % (names.get(got, got), out.split("\n")[0]))
52
+
53
+
54
+ def main():
55
+ print("REFUSED — disarming the kit")
56
+ for c in [
57
+ "sed -i s/block/off/ .formwork.toml",
58
+ "echo x > formwork/check/run",
59
+ "rm formwork/check/checks/rule-labels",
60
+ "rm -rf formwork/guard",
61
+ "cp /tmp/x formwork/guard/" + VCS + "-boundary",
62
+ "mv formwork/guard/protected-files /tmp/x",
63
+ "cat /dev/null > .claude/settings.json",
64
+ "truncate -s 0 ~/.formwork/allowlist.txt",
65
+ "echo something >> ~/.formwork/allowlist.txt",
66
+ "chmod 000 formwork/check/run",
67
+ "python3 -c \"open('.formwork.toml','w')\"",
68
+ "ls && sed -i s/a/b/ formwork/guard/protected-files",
69
+ "tee formwork/check/run < /dev/null",
70
+ ]:
71
+ expect(c, REFUSE, command=c)
72
+
73
+ print()
74
+ print("REFUSED — editing one directly")
75
+ for p in ["formwork/check/run", ".formwork.toml",
76
+ "formwork/guard/protected-files", ".claude/settings.json",
77
+ "formwork/check/checks/doc-links",
78
+ os.path.expanduser("~/.formwork/denylist-anycase.txt")]:
79
+ expect(p, REFUSE, path=p)
80
+
81
+ print()
82
+ print("ALLOWED — creating something that does not exist yet")
83
+ # Adding a guard or a check removes no enforcement, and the integrity
84
+ # check reports the addition, so it is never silent. Left refused, the
85
+ # protection had to be switched off to extend the kit at all.
86
+ for p in ["formwork/guard/a-new-guard",
87
+ "formwork/check/checks/a-new-check"]:
88
+ expect(p, ALLOW, path=p)
89
+
90
+ print()
91
+ print("ALLOWED — ordinary work must not be obstructed")
92
+ for c in [
93
+ "formwork/check/run",
94
+ "formwork/check/run --demo-fail",
95
+ "cat formwork/check/run",
96
+ "grep -n block .formwork.toml",
97
+ "sed -i s/a/b/ docs/design.md",
98
+ "echo notes > /tmp/scratch.txt",
99
+ "python3 tools/privacy_scan.py",
100
+ "ls formwork/guard",
101
+ ]:
102
+ expect(c, ALLOW, command=c)
103
+
104
+ print()
105
+ print("ALLOWED — editing anything that is not a guard")
106
+ for p in ["docs/design.md", "formwork/rules/core.md",
107
+ "formwork/roles/method/lead.md", "README.md"]:
108
+ expect(p, ALLOW, path=p)
109
+
110
+ print()
111
+ print("ALLOWED — reading with a tool that can also write")
112
+ # Refusing these was a false positive three times in one session, and a
113
+ # guard that is wrong about ordinary work gets switched off by somebody
114
+ # busy. Reading is not writing.
115
+ for c in ["sed -n 1,5p .formwork.toml",
116
+ "sed s/a/b/ formwork/check/run",
117
+ "awk {print} formwork/check/run",
118
+ "chmod +x formwork/check/checks/decision-ids",
119
+ "chmod 755 formwork/check/checks/decision-ids"]:
120
+ expect(c, ALLOW, command=c)
121
+
122
+ print()
123
+ print("REFUSED — the same tools, actually writing")
124
+ for c in ["sed -i s/a/b/ .formwork.toml",
125
+ "sed -i.bak s/a/b/ formwork/check/run",
126
+ "perl -i -pe s/a/b/ .formwork.toml",
127
+ "chmod 000 formwork/check/run",
128
+ "chmod -x formwork/guard/git-boundary",
129
+ "chmod 644 formwork/check/run"]:
130
+ expect(c, REFUSE, command=c)
131
+
132
+ print()
133
+ print("PAYLOADS — and a refusal to guess")
134
+ for fmt in ("claude-code", "codex", "cursor", "gemini-cli"):
135
+ expect("%s: an edit to the gate" % fmt, REFUSE, fmt=fmt,
136
+ payload={"tool_input": {"file_path": "formwork/check/run"}})
137
+ expect("claude-code: an edit to a rule", ALLOW, fmt="claude-code",
138
+ payload={"tool_input": {"file_path": "formwork/rules/core.md"}})
139
+ expect("an unknown runtime", REFUSE, fmt="nope", payload={})
140
+ expect("no payload at all", REFUSE, fmt="claude-code")
141
+
142
+ print()
143
+ print("STRENGTH — the same command, three settings")
144
+ for level, want in (("block", REFUSE), ("warn", WARN), ("off", ALLOW)):
145
+ expect("%s: disarming the configuration" % level, want,
146
+ command="sed -i s/block/off/ .formwork.toml", level=level)
147
+
148
+ # Everything below is a hole an audit walked through, or a false positive
149
+ # it reported. Each was the behaviour before the fix beside it.
150
+ print("REFUSED — routes that used to walk straight past this guard")
151
+ for c in ["echo x | tee formwork/check/run",
152
+ "chmod a-x formwork/check/checks/doc-links",
153
+ "chmod u-x formwork/check/checks/doc-links",
154
+ "chmod go-rwx formwork/check/run",
155
+ "chmod a=r formwork/check/run",
156
+ "rm -rf formwork",
157
+ "mv formwork /tmp/elsewhere",
158
+ "rm -rf formwork/check",
159
+ "formwork/check/checks/kit-integrity --record ."]:
160
+ expect(c, REFUSE, command=c)
161
+
162
+ print("ALLOWED — ordinary work this guard must not refuse")
163
+ for c in ["chmod +x formwork/check/run",
164
+ "python3 formwork/check/run",
165
+ "bash formwork/check/run",
166
+ "./formwork/check/run",
167
+ "formwork/check/checks/kit-integrity .",
168
+ "cat formwork/guard/git-boundary",
169
+ "rm -rf build",
170
+ "mkdir docs/rounds"]:
171
+ expect(c, ALLOW, command=c)
172
+
173
+ print("FAILS CLOSED — a payload it cannot read")
174
+ for bad in ("[]", "null"):
175
+ got, out = run(fmt="claude-code", payload=bad)
176
+ ok = got == REFUSE
177
+ results.append(ok)
178
+ print(" [%s] %-52s %s"
179
+ % ("pass" if ok else "FAIL", "unreadable payload %s" % bad,
180
+ "REFUSE"))
181
+
182
+ print("SECOND AUDIT — routes that were still open")
183
+ for c in ["rm formwork/guard/*",
184
+ "rm -rf formwork/check/checks/*",
185
+ "chmod 644 formwork/check/checks/*",
186
+ "rm formwork/check/ru*",
187
+ "nice -n 5 rm formwork/guard/git-boundary",
188
+ "timeout 5 rm formwork/guard/git-boundary",
189
+ "sudo -u nobody rm formwork/guard/git-boundary",
190
+ "bash -c 'rm formwork/guard/git-boundary'",
191
+ "for f in x; do rm formwork/check/run; done",
192
+ "(rm formwork/check/run)",
193
+ "perl -e 'unlink \"formwork/guard/git-boundary\"'",
194
+ "sed --in-place s/a/b/ formwork/check/run"]:
195
+ expect(c, REFUSE, command=c)
196
+ got, _ = run(command="bash <<'EOF'\nrm formwork/check/run\nEOF\n")
197
+ ok = got == REFUSE
198
+ results.append(ok)
199
+ print(" [%s] %-52s %s" % ("pass" if ok else "FAIL",
200
+ "heredoc fed to a shell", "REFUSE"))
201
+
202
+ print("THE FRONT DOOR — the short command must not be a way round")
203
+ for c in ["formwork record", "formwork/fw record", "./formwork/fw record"]:
204
+ expect(c, REFUSE, command=c)
205
+ for c in ["formwork check", "formwork/fw check", "formwork test",
206
+ "formwork init", "formwork install --runtime claude-code",
207
+ "formwork where"]:
208
+ expect(c, ALLOW, command=c)
209
+
210
+ print("THIRD AUDIT — routes found by the third audit")
211
+ for c in ["sudo -n rm formwork/guard/git-boundary",
212
+ "env -i rm formwork/guard/git-boundary",
213
+ "if rm formwork/check/run; then echo x; fi",
214
+ "while rm formwork/check/run; do :; done",
215
+ "python3 formwork/fw record",
216
+ "python3 formwork/check/checks/kit-integrity --record .",
217
+ "uv run formwork/fw record",
218
+ "rm -rf *", "rm -rf ./*",
219
+ "dd if=/dev/zero of=formwork/check/run",
220
+ "unlink formwork/guard/git-boundary",
221
+ "rsync /tmp/x formwork/check/run"]:
222
+ expect(c, REFUSE, command=c)
223
+ got, _ = run(command="bash <<'EOF'\nrm formwork/check/run\nEOF\n")
224
+ ok = got == REFUSE
225
+ results.append(ok)
226
+ print(" [%s] %-52s %s" % ("pass" if ok else "FAIL",
227
+ "heredoc into a shell", "REFUSE"))
228
+
229
+ print("THIRD AUDIT — siblings and ordinary globs, wrongly refused")
230
+ for c in ["rm .formwork.toml.bak", "rm formwork/check/run.orig",
231
+ "rm *.pyc", "rm build/*", "rm docs/*.tmp",
232
+ "sudo -n ls", "env -i ls", "python3 formwork/fw check"]:
233
+ expect(c, ALLOW, command=c)
234
+ got, _ = run(fmt="claude-code", payload='{"tool_input":{"command":123}}')
235
+ ok = got == REFUSE
236
+ results.append(ok)
237
+ print(" [%s] %-52s %s" % ("pass" if ok else "FAIL",
238
+ "a command that is not a string", "REFUSE"))
239
+
240
+ print("SECOND AUDIT — ordinary work that was wrongly refused")
241
+ for c in ["python3 -m pytest formwork/guard/test_boundary.py",
242
+ "cp formwork/check/run /tmp/backup-run",
243
+ "rm formwork/check/runner-notes.md",
244
+ "rm build/*", "rm docs/*.tmp", "ls formwork/guard/*",
245
+ "nice -n 5 ls", "bash -c 'echo hi'"]:
246
+ expect(c, ALLOW, command=c)
247
+
248
+ print()
249
+ print("%d of %d behaved as specified." % (sum(results), len(results)))
250
+ return 0 if all(results) else 1
251
+
252
+
253
+ if __name__ == "__main__":
254
+ sys.exit(main())
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """Prove the end-of-turn gate blocks, and prove the budget behaves.
3
+
4
+ The budget is the part that matters. A guard that refuses once was defeated
5
+ under test elsewhere; a guard that refuses for ever strands the work. This
6
+ exercises both edges and everything between.
7
+
8
+ Python 3, standard library only, no dependencies.
9
+ """
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+
17
+ HERE = os.path.dirname(os.path.abspath(__file__))
18
+ GUARD = os.path.join(HERE, "quality-gate")
19
+ ALLOW, REFUSE = 0, 2
20
+ results = []
21
+
22
+
23
+ def check(name, got, want, output=""):
24
+ ok = got == want
25
+ results.append(ok)
26
+ print(" [%s] %-50s expected %d, got %d"
27
+ % ("pass" if ok else "FAIL", name[:50], want, got))
28
+ if not ok and output:
29
+ for line in output.strip().split("\n")[:4]:
30
+ print(" %s" % line)
31
+
32
+
33
+ def build(aggregate_exit, config="", state=None):
34
+ """A throwaway project whose aggregate exits however we say."""
35
+ root = tempfile.mkdtemp(prefix="fw-gate-")
36
+ os.makedirs(os.path.join(root, "formwork", "check"))
37
+ gate = os.path.join(root, "formwork", "check", "run")
38
+ open(gate, "w").write(
39
+ "#!/usr/bin/env python3\n"
40
+ "print('aggregate output')\n"
41
+ "import sys; sys.exit(%d)\n" % aggregate_exit)
42
+ os.chmod(gate, 0o755)
43
+ open(os.path.join(root, ".formwork.toml"), "w").write(config)
44
+ open(os.path.join(root, "README.md"), "w").write("a project\n")
45
+ if state:
46
+ os.makedirs(state, exist_ok=True)
47
+ return root
48
+
49
+
50
+ def run(root, session="s1", state=None, env_extra=None):
51
+ env = dict(os.environ)
52
+ env["CLAUDE_PROJECT_DIR"] = root
53
+ env["FORMWORK_STATE_DIR"] = state or os.path.join(root, "state")
54
+ env.pop("FORMWORK_GATE", None)
55
+ if env_extra:
56
+ env.update(env_extra)
57
+ p = subprocess.run([sys.executable, GUARD, "--format", "claude-code"],
58
+ capture_output=True, text=True, env=env,
59
+ input=json.dumps({"session_id": session}))
60
+ return p.returncode, (p.stdout + p.stderr).strip()
61
+
62
+
63
+ def main():
64
+ print("THE END-OF-TURN GATE")
65
+
66
+ root = build(0)
67
+ code, out = run(root)
68
+ check("green aggregate: the turn may finish", code, ALLOW, out)
69
+ shutil.rmtree(root)
70
+
71
+ root = build(1)
72
+ code, out = run(root)
73
+ check("red aggregate: the turn is refused", code, REFUSE, out)
74
+ shutil.rmtree(root)
75
+
76
+ # The aggregate itself failing to run is not a pass.
77
+ root = build(0)
78
+ os.remove(os.path.join(root, "formwork", "check", "run"))
79
+ code, out = run(root)
80
+ check("no aggregate at all: refused, not waved through", code, REFUSE, out)
81
+ shutil.rmtree(root)
82
+
83
+ print()
84
+ print("THE BUDGET")
85
+
86
+ root = build(1, config='[strength]\ngate_budget = "2"\n')
87
+ state = os.path.join(root, "state")
88
+ seq = []
89
+ for i in range(4):
90
+ code, out = run(root, session="budget-test", state=state)
91
+ seq.append(code)
92
+ ok = seq == [REFUSE, REFUSE, ALLOW, ALLOW]
93
+ results.append(ok)
94
+ print(" [%s] %-50s %s"
95
+ % ("pass" if ok else "FAIL", "refuses twice, then stands aside", seq))
96
+ # And it must say plainly that nothing was fixed.
97
+ code, out = run(root, session="budget-test", state=state)
98
+ told = "NOTHING BELOW HAS BEEN FIXED" in out
99
+ results.append(told)
100
+ print(" [%s] %-50s" % ("pass" if told else "FAIL",
101
+ "when spent, it says nothing was fixed"))
102
+ shutil.rmtree(root)
103
+
104
+ # A different session gets its own budget.
105
+ root = build(1, config='[strength]\ngate_budget = "1"\n')
106
+ state = os.path.join(root, "state")
107
+ run(root, session="one", state=state)
108
+ code, _ = run(root, session="one", state=state)
109
+ first_spent = code == ALLOW
110
+ code, _ = run(root, session="two", state=state)
111
+ second_fresh = code == REFUSE
112
+ results.append(first_spent and second_fresh)
113
+ print(" [%s] %-50s" % ("pass" if first_spent and second_fresh else "FAIL",
114
+ "each session gets its own budget"))
115
+ shutil.rmtree(root)
116
+
117
+ print()
118
+ print("STRENGTH")
119
+
120
+ for level, want in (("block", REFUSE), ("warn", ALLOW), ("off", ALLOW)):
121
+ root = build(1, config='[strength]\naggregate_gate = "%s"\n' % level)
122
+ code, out = run(root, session="strength-%s" % level)
123
+ check("red aggregate at strength=%s" % level, code, want, out)
124
+ shutil.rmtree(root)
125
+
126
+ # warn must still say what happened rather than going quiet.
127
+ root = build(1, config='[strength]\naggregate_gate = "warn"\n')
128
+ code, out = run(root, session="warn-loud")
129
+ loud = "red" in out.lower()
130
+ results.append(loud)
131
+ print(" [%s] %-50s" % ("pass" if loud else "FAIL",
132
+ "warn still reports the failure"))
133
+ shutil.rmtree(root)
134
+
135
+ print()
136
+ print("REFUSING TO GUESS")
137
+
138
+ root = build(1)
139
+ env = dict(os.environ)
140
+ env["CLAUDE_PROJECT_DIR"] = root
141
+ p = subprocess.run([sys.executable, GUARD, "--format", "nope"],
142
+ capture_output=True, text=True, env=env, input="{}")
143
+ check("an unknown runtime: refused", p.returncode, REFUSE)
144
+ p = subprocess.run([sys.executable, GUARD],
145
+ capture_output=True, text=True, env=env, input="")
146
+ check("no arguments at all: refused", p.returncode, REFUSE)
147
+ shutil.rmtree(root)
148
+
149
+ print()
150
+ print("%d of %d behaved as specified." % (sum(1 for r in results if r),
151
+ len(results)))
152
+ return 0 if all(results) else 1
153
+
154
+
155
+ if __name__ == "__main__":
156
+ sys.exit(main())