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,260 @@
1
+ #!/usr/bin/env python3
2
+ """The gate, wired to the end of a turn. The rule that actually blocks.
3
+
4
+ quality-gate --format claude-code < hook payload on stdin
5
+ quality-gate --run run the aggregate and report it.
6
+ No budget, no counter, no state
7
+
8
+ Exit status:
9
+ 0 allowed to finish — green, or the budget is spent and you were told
10
+ 2 refused. The aggregate is red and the turn cannot conclude
11
+
12
+ WHAT THIS DOES
13
+ --------------
14
+ When the agent tries to finish a turn, this runs the aggregate — by name, the
15
+ whole thing, never a hand-picked subset — and refuses to let the turn end while
16
+ it is red.
17
+
18
+ Naming the aggregate rather than listing its parts means a check added later is
19
+ covered without anybody remembering to update this file.
20
+
21
+ THE BUDGET, AND WHY IT IS NOT A DETAIL
22
+ --------------------------------------
23
+ It refuses a bounded number of times per session, then stands aside and tells
24
+ the human.
25
+
26
+ Both extremes fail, and one of them fails silently:
27
+
28
+ * **Refuse once.** A guard that gives up after a single refusal was defeated
29
+ under test: an agent broke a check, met one refusal, then altered working
30
+ code until the broken check agreed with it, and declared itself finished.
31
+ Nobody had run the aggregate a second time. The safeguard produced a
32
+ successful-looking result the first time it met a real agent.
33
+ * **Refuse for ever.** The work strands, and the human finds out by noticing
34
+ that nothing is happening.
35
+
36
+ Bounded refusal with escalation is the only version that survives contact.
37
+
38
+ WHAT A GREEN RESULT HERE DOES NOT MEAN
39
+ --------------------------------------
40
+ Named, because an unqualified pass reads as total coverage.
41
+
42
+ * Work done in another checkout this session is not seen.
43
+ * Changes made outside the agent's tool calls are not seen.
44
+ * Anything needing real hardware, a live model, or real money is not run by
45
+ the aggregate, so it is not run here either.
46
+
47
+ Python 3, standard library only, no dependencies.
48
+ """
49
+ import json
50
+ import os
51
+ import re
52
+ import subprocess
53
+ import sys
54
+
55
+ ALLOW, REFUSE = 0, 2
56
+ STRENGTHS = ("block", "warn", "off")
57
+
58
+ DEFAULT_BUDGET = 3
59
+
60
+
61
+ def _timeout():
62
+ """A malformed timeout must not crash the hook that blocks the turn."""
63
+ raw = os.environ.get("FORMWORK_GATE_TIMEOUT", "300")
64
+ try:
65
+ v = int(raw)
66
+ except (TypeError, ValueError):
67
+ return 300
68
+ if v <= 0:
69
+ return 300
70
+ return min(v, 86400)
71
+
72
+
73
+ GATE_TIMEOUT = _timeout()
74
+ STATE = os.environ.get("FORMWORK_STATE_DIR", os.path.expanduser("~/.formwork"))
75
+ FORMATS = ("claude-code", "codex", "cursor", "gemini-cli")
76
+
77
+
78
+ def project_root():
79
+ env = os.environ.get("CLAUDE_PROJECT_DIR")
80
+ if env and os.path.isdir(env):
81
+ return env
82
+ here = os.path.dirname(os.path.abspath(__file__))
83
+ return os.path.dirname(os.path.dirname(here))
84
+
85
+
86
+ def setting(root, key, allowed, default):
87
+ config = os.path.join(root, ".formwork.toml")
88
+ if not os.path.exists(config):
89
+ return default
90
+ try:
91
+ text = open(config, encoding="utf-8", errors="ignore").read()
92
+ except OSError:
93
+ return default
94
+ m = re.search(r'^\s*%s\s*=\s*["\']?([A-Za-z0-9_]+)["\']?' % key, text, re.M)
95
+ if not m:
96
+ return default
97
+ v = m.group(1).strip().lower()
98
+ return v if (allowed is None or v in allowed) else default
99
+
100
+
101
+ def budget(root):
102
+ env = os.environ.get("FORMWORK_GATE_BUDGET")
103
+ if env and env.isdigit():
104
+ return int(env)
105
+ v = setting(root, "gate_budget", None, str(DEFAULT_BUDGET))
106
+ return int(v) if str(v).isdigit() else DEFAULT_BUDGET
107
+
108
+
109
+ def counter_path(session):
110
+ """Where this session's refusal count lives.
111
+
112
+ A payload without a session identifier used to get no counter at all,
113
+ which meant the escalation never happened and the gate refused for ever —
114
+ the exact failure this file's own docstring says it avoids. Three of the
115
+ four runtimes have never been run, so a missing identifier is likely
116
+ rather than exotic. Fall back to the project, which bounds the refusals
117
+ even when it cannot tell two sessions apart.
118
+ """
119
+ if not session:
120
+ session = "no-session-id-%s" % re.sub(
121
+ r"[^A-Za-z0-9_.-]", "_", os.path.basename(project_root()))
122
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session)
123
+ return os.path.join(STATE, "sessions", "%s.refusals" % safe)
124
+
125
+
126
+ def refusals_so_far(path):
127
+ if not path:
128
+ return 0
129
+ try:
130
+ return int(open(path).read().strip())
131
+ except (OSError, ValueError):
132
+ return 0
133
+
134
+
135
+ def record(path, n):
136
+ if not path:
137
+ return
138
+ try:
139
+ os.makedirs(os.path.dirname(path), exist_ok=True)
140
+ open(path, "w").write(str(n))
141
+ except OSError:
142
+ pass
143
+
144
+
145
+ def nothing_changed(root):
146
+ """True when version control reports a clean tree. Cheap, and read-only."""
147
+ try:
148
+ p = subprocess.run(["git", "-C", root, "status", "--porcelain"],
149
+ capture_output=True, text=True, timeout=20)
150
+ except (OSError, subprocess.SubprocessError):
151
+ return False
152
+ return p.returncode == 0 and not p.stdout.strip()
153
+
154
+
155
+ def run_aggregate(root):
156
+ """(exit_code, output). exit_code is None when it could not run at all."""
157
+ gate = os.path.join(root, "formwork", "check", "run")
158
+ if not os.path.isfile(gate):
159
+ return None, "there is no aggregate at %s" % gate
160
+ try:
161
+ p = subprocess.run([gate], capture_output=True, text=True,
162
+ cwd=root, timeout=GATE_TIMEOUT)
163
+ except subprocess.TimeoutExpired:
164
+ return None, ("the aggregate took longer than %ds and was stopped"
165
+ % GATE_TIMEOUT)
166
+ except OSError as e:
167
+ return None, "the aggregate could not be started: %s" % e
168
+ return p.returncode, (p.stdout + p.stderr).strip()
169
+
170
+
171
+ def main(argv):
172
+ root = project_root()
173
+ direct = "--run" in argv
174
+ fmt = None
175
+ for i, a in enumerate(argv):
176
+ if a == "--format" and i + 1 < len(argv):
177
+ fmt = argv[i + 1]
178
+
179
+ session = ""
180
+ if not direct:
181
+ if fmt is None:
182
+ print("ERROR: give --run, or --format with a payload on stdin",
183
+ file=sys.stderr)
184
+ return REFUSE
185
+ if fmt not in FORMATS:
186
+ print("ERROR: unknown runtime format: %s" % fmt, file=sys.stderr)
187
+ print(" Refusing rather than guessing.", file=sys.stderr)
188
+ return REFUSE
189
+ raw = sys.stdin.read()
190
+ if raw.strip():
191
+ try:
192
+ session = (json.loads(raw) or {}).get("session_id", "") or ""
193
+ except ValueError:
194
+ session = ""
195
+
196
+ level = os.environ.get("FORMWORK_GATE", "").strip().lower()
197
+ if level not in STRENGTHS:
198
+ level = setting(root, "aggregate_gate", STRENGTHS, "block")
199
+ if level == "off":
200
+ return ALLOW
201
+
202
+ if not direct and nothing_changed(root):
203
+ return ALLOW
204
+
205
+ code, out = run_aggregate(root)
206
+
207
+ if code is None:
208
+ if level == "warn":
209
+ # Saying REFUSED and then allowing it made the message and the
210
+ # exit code disagree.
211
+ print("WARNING: %s. A gate that did not run is not a green gate, "
212
+ "and this gate is set to warn, so the turn finished anyway."
213
+ % out, file=sys.stderr)
214
+ return ALLOW
215
+ print("REFUSED: %s." % out, file=sys.stderr)
216
+ print("A gate that did not run is not a green gate.", file=sys.stderr)
217
+ return REFUSE
218
+
219
+ if code == 0:
220
+ return ALLOW
221
+
222
+ if direct:
223
+ # --run is for a person or a script asking "is it green?". Reporting
224
+ # ALLOW on a red aggregate because a counter somewhere had reached
225
+ # three would be a false answer to a direct question — and the counter
226
+ # never reset, so it stayed false for ever.
227
+ print(out, file=sys.stderr)
228
+ return REFUSE
229
+
230
+ if level == "warn":
231
+ print("The aggregate is red. This gate is set to warn, so the turn "
232
+ "finished anyway.", file=sys.stderr)
233
+ print(out, file=sys.stderr)
234
+ return ALLOW
235
+
236
+ path = counter_path(session)
237
+ used = refusals_so_far(path)
238
+ allowance = budget(root)
239
+
240
+ if used >= allowance:
241
+ print("THE AGGREGATE IS STILL RED, and this gate has already refused "
242
+ "%d times in this session." % used, file=sys.stderr)
243
+ print("It is standing aside so the work is not stranded. NOTHING BELOW "
244
+ "HAS BEEN FIXED.", file=sys.stderr)
245
+ print(out, file=sys.stderr)
246
+ return ALLOW
247
+
248
+ record(path, used + 1)
249
+ print("REFUSED: the aggregate is red, so this turn cannot conclude.",
250
+ file=sys.stderr)
251
+ print(out, file=sys.stderr)
252
+ print("", file=sys.stderr)
253
+ print("Refusal %d of %d this session. Fix the cause. Do not change the "
254
+ "check that just failed you." % (used + 1, allowance),
255
+ file=sys.stderr)
256
+ return REFUSE
257
+
258
+
259
+ if __name__ == "__main__":
260
+ sys.exit(main(sys.argv))
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ """Prove the boundary refuses, and prove it lets reading through.
3
+
4
+ Three things are tested, and the third is the unusual one:
5
+
6
+ REFUSED commands that change the repository
7
+ ALLOWED commands that only read it, because the report needs them
8
+ KNOWN MISS evasions this guard does not catch
9
+
10
+ The third group is asserted deliberately. A blind spot that is tested is a
11
+ blind spot somebody has to delete a test to pretend away. An unqualified pass
12
+ would read as total coverage, and this is not that.
13
+
14
+ Python 3, standard library only, no dependencies.
15
+ """
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+
21
+ HERE = os.path.dirname(os.path.abspath(__file__))
22
+ GUARD = os.path.join(HERE, "git-boundary")
23
+ ALLOW, WARN, REFUSE = 0, 1, 2
24
+ results = []
25
+
26
+
27
+ def run(command=None, fmt=None, payload=None, level=None):
28
+ env = dict(os.environ)
29
+ # Each case states its own strength, so a stray setting in the shell this
30
+ # runs from cannot quietly change the result.
31
+ env["FORMWORK_GIT_BOUNDARY"] = level or "block"
32
+ args = [sys.executable, GUARD]
33
+ if command is not None:
34
+ args += ["--command", command]
35
+ if fmt is not None:
36
+ args += ["--format", fmt]
37
+ p = subprocess.run(args, capture_output=True, text=True, env=env,
38
+ input=json.dumps(payload) if payload is not None else "")
39
+ return p.returncode, (p.stdout + p.stderr).strip()
40
+
41
+
42
+ def expect(label, command, want):
43
+ got, out = run(command=command)
44
+ ok = got == want
45
+ results.append(ok)
46
+ names = {ALLOW: "allow", WARN: "warn", REFUSE: "REFUSE"}
47
+ print(" [%s] %-44s %s" % ("pass" if ok else "FAIL", command, names[want]))
48
+ if not ok:
49
+ print(" got %s: %s" % (names.get(got, got), out.split("\n")[0]))
50
+
51
+
52
+ def main():
53
+ print("REFUSED — changes the repository")
54
+ for c in [
55
+ "git commit -m 'x'",
56
+ "git add .",
57
+ "git push origin main",
58
+ "git merge feature",
59
+ "git rebase -i HEAD~3",
60
+ "git reset --hard",
61
+ "git stash",
62
+ "git tag -a v1 -m x",
63
+ "git branch -D old",
64
+ "git config user.name --add",
65
+ "git remote add origin git@example.com:a/b.git",
66
+ "git worktree add ../wt",
67
+ "git checkout -b new-branch",
68
+ "git switch -c new-branch",
69
+ "gh pr create --fill",
70
+ "gh pr merge 12",
71
+ "glab mr create",
72
+ # wrappers and compound commands
73
+ "sudo git commit -m x",
74
+ "FOO=1 git commit -m x",
75
+ "cd subdir && git commit -m x",
76
+ "git status && git commit -m x",
77
+ "git -C /elsewhere commit -m x",
78
+ "git -c user.name=x commit -m y",
79
+ "echo hi; git push",
80
+ # A subcommand held in a variable cannot be read, so it is not on the
81
+ # read-only list, so it is refused. Caution is the default.
82
+ "C=commit; git $C -m x",
83
+ ]:
84
+ expect("refuse", c, REFUSE)
85
+
86
+ print()
87
+ print("ALLOWED — reading only, and the report depends on it")
88
+ for c in [
89
+ "git status",
90
+ "git status --short",
91
+ "git diff",
92
+ "git diff --cached --name-only",
93
+ "git log --oneline -20",
94
+ "git show HEAD",
95
+ "git ls-files",
96
+ "git rev-parse HEAD",
97
+ "git branch",
98
+ "git branch --list",
99
+ "git tag",
100
+ "git remote -v",
101
+ "git worktree list",
102
+ "git config --get user.name",
103
+ "git blame README.md",
104
+ "git describe --tags",
105
+ "ls -la",
106
+ "python3 tools/privacy_scan.py",
107
+ "grep -rn TODO .",
108
+ "git diff && git status",
109
+ ]:
110
+ expect("allow", c, ALLOW)
111
+
112
+ print()
113
+ print("KNOWN MISS — asserted, so nobody mistakes these for covered")
114
+ for c in [
115
+ # the whole command is assembled at run time, so the word "git"
116
+ # never appears for the guard to find
117
+ "eval $(echo git commit -m x)",
118
+ # a script on disk commits; this reads the command, not the script
119
+ "./scripts/release.sh",
120
+ "make release",
121
+ ]:
122
+ expect("known miss", c, ALLOW)
123
+
124
+ print()
125
+ print("PAYLOADS — the four runtimes, and a refusal to guess")
126
+ for fmt in ("claude-code", "codex", "cursor", "gemini-cli"):
127
+ code, _ = run(fmt=fmt, payload={"tool_input": {"command": "git push"}})
128
+ ok = code == REFUSE
129
+ results.append(ok)
130
+ print(" [%s] %-44s REFUSE" % ("pass" if ok else "FAIL", fmt))
131
+
132
+ code, _ = run(fmt="claude-code", payload={"tool_input": {"command": "git log"}})
133
+ ok = code == ALLOW
134
+ results.append(ok)
135
+ print(" [%s] %-44s allow" % ("pass" if ok else "FAIL", "claude-code, a read"))
136
+
137
+ # Fails closed. A guard that cannot decide refuses, because a boundary
138
+ # that fails open is a boundary nobody notices has gone.
139
+ code, out = run(fmt="some-other-tool", payload={})
140
+ ok = code == REFUSE
141
+ results.append(ok)
142
+ print(" [%s] %-44s REFUSE, not allow"
143
+ % ("pass" if ok else "FAIL", "an unknown runtime"))
144
+
145
+ code, out = run(fmt="claude-code", payload=None)
146
+ ok = code == REFUSE
147
+ results.append(ok)
148
+ print(" [%s] %-44s REFUSE, not allow"
149
+ % ("pass" if ok else "FAIL", "no payload at all"))
150
+
151
+ print()
152
+ print("STRENGTH — the same command, three settings")
153
+ for level, want, label in (("block", REFUSE, "block: refuses"),
154
+ ("warn", WARN, "warn: runs, and says so"),
155
+ ("off", ALLOW, "off: runs, silently")):
156
+ code, _ = run(command="git commit -m x", level=level)
157
+ ok = code == want
158
+ results.append(ok)
159
+ print(" [%s] %-44s %s"
160
+ % ("pass" if ok else "FAIL", label,
161
+ {ALLOW: "allow", WARN: "warn", REFUSE: "REFUSE"}[want]))
162
+
163
+ # A read is allowed whatever the strength; the report depends on it.
164
+ for level in ("block", "warn", "off"):
165
+ code, _ = run(command="git status", level=level)
166
+ ok = code == ALLOW
167
+ results.append(ok)
168
+ print(" [%s] %-44s allow"
169
+ % ("pass" if ok else "FAIL", "reads pass at strength=%s" % level))
170
+
171
+ # Everything below is a hole an audit walked through. Each one was
172
+ # allowed before the fix that follows it in git history.
173
+ print("WRAPPERS — a write hidden behind something else")
174
+ for c in ['bash -c "git commit -m x"',
175
+ 'sh -c "git push"',
176
+ 'eval "git push"',
177
+ 'nice -n 5 git push',
178
+ 'timeout 60 git push',
179
+ 'env -u FOO git push',
180
+ 'sudo -u somebody git push',
181
+ 'echo $(git commit -m x)']:
182
+ expect(c, c, REFUSE)
183
+
184
+ print("WRAPPERS — and the same wrappers around innocent work")
185
+ for c in ['bash -c "echo hello"', 'nice -n 5 ls', 'timeout 60 git status']:
186
+ expect(c, c, ALLOW)
187
+
188
+ print("WRITES WITH NO OPTION AT ALL")
189
+ expect("git config core.hooksPath /dev/null",
190
+ "git config core.hooksPath /dev/null", REFUSE)
191
+ expect("git checkout somefile.py", "git checkout somefile.py", REFUSE)
192
+
193
+ print("READS ONCE WRONGLY REFUSED")
194
+ for c in ['git stash list', 'git stash show', 'git notes list',
195
+ 'git cherry -v', 'git config --get user.name']:
196
+ expect(c, c, ALLOW)
197
+ expect("git stash", "git stash", REFUSE)
198
+
199
+ print("FAILS CLOSED — a payload it cannot read")
200
+ for bad in ("[]", "null", '{"tool_input":{"command":5}}'):
201
+ code, out = run(fmt="claude-code", payload=bad)
202
+ ok = code == REFUSE
203
+ results.append(ok)
204
+ print(" [%s] %-44s %s"
205
+ % ("pass" if ok else "FAIL", "unreadable payload %s" % bad[:20],
206
+ "REFUSE"))
207
+
208
+ print("SECOND AUDIT — grouping, loops, aliases, heredocs into a shell")
209
+ for c in ['(git push)', '{ git push; }',
210
+ 'for f in x; do git push; done',
211
+ 'while true; do git commit -m x; done',
212
+ 'if true; then git push; fi',
213
+ '\\git commit',
214
+ 'git submodule foreach git push',
215
+ 'gh -R owner/repo pr create',
216
+ 'gh --repo a/b pr create',
217
+ 'gh api -X POST /repos/a/b/pulls',
218
+ 'git config core.hooksPath /dev/null']:
219
+ expect(c, c, REFUSE)
220
+ code, out = run(command="bash <<'EOF'\ngit push\nEOF\n")
221
+ ok = code == REFUSE
222
+ results.append(ok)
223
+ print(" [%s] %-44s %s" % ("pass" if ok else "FAIL",
224
+ "heredoc fed to a shell", "REFUSE"))
225
+
226
+ print("THIRD AUDIT — wrappers that swallowed the command")
227
+ for c in ['sudo -n git push', 'env -i git push', 'sudo -s git push',
228
+ 'timeout -k 1 30 git push',
229
+ 'git symbolic-ref HEAD refs/heads/evil']:
230
+ expect(c, c, REFUSE)
231
+ for pre in ('bash', 'sudo -n bash', 'nice -n 5 bash', 'timeout 60 bash',
232
+ 'env FOO=1 bash'):
233
+ code, out = run(command="%s <<'EOF'\ngit push\nEOF\n" % pre)
234
+ ok = code == REFUSE
235
+ results.append(ok)
236
+ print(" [%s] %-44s %s" % ("pass" if ok else "FAIL",
237
+ "heredoc into: " + pre, "REFUSE"))
238
+
239
+ print("THIRD AUDIT — reads and branch moves wrongly refused")
240
+ for c in ['git stash show -p', 'git checkout main', 'git checkout v1.2.0',
241
+ 'git symbolic-ref --short HEAD',
242
+ 'git config --local user.email 2>/dev/null']:
243
+ expect(c, c, ALLOW)
244
+ for name in ('retrieval.md', 'node-setup.md', 'ruby-guide.md'):
245
+ code, out = run(command="cat > docs/%s <<'EOF'\ngit push\nEOF\n" % name)
246
+ ok = code == ALLOW
247
+ results.append(ok)
248
+ print(" [%s] %-44s %s" % ("pass" if ok else "FAIL",
249
+ "heredoc written to " + name, "allow"))
250
+
251
+ print("SECOND AUDIT — reads that were wrongly refused")
252
+ for c in ['git symbolic-ref --short HEAD',
253
+ 'git config --global --get user.name',
254
+ 'git config --list --global',
255
+ 'git apply --check patch.diff',
256
+ 'git fetch origin',
257
+ 'for f in x; do echo $f; done',
258
+ 'bash -c "echo hello"',
259
+ 'gh pr list', 'gh api /repos/a/b']:
260
+ expect(c, c, ALLOW)
261
+ code, out = run(command="cat > notes.md <<'EOF'\ngit commit -m x\nEOF\n")
262
+ ok = code == ALLOW
263
+ results.append(ok)
264
+ print(" [%s] %-44s %s" % ("pass" if ok else "FAIL",
265
+ "heredoc written to a file", "allow"))
266
+
267
+ print()
268
+ print("%d of %d behaved as specified." % (sum(results), len(results)))
269
+ return 0 if all(results) else 1
270
+
271
+
272
+ if __name__ == "__main__":
273
+ sys.exit(main())