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,627 @@
1
+ #!/usr/bin/env python3
2
+ """The version-control boundary. The rule that refuses rather than advises.
3
+
4
+ git-boundary --format claude-code < hook payload on stdin
5
+ git-boundary --command "git commit" decide a single command directly
6
+
7
+ Exit status:
8
+ 0 allow. Reading version-control state is required, not forbidden
9
+ 1 warn. The command runs; the human is told what happened
10
+ 2 refuse, with the reason on stderr
11
+
12
+ IT FAILS CLOSED. If the guard cannot decide — an unreadable payload, an
13
+ unknown runtime, nothing on stdin — it refuses. A boundary that fails open is
14
+ a boundary nobody notices is gone, and that is the worse of the two mistakes.
15
+ A boundary that fails closed is noticed within one command.
16
+
17
+ STRENGTH IS CONFIGURABLE, AND HAS TO BE
18
+ ---------------------------------------
19
+ .formwork.toml [strength] git_boundary = "block" | "warn" | "off"
20
+ environment FORMWORK_GIT_BOUNDARY=off overrides it for one session
21
+
22
+ block is the default, and the right setting for one person working alone. A
23
+ team whose reviewer is in another timezone is stopped for twelve hours by a
24
+ rule that costs a solo builder ten minutes.
25
+
26
+ **What this does not do is stop the agent turning it off.** Nothing can. What
27
+ protects you is that the change is visible: editing .formwork.toml shows up in
28
+ `git status`, and the report is required to carry `git status`. The protection
29
+ is a trace, not a lock, and calling it a lock would be a lie.
30
+
31
+ Four runtimes refuse a tool call on exit code 2, so one program serves all of
32
+ them. Only the payload shape differs, and --format says which.
33
+
34
+ WHAT THIS CANNOT SEE
35
+ --------------------
36
+ Named, because an unqualified pass reads as total coverage and this is not.
37
+
38
+ * A command built at run time from variables, or decoded from a string.
39
+ * A script on disk that commits. This reads the command, not what it runs.
40
+ * An editor, an IDE button, or anything outside the agent's tool calls.
41
+ * A runtime with no pre-tool hook. There the rule is advice and says so.
42
+
43
+ It catches the ordinary cases, which is what a boundary is for. It is not a
44
+ sandbox and must not be described as one.
45
+
46
+ Python 3, standard library only, no dependencies.
47
+ """
48
+ import json
49
+ import os
50
+ import re
51
+ import shlex
52
+ import sys
53
+
54
+ ALLOW, WARN, REFUSE = 0, 1, 2
55
+
56
+ STRENGTHS = ("block", "warn", "off")
57
+
58
+ # Subcommands that change history, the index, or a remote.
59
+ WRITES = {
60
+ "commit", "push", "merge", "rebase", "revert", "cherry-pick", "am",
61
+ "reset", "add", "rm", "mv", "clean",
62
+ "update-ref", "update-index", "filter-branch",
63
+ "filter-repo", "replace", "gc", "prune",
64
+ "send-email", "request-pull", "subtree",
65
+ }
66
+
67
+ # Read-only. These must pass, or the report cannot be written.
68
+ READS = {
69
+ "status", "diff", "log", "show", "ls-files", "ls-tree", "ls-remote",
70
+ "rev-parse", "rev-list", "cat-file", "blame", "describe", "shortlog",
71
+ "grep", "whatchanged", "reflog", "bisect", "annotate", "count-objects",
72
+ "check-ignore", "check-attr", "verify-commit", "merge-base", "diff-tree",
73
+ # `git init` makes a new repository. There is no history to damage, and
74
+ # refusing it stopped somebody starting an unrelated project.
75
+ "init", "archive", "fsck", "range-diff", "verify-pack", "bundle",
76
+ "difftool", "mergetool", "column", "sparse-checkout", "count-objects",
77
+ "diff-index", "name-rev", "for-each-ref", "var", "help", "version",
78
+ }
79
+
80
+ # Read when bare, write with certain flags.
81
+ #
82
+ # `stash`, `notes` and `cherry` sit here rather than in WRITES: an audit found
83
+ # `git stash list`, `git notes list` and `git cherry -v` refused with the
84
+ # message "git stash changes the repository", which is false. A guard that is
85
+ # wrong about ordinary work is a guard somebody switches off.
86
+ CONDITIONAL = {
87
+ "branch": ("-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move",
88
+ "--copy", "--set-upstream-to", "--edit-description"),
89
+ "tag": ("-d", "-a", "-s", "-f", "--delete", "--annotate", "--sign",
90
+ "--force"),
91
+ # `git config core.hooksPath /dev/null` switches off every hook in the
92
+ # repository, this kit's included, and needs none of the flags below.
93
+ # Anything past a bare `git config <key> <value>` is a write.
94
+ "config": ("--add", "--unset", "--unset-all", "--replace-all",
95
+ "--rename-section", "--remove-section", "--edit", "-e"),
96
+ "remote": ("add", "remove", "rm", "set-url", "set-head", "rename",
97
+ "prune", "set-branches"),
98
+ "worktree": ("add", "remove", "prune", "move", "lock", "unlock"),
99
+ "checkout": ("-b", "-B", "--orphan", "--"),
100
+ "switch": ("-c", "-C", "--create", "--orphan"),
101
+ "submodule": ("add", "update", "deinit", "sync", "set-url", "foreach"),
102
+ # `-p` is NOT here: `git stash show -p` is the standard way to read a
103
+ # stash, and refusing it said "changes the repository", which is untrue.
104
+ "stash": ("push", "pop", "apply", "drop", "clear", "save", "store",
105
+ "create", "branch", "-u", "--include-untracked"),
106
+ "notes": ("add", "append", "copy", "edit", "remove", "prune", "merge"),
107
+ "cherry": (),
108
+ # `git restore --staged` unstages. `git restore <path>` throws away the
109
+ # edits in your working tree, which is the one to stop.
110
+ "restore": ("--worktree", "-W", "--source", "-s"),
111
+ # `git apply --check` is explicitly a dry run; `git symbolic-ref --short
112
+ # HEAD` is the standard way to read the branch name. Both were refused.
113
+ "apply": (), # handled below: bare apply writes, --check does not
114
+ "symbolic-ref": ("-d", "--delete", "-m"), # plus: two operands writes
115
+ "fetch": ("--prune", "-p", "--force", "-f", "--tags", "--unshallow"),
116
+ "format-patch": ("-o", "--output-directory"),
117
+ }
118
+
119
+ # Global options these tools take before the subcommand, with a value.
120
+ # Endings that mean a file rather than a branch. `feature/new-thing` and
121
+ # `release-1.2` are branch names people really use, and refusing them sent
122
+ # them to `git switch` or to turning the guard off.
123
+ FILE_SUFFIXES = {
124
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".rb", ".java", ".c",
125
+ ".h", ".cpp", ".cs", ".php", ".swift", ".kt", ".scala", ".sh", ".bash",
126
+ ".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg",
127
+ ".html", ".css", ".scss", ".sql", ".xml", ".csv", ".lock", ".gradle",
128
+ }
129
+
130
+ GH_FLAGS_WITH_VALUE = {"-R", "--repo", "--hostname", "-X", "--method",
131
+ "-F", "-f", "--jq", "--template"}
132
+
133
+ # Raising or merging a change request is the same boundary, different tool.
134
+ FORGE = {
135
+ "gh": {"pr": ("create", "merge", "close", "ready", "edit", "comment",
136
+ "review", "reopen"),
137
+ "release": ("create", "delete", "edit", "upload"),
138
+ "repo": ("create", "delete", "edit", "fork", "sync"),
139
+ "issue": ("create", "close", "edit", "comment", "reopen")},
140
+ "glab": {"mr": ("create", "merge", "close", "update"),
141
+ "release": ("create", "delete")},
142
+ }
143
+
144
+ # Wrappers that sit in front of the real command.
145
+ PREFIXES = {"sudo", "env", "time", "nohup", "nice", "command", "exec",
146
+ "doas", "stdbuf", "timeout"}
147
+
148
+ # Shell grouping and loop words. They are punctuation, not programs, and left
149
+ # in place they became words[0] — so `for f in x; do git push; done` and
150
+ # `(git push)` were both allowed.
151
+ GROUPING = {"(", "{", "}", ")", "then", "else", "do", "done", "fi", "!",
152
+ "for", "while", "until", "if", "in", "case", "esac", "elif"}
153
+
154
+ # Wrapper flags that consume the next token, so it is not the command either.
155
+ # Which flags each wrapper takes a VALUE for. One shared set was wrong: `sudo
156
+ # -n` and `env -i` take no value, so the program name after them was eaten and
157
+ # `sudo -n rm <a guard>` was allowed.
158
+ WRAPPER_VALUE_FLAGS = {
159
+ "nice": {"-n", "--adjustment"},
160
+ "timeout": {"-k", "-s", "--kill-after", "--signal"},
161
+ "env": {"-u", "-C", "--unset", "--chdir"},
162
+ "sudo": {"-u", "-g", "-p", "-C", "-r", "-t", "--user", "--group"},
163
+ "doas": {"-u", "-C"},
164
+ "stdbuf": {"-i", "-o", "-e"},
165
+ "command": set(),
166
+ "exec": set(),
167
+ "time": set(),
168
+ "nohup": set(),
169
+ }
170
+
171
+ # Shells and runners that take a command as an ARGUMENT. Whatever follows is
172
+ # inspected in its own right; otherwise `bash -c "git commit"` walked through.
173
+ INDIRECT = {"bash", "sh", "zsh", "ksh", "dash", "eval", "xargs", "watch"}
174
+
175
+ # Grouping characters are punctuation, not part of the program name. Without
176
+ # this, "(rm <guard>)" tokenised as "(rm" and matched nothing.
177
+ GROUPING_EDGE = re.compile(r"(?<![\w-])([(){}])(?![\w-])|([(){}])")
178
+
179
+
180
+ def ungroup(text):
181
+ return GROUPING_EDGE.sub(lambda m: " %s " % (m.group(1) or m.group(2)), text)
182
+
183
+
184
+ SPLIT = re.compile(r"(?:&&|\|\||[;|&\n])")
185
+ # Text being written is data, not instruction. A here-document body is the
186
+ # contents of a file, and reading it as a command refused a legitimate write
187
+ # while this kit's own tests were being written.
188
+ HEREDOC = re.compile(r"<<-?\s*'?\"?([A-Za-z_][A-Za-z0-9_]*)'?\"?.*?^\1",
189
+ re.S | re.M)
190
+ ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
191
+
192
+
193
+ def strength(root=None):
194
+ """block, warn or off. Environment wins, then the file, then the default."""
195
+ env = os.environ.get("FORMWORK_GIT_BOUNDARY", "").strip().lower()
196
+ if env in STRENGTHS:
197
+ return env
198
+ root = root or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
199
+ config = os.path.join(root, ".formwork.toml")
200
+ if os.path.exists(config):
201
+ try:
202
+ text = open(config, encoding="utf-8", errors="ignore").read()
203
+ except OSError:
204
+ return "block"
205
+ m = re.search(r'^\s*git_boundary\s*=\s*["\']([^"\']+)["\']', text, re.M)
206
+ if m and m.group(1).strip().lower() in STRENGTHS:
207
+ return m.group(1).strip().lower()
208
+ return "block"
209
+
210
+
211
+ def tokens(segment):
212
+ """Rough split. Quotes are stripped; this reads intent, not syntax.
213
+
214
+ A leading backslash is removed: `\\git push` is the standard way to step
215
+ past a shell alias, and it ran straight through this guard.
216
+ """
217
+ out = []
218
+ for t in segment.split():
219
+ t = t.strip("'\"")
220
+ if t.startswith("\\") and len(t) > 1:
221
+ t = t[1:]
222
+ if t:
223
+ out.append(t)
224
+ return out
225
+
226
+
227
+ def strip_prefixes(words):
228
+ """Drop leading wrappers to reach the real command.
229
+
230
+ Wrappers take options of their own — `nice -n 5`, `timeout 60`,
231
+ `env -u FOO`, `sudo -u me`. Stopping at the first token that is not a
232
+ known wrapper meant any of those shielded whatever came after it, and
233
+ `nice -n 5 git push` was allowed.
234
+ """
235
+ i = 0
236
+ while i < len(words):
237
+ w = words[i]
238
+ if w in GROUPING:
239
+ i += 1
240
+ continue
241
+ if ASSIGNMENT.match(w) or w in PREFIXES:
242
+ i += 1
243
+ # Skip the wrapper's own flags, and a value where one is taken.
244
+ takes = WRAPPER_VALUE_FLAGS.get(w, set())
245
+ while i < len(words) and words[i].startswith("-"):
246
+ takes_value = words[i] in takes
247
+ i += 1
248
+ if takes_value and i < len(words):
249
+ i += 1
250
+ # A bare number after a wrapper is its argument, not a command.
251
+ if i < len(words) and words[i].isdigit():
252
+ i += 1
253
+ continue
254
+ break
255
+ return words[i:]
256
+
257
+
258
+ def git_subcommand(words):
259
+ """Skip git's own options to reach the subcommand. Handles -C and -c."""
260
+ i = 1
261
+ while i < len(words):
262
+ w = words[i]
263
+ if w in ("-C", "-c", "--git-dir", "--work-tree", "--namespace",
264
+ "--exec-path"):
265
+ i += 2
266
+ continue
267
+ if w.startswith("--git-dir=") or w.startswith("--work-tree=") \
268
+ or w.startswith("--namespace=") or w.startswith("-c"):
269
+ i += 1
270
+ continue
271
+ if w.startswith("-"):
272
+ i += 1
273
+ continue
274
+ return w, words[i + 1:]
275
+ return None, []
276
+
277
+
278
+ def verdict_for(segment):
279
+ """Return a reason to refuse, or None."""
280
+ words = strip_prefixes(tokens(segment))
281
+ if not words:
282
+ return None
283
+ program = os.path.basename(words[0])
284
+
285
+ if program == "git":
286
+ sub, rest = git_subcommand(words)
287
+ if sub is None:
288
+ return None
289
+ if sub in READS:
290
+ return None
291
+ if sub in WRITES:
292
+ return "git %s changes the repository" % sub
293
+ if sub in CONDITIONAL:
294
+ for flag in CONDITIONAL[sub]:
295
+ if flag in rest:
296
+ return "git %s %s changes the repository" % (sub, flag)
297
+ # Some subcommands write with no flag at all, purely by having
298
+ # arguments. `git config a.b c` sets a value. `git checkout FILE`
299
+ # discards uncommitted work. Both were allowed.
300
+ # A redirection is not an argument. `git config --local user.email
301
+ # 2>/dev/null` is a read, and counting `2>/dev/null` as a value
302
+ # made it look like a write.
303
+ plain = [r for r in rest
304
+ if not r.startswith("-")
305
+ and ">" not in r and "<" not in r and r != "|"]
306
+ # Bare `git stash` is `git stash push`. It moves your changes.
307
+ if sub == "stash" and not plain:
308
+ return "git stash with no subcommand stashes your changes"
309
+ # `git apply` writes unless it is one of the dry runs.
310
+ if sub == "apply" and not any(
311
+ r.startswith(("--check", "--stat", "--summary",
312
+ "--numstat")) for r in rest):
313
+ return "git apply changes files in the working tree"
314
+ # --get and --list read, whatever scope they are given. Refusing
315
+ # `git config --global --get user.name` was a false positive, and
316
+ # the message said it changed the repository, which is untrue.
317
+ if sub == "config" and any(
318
+ r.startswith(("--get", "--list", "-l")) for r in rest):
319
+ return None
320
+ # Setting your name, your email or your editor is routine. What
321
+ # this is guarding against is a write that switches off the hooks.
322
+ DANGEROUS_CONFIG = ("hookspath", "core.hookspath", "alias.",
323
+ "core.editor" "", "include.path",
324
+ "core.fsmonitor", "core.sshcommand",
325
+ "credential.helper", "filter.", "diff.external",
326
+ "pager.", "core.pager", "uploadpack.",
327
+ "receive.")
328
+ if sub == "config" and len(plain) >= 2:
329
+ key = plain[0].lower()
330
+ if any(key.startswith(d) or d in key
331
+ for d in ("hookspath", "alias.", "include.path",
332
+ "fsmonitor", "sshcommand", "credential.helper",
333
+ "filter.", "external", "uploadpack.",
334
+ "receive.")):
335
+ return ("git config %s can change what runs on your "
336
+ "machine, including switching off these hooks"
337
+ % plain[0])
338
+ return None
339
+ # `git checkout main` moves to a branch and discards nothing.
340
+ # `git checkout somefile.py` throws your work away. Tell them
341
+ # apart by asking the filesystem.
342
+ # `git checkout main` moves to a branch and discards nothing.
343
+ # `git checkout somefile.py` throws your work away. A branch name
344
+ # has no slash and no file extension, so ask that as well as the
345
+ # filesystem: the file may not exist yet and still be meant.
346
+ if sub == "checkout" and plain:
347
+ # A slash does not mean a path: `feature/new-thing` is an
348
+ # ordinary branch name, and refusing it sent people to
349
+ # `git switch` or to turning the guard off.
350
+ looks_like_path = [
351
+ q for q in plain
352
+ if os.path.exists(q)
353
+ or os.path.splitext(q)[1].lower() in FILE_SUFFIXES]
354
+ if looks_like_path or "--" in rest:
355
+ return ("git checkout with a path discards uncommitted "
356
+ "work in it")
357
+ if sub == "symbolic-ref" and len(plain) >= 2:
358
+ return "git symbolic-ref with a value repoints a reference"
359
+ return None
360
+ # An unknown subcommand is not waved through. Say so rather than
361
+ # guessing, and let a human decide.
362
+ return "git %s is not on the read-only list" % sub
363
+
364
+ if program in FORGE:
365
+ # Skip the tool's own global options and their values. Reading
366
+ # words[1] and words[2] positionally meant `gh -R owner/repo pr
367
+ # create` was invisible.
368
+ rest = []
369
+ skip = False
370
+ for w in words[1:]:
371
+ if skip:
372
+ skip = False
373
+ continue
374
+ if w.startswith("-"):
375
+ skip = "=" not in w and w in GH_FLAGS_WITH_VALUE
376
+ continue
377
+ rest.append(w)
378
+ # `gh api -X POST ...` has no group/action pair to read at all.
379
+ if rest and rest[0] == "api" and re.search(
380
+ r"(?:-X|--method)\s+(POST|PUT|PATCH|DELETE)", segment, re.I):
381
+ return ("%s api with a writing method changes the repository"
382
+ % program)
383
+ if len(rest) < 2:
384
+ return None
385
+ group, action = rest[0], rest[1]
386
+ if group in FORGE[program] and action in FORGE[program][group]:
387
+ return "%s %s %s changes a shared repository" % (program, group, action)
388
+ return None
389
+
390
+ return None
391
+
392
+
393
+ # A heredoc body is data only when it is being written somewhere. Fed to a
394
+ # shell or an interpreter it is code, and stripping it hid the command
395
+ # completely. This was introduced by the fix for the opposite false positive,
396
+ # which is a good reminder that a guard change needs its own test.
397
+ # Does this line hand the heredoc to something that will RUN it?
398
+ #
399
+ # The first version was a regular expression with an unparenthesised
400
+ # alternation. Only the shell branch was anchored, so `cat > retrieval.md` was
401
+ # read as `eval` and refused, while `sudo -n bash` was not read as a shell at
402
+ # all and walked through. Tokenising is slower and correct.
403
+ EXECUTES_STDIN = {"sh", "bash", "zsh", "ksh", "dash", "eval", "python",
404
+ "python3", "perl", "ruby", "node", "uv", "xargs"}
405
+
406
+
407
+ def _sink_runs_it(line):
408
+ try:
409
+ words = shlex.split(line)
410
+ except ValueError:
411
+ words = tokens(line)
412
+ words = strip_prefixes(words)
413
+ if not words:
414
+ return False
415
+ return os.path.basename(words[0].strip("'\"")) in EXECUTES_STDIN
416
+
417
+
418
+ def strip_heredocs(command):
419
+ """Remove heredoc bodies, unless the receiving program can execute them."""
420
+ def keep_or_drop(m):
421
+ before = command[:m.start()]
422
+ line = before.rsplit("\n", 1)[-1]
423
+ # Split on the last separator: `x && bash <<EOF` is a shell sink.
424
+ for sep in ("&&", "||", ";", "|"):
425
+ if sep in line:
426
+ line = line.rsplit(sep, 1)[-1]
427
+ if _sink_runs_it(line):
428
+ return m.group(0) # it is code. Leave it to be inspected.
429
+ return "<<REDACTED"
430
+ return HEREDOC.sub(keep_or_drop, command)
431
+
432
+
433
+ def decide(command):
434
+ """(refuse?, reason). Every segment of a compound command is examined.
435
+
436
+ A here-document body is removed first. It is content being written, not an
437
+ instruction. Writing a file whose text happens to contain "git commit" was
438
+ refused while this kit's own tests were being written, which is a false
439
+ positive rather than a boundary.
440
+
441
+ A command passed as an argument to a shell — `bash -c "git push"` — is
442
+ unwrapped and examined in its own right. An audit walked through this
443
+ guard with `bash -c`, `eval`, backticks and `$(...)`, so those are
444
+ unwrapped too.
445
+
446
+ **This is not containment and does not claim to be.** A determined agent
447
+ can encode a command in a form no regular expression will recognise. What
448
+ this stops is the ordinary path: the everyday `git commit` that should
449
+ have been a human's decision. See formwork/limits.md.
450
+ """
451
+ command = strip_heredocs(command)
452
+ for segment in split_all(command):
453
+ reason = verdict_for(segment)
454
+ if reason:
455
+ return True, reason
456
+ return False, None
457
+
458
+
459
+ SUBSHELL = re.compile(r"\$\(([^()]*)\)|`([^`]*)`")
460
+
461
+
462
+ def split_all(command, depth=0):
463
+ """Every command line in this string, including nested ones.
464
+
465
+ Command substitution runs what is inside it. `echo $(git push)` pushes.
466
+ Shell wrappers run their argument. Both were invisible to a guard that
467
+ only split on `;` and `&&`.
468
+ """
469
+ if depth > 4:
470
+ return
471
+ for m in SUBSHELL.finditer(command):
472
+ inner = m.group(1) or m.group(2) or ""
473
+ for seg in split_all(inner, depth + 1):
474
+ yield seg
475
+ stripped = ungroup(SUBSHELL.sub(" ", command))
476
+ for segment in SPLIT.split(stripped):
477
+ yield segment
478
+ # For a wrapper, the argument must keep its quoting: the command is
479
+ # one token, not the words it is made of.
480
+ try:
481
+ quoted = shlex.split(segment)
482
+ except ValueError:
483
+ quoted = tokens(segment)
484
+ words = strip_prefixes(quoted)
485
+ if not words:
486
+ continue
487
+ head = os.path.basename(words[0])
488
+
489
+ # `find . -exec <command> ;` runs whatever follows -exec. It was not
490
+ # inspected at all.
491
+ if head == "find":
492
+ for k, w in enumerate(words):
493
+ if w in ("-exec", "-execdir", "-ok", "-okdir"):
494
+ rest = []
495
+ for t in words[k + 1:]:
496
+ if t in (";", "\\\\;", "+"):
497
+ break
498
+ rest.append(t)
499
+ if rest:
500
+ for seg in split_all(" ".join(rest), depth + 1):
501
+ yield seg
502
+ continue
503
+
504
+ if head in INDIRECT:
505
+ rest = [w for w in words[1:]]
506
+ # A quoted single argument, as in `sh -c "git push"`.
507
+ first = None
508
+ for w in rest:
509
+ if not w.startswith("-"):
510
+ first = w
511
+ break
512
+ if first is not None:
513
+ for seg in split_all(first, depth + 1):
514
+ yield seg
515
+ # And the same command spread across argv, as in `xargs git push`
516
+ # or `watch -n1 git push`, which was only half read.
517
+ spread = [w for w in rest if not w.startswith("-")]
518
+ if len(spread) > 1:
519
+ for seg in split_all(" ".join(spread), depth + 1):
520
+ yield seg
521
+
522
+
523
+ FORMATS = {
524
+ # Where each runtime puts the shell command inside its hook payload.
525
+ "claude-code": ("tool_input", "command"),
526
+ "codex": ("tool_input", "command"),
527
+ "cursor": ("tool_input", "command"),
528
+ "gemini-cli": ("tool_input", "command"),
529
+ }
530
+
531
+
532
+ def command_from_payload(payload, fmt):
533
+ """The shell command in this payload, or None when it cannot be read.
534
+
535
+ None means refuse. Three of the four runtimes have never been run, so the
536
+ exact payload shape is NOT ESTABLISHED for them. A guard that silently
537
+ allows whatever it failed to parse is worse than no guard, because it
538
+ reports success.
539
+ """
540
+ if not isinstance(payload, dict):
541
+ return None
542
+ outer, inner = FORMATS[fmt]
543
+ box = payload.get(outer)
544
+ if box is None:
545
+ box = payload # runtimes that put the fields at the top
546
+ if not isinstance(box, dict):
547
+ return None
548
+ for key in (inner, "command", "cmd"):
549
+ v = box.get(key)
550
+ if isinstance(v, str):
551
+ return v
552
+ if v is not None:
553
+ return None # present, and not a string. Do not guess.
554
+ return ""
555
+
556
+
557
+ def main(argv):
558
+ fmt, command = None, None
559
+ i = 1
560
+ while i < len(argv):
561
+ if argv[i] == "--format" and i + 1 < len(argv):
562
+ fmt = argv[i + 1]; i += 2; continue
563
+ if argv[i] == "--command" and i + 1 < len(argv):
564
+ command = argv[i + 1]; i += 2; continue
565
+ i += 1
566
+
567
+ if command is None:
568
+ if fmt is None:
569
+ print("ERROR: give --command, or --format with a payload on stdin",
570
+ file=sys.stderr)
571
+ return REFUSE
572
+ if fmt not in FORMATS:
573
+ print("ERROR: unknown runtime format: %s" % fmt, file=sys.stderr)
574
+ print(" The guard did not decide, so it refuses. Failing "
575
+ "open would hide the fact that the boundary is gone.",
576
+ file=sys.stderr)
577
+ return REFUSE
578
+ raw = sys.stdin.read()
579
+ if not raw.strip():
580
+ print("ERROR: no payload arrived on stdin.", file=sys.stderr)
581
+ print(" The guard did not decide, so it refuses. This "
582
+ "usually means the hook is wired up wrongly.",
583
+ file=sys.stderr)
584
+ return REFUSE
585
+ try:
586
+ payload = json.loads(raw)
587
+ except ValueError as e:
588
+ print("ERROR: payload is not readable: %s" % e, file=sys.stderr)
589
+ print(" Refusing rather than guessing.", file=sys.stderr)
590
+ return REFUSE
591
+ command = command_from_payload(payload, fmt)
592
+ if command is None:
593
+ print("ERROR: payload is not a shape this guard understands.",
594
+ file=sys.stderr)
595
+ print(" Refusing rather than allowing something unread.",
596
+ file=sys.stderr)
597
+ return REFUSE
598
+
599
+ if not command:
600
+ # A payload with no shell command in it. Reading a file, editing one,
601
+ # calling a tool that is not a shell. Nothing to inspect.
602
+ return ALLOW
603
+
604
+ refuse, reason = decide(command)
605
+ if not refuse:
606
+ return ALLOW
607
+
608
+ level = strength()
609
+ if level == "off":
610
+ return ALLOW
611
+ if level == "warn":
612
+ print("The version-control boundary would have refused this: %s."
613
+ % reason, file=sys.stderr)
614
+ print("It is set to warn, so the command ran.", file=sys.stderr)
615
+ return WARN
616
+
617
+ print("REFUSED by the version-control boundary: %s." % reason,
618
+ file=sys.stderr)
619
+ print("The human does all of it. Report what you would have run, and stop.",
620
+ file=sys.stderr)
621
+ print("To change this for one session: FORMWORK_GIT_BOUNDARY=warn, or off.",
622
+ file=sys.stderr)
623
+ return REFUSE
624
+
625
+
626
+ if __name__ == "__main__":
627
+ sys.exit(main(sys.argv))