formwork-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- formwork_cli/__init__.py +326 -0
- formwork_cli/kit/COSTS.md +111 -0
- formwork_cli/kit/adapters/claude-code/README.md +53 -0
- formwork_cli/kit/adapters/claude-code/settings.json +46 -0
- formwork_cli/kit/adapters/codex/README.md +43 -0
- formwork_cli/kit/adapters/cursor/README.md +45 -0
- formwork_cli/kit/adapters/gemini-cli/README.md +47 -0
- formwork_cli/kit/build +410 -0
- formwork_cli/kit/check/checks/config-shape +123 -0
- formwork_cli/kit/check/checks/decision-ids +159 -0
- formwork_cli/kit/check/checks/doc-links +133 -0
- formwork_cli/kit/check/checks/generated-current +74 -0
- formwork_cli/kit/check/checks/guard-wired +139 -0
- formwork_cli/kit/check/checks/kit-integrity +199 -0
- formwork_cli/kit/check/checks/predictions-first +127 -0
- formwork_cli/kit/check/checks/role-shape +172 -0
- formwork_cli/kit/check/checks/rule-labels +135 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/.formwork.toml +5 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/formwork/guide.md +13 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/rules-as-a-switchboard/.formwork.toml +8 -0
- formwork_cli/kit/check/fixtures/config-shape/must-pass/layers-kept-apart/.formwork.toml +5 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/a-placeholder-shipped/docs/decisions/0003-still-pending.md +7 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/superseded-by-nothing/docs/decisions/0002-old.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-first.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-second.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0001-the-first.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0002-the-second.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0003-the-third.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/nothing-recorded-yet/docs/decisions/README.md +3 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/never-written/index.md +7 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/architecture-notes.md +3 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/guide.md +8 -0
- formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/architecture-notes.md +1 -0
- formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/guide.md +5 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.claude/agents/sample.md +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.codex/agents/sample.toml +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.gemini/agents/sample.md +23 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/build +349 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/roles/method/sample.md +18 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.claude/agents/sample.md +20 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.codex/agents/sample.toml +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.gemini/agents/sample.md +23 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/build +349 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/roles/method/sample.md +18 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/nothing-is-generated-here/README.md +3 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/declared-but-no-file/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.claude/settings.json +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.claude/settings.json +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/nothing-declared/README.md +1 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/formwork/check/checks/still-here +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/state/fingerprints.txt +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/formwork/guard/git-boundary +3 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/state/fingerprints.txt +1 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/formwork/guard/git-boundary +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/state/fingerprints.txt +1 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/architect.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/researcher.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/round.md +4 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/a-round-that-has-not-argued-yet/docs/rounds/0006-not-started/round.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/no-rounds-at-all/docs/README.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/architect.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/predictions.md +4 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/researcher.md +3 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/README.md +6 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/complete.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/missing-a-section/formwork/roles/vague.md +16 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/spawn-without-being-lead/formwork/roles/eager.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/first.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/second.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-pass/well-formed/formwork/roles/complete.md +18 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/claims-enforcement-that-does-not-exist/formwork/rules/core.md +9 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/no-catches/formwork/rules/core.md +9 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/unlabelled/formwork/rules/core.md +7 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-pass/well-formed/formwork/rules/core.md +10 -0
- formwork_cli/kit/check/run +340 -0
- formwork_cli/kit/check/test_gate.py +222 -0
- formwork_cli/kit/first-run.md +204 -0
- formwork_cli/kit/fw +121 -0
- formwork_cli/kit/glossary.md +160 -0
- formwork_cli/kit/guard/git-boundary +627 -0
- formwork_cli/kit/guard/protected-files +748 -0
- formwork_cli/kit/guard/quality-gate +260 -0
- formwork_cli/kit/guard/test_boundary.py +273 -0
- formwork_cli/kit/guard/test_protection.py +254 -0
- formwork_cli/kit/guard/test_quality_gate.py +156 -0
- formwork_cli/kit/install +395 -0
- formwork_cli/kit/limits.md +141 -0
- formwork_cli/kit/loop.md +82 -0
- formwork_cli/kit/roles/HOW-TO-ADD-A-ROLE.md +105 -0
- formwork_cli/kit/roles/TEMPLATE.md +26 -0
- formwork_cli/kit/roles/method/architect.md +269 -0
- formwork_cli/kit/roles/method/challenger.md +243 -0
- formwork_cli/kit/roles/method/lead.md +280 -0
- formwork_cli/kit/roles/method/record-keeper.md +206 -0
- formwork_cli/kit/roles/method/researcher.md +246 -0
- formwork_cli/kit/roles/method/reviewer.md +207 -0
- formwork_cli/kit/roles/packs/accessibility.md +236 -0
- formwork_cli/kit/roles/packs/ai.md +248 -0
- formwork_cli/kit/roles/packs/analyst.md +233 -0
- formwork_cli/kit/roles/packs/backend.md +425 -0
- formwork_cli/kit/roles/packs/brainstormer.md +190 -0
- formwork_cli/kit/roles/packs/data.md +212 -0
- formwork_cli/kit/roles/packs/devops.md +203 -0
- formwork_cli/kit/roles/packs/frontend.md +224 -0
- formwork_cli/kit/roles/packs/integrations.md +215 -0
- formwork_cli/kit/roles/packs/legal.md +251 -0
- formwork_cli/kit/roles/packs/marketing.md +206 -0
- formwork_cli/kit/roles/packs/mobile.md +202 -0
- formwork_cli/kit/roles/packs/performance.md +192 -0
- formwork_cli/kit/roles/packs/product.md +217 -0
- formwork_cli/kit/roles/packs/security.md +267 -0
- formwork_cli/kit/roles/packs/sre.md +203 -0
- formwork_cli/kit/roles/packs/tester.md +246 -0
- formwork_cli/kit/roles/packs/user-researcher.md +218 -0
- formwork_cli/kit/roles/packs/ux.md +205 -0
- formwork_cli/kit/roles/packs/visual.md +199 -0
- formwork_cli/kit/roles/packs/writer.md +198 -0
- formwork_cli/kit/round.md +131 -0
- formwork_cli/kit/rules/core.md +195 -0
- formwork_cli/kit/rules/full.md +493 -0
- formwork_cli/kit/templates/brief.md +68 -0
- formwork_cli/kit/templates/decision.md +93 -0
- formwork_cli/kit/templates/predictions.md +54 -0
- formwork_cli/kit/templates/report.md +52 -0
- formwork_cli/kit/templates/round.md +77 -0
- formwork_cli/kit/test_install.py +165 -0
- formwork_cli/kit/troubleshooting.md +247 -0
- formwork_cli/kit-page/FORMWORK.md +182 -0
- formwork_kit-0.1.0.dist-info/METADATA +308 -0
- formwork_kit-0.1.0.dist-info/RECORD +137 -0
- formwork_kit-0.1.0.dist-info/WHEEL +4 -0
- formwork_kit-0.1.0.dist-info/entry_points.txt +2 -0
- formwork_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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())
|