@arbiterforge/ca-pi 0.6.0

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 (206) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +558 -0
  3. package/package.json +35 -0
  4. package/plugins/ca-pi/CHANGELOG.md +1030 -0
  5. package/plugins/ca-pi/COMMANDS.md +90 -0
  6. package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
  7. package/plugins/ca-pi/SKILLS.md +47 -0
  8. package/plugins/ca-pi/SPRINT.md +142 -0
  9. package/plugins/ca-pi/agents/INDEX.md +31 -0
  10. package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
  11. package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
  12. package/plugins/ca-pi/agents/backend-author.md +60 -0
  13. package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
  14. package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
  15. package/plugins/ca-pi/agents/decision-challenger.md +116 -0
  16. package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
  17. package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
  18. package/plugins/ca-pi/agents/finding-triage.md +86 -0
  19. package/plugins/ca-pi/agents/frontend-author.md +64 -0
  20. package/plugins/ca-pi/agents/grader.md +173 -0
  21. package/plugins/ca-pi/agents/infra-author.md +64 -0
  22. package/plugins/ca-pi/agents/map-deps.md +35 -0
  23. package/plugins/ca-pi/agents/map-structure.md +37 -0
  24. package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
  25. package/plugins/ca-pi/agents/scout.md +127 -0
  26. package/plugins/ca-pi/agents/security-reviewer.md +72 -0
  27. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
  28. package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
  29. package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
  30. package/plugins/ca-pi/generated/command-catalog.json +197 -0
  31. package/plugins/ca-pi/generated/roles.json +213 -0
  32. package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
  33. package/plugins/ca-pi/hooks/_activationlib.py +196 -0
  34. package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
  35. package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
  36. package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
  37. package/plugins/ca-pi/hooks/_boxlib.py +131 -0
  38. package/plugins/ca-pi/hooks/_colorlib.py +304 -0
  39. package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
  40. package/plugins/ca-pi/hooks/_entrylib.py +41 -0
  41. package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
  42. package/plugins/ca-pi/hooks/_gitexec.py +45 -0
  43. package/plugins/ca-pi/hooks/_githooks.py +920 -0
  44. package/plugins/ca-pi/hooks/_gitlib.py +110 -0
  45. package/plugins/ca-pi/hooks/_hooklib.py +595 -0
  46. package/plugins/ca-pi/hooks/_host.py +115 -0
  47. package/plugins/ca-pi/hooks/_intentlib.py +242 -0
  48. package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
  49. package/plugins/ca-pi/hooks/_metricslib.py +709 -0
  50. package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
  51. package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
  52. package/plugins/ca-pi/hooks/_previewlib.py +193 -0
  53. package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
  54. package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
  55. package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
  56. package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
  57. package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
  58. package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
  59. package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
  60. package/plugins/ca-pi/hooks/_scopelib.py +262 -0
  61. package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
  62. package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
  63. package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
  64. package/plugins/ca-pi/hooks/_sloplib.py +244 -0
  65. package/plugins/ca-pi/hooks/_standuplib.py +214 -0
  66. package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
  67. package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
  68. package/plugins/ca-pi/hooks/_updatelib.py +278 -0
  69. package/plugins/ca-pi/hooks/babysit.py +47 -0
  70. package/plugins/ca-pi/hooks/boardsync.py +129 -0
  71. package/plugins/ca-pi/hooks/doctor.py +420 -0
  72. package/plugins/ca-pi/hooks/git-enforce.py +325 -0
  73. package/plugins/ca-pi/hooks/hostapi.py +460 -0
  74. package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
  75. package/plugins/ca-pi/hooks/metrics.py +62 -0
  76. package/plugins/ca-pi/hooks/migration-pass.py +129 -0
  77. package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
  78. package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
  79. package/plugins/ca-pi/hooks/pre-bash.py +90 -0
  80. package/plugins/ca-pi/hooks/pre-edit.py +284 -0
  81. package/plugins/ca-pi/hooks/pre-read.py +81 -0
  82. package/plugins/ca-pi/hooks/pre-write.py +217 -0
  83. package/plugins/ca-pi/hooks/preview.py +69 -0
  84. package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
  85. package/plugins/ca-pi/hooks/releasehash.py +216 -0
  86. package/plugins/ca-pi/hooks/security-pass.py +139 -0
  87. package/plugins/ca-pi/hooks/session-start.py +1218 -0
  88. package/plugins/ca-pi/hooks/statusline.py +736 -0
  89. package/plugins/ca-pi/hooks/taskwrite.py +351 -0
  90. package/plugins/ca-pi/hooks/update-refresh.py +51 -0
  91. package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
  92. package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
  93. package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
  94. package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
  95. package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
  96. package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
  97. package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
  98. package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
  99. package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
  100. package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
  101. package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
  102. package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
  103. package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
  104. package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
  105. package/plugins/ca-pi/includes/compaction-charter.md +16 -0
  106. package/plugins/ca-pi/includes/cut-docs.md +16 -0
  107. package/plugins/ca-pi/includes/dev-mode.md +30 -0
  108. package/plugins/ca-pi/includes/farm.md +237 -0
  109. package/plugins/ca-pi/includes/fresh-verification.md +14 -0
  110. package/plugins/ca-pi/includes/harvest.md +69 -0
  111. package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
  112. package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
  113. package/plugins/ca-pi/includes/redirect.md +69 -0
  114. package/plugins/ca-pi/includes/reference-map.md +22 -0
  115. package/plugins/ca-pi/includes/review-matrix.md +14 -0
  116. package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
  117. package/plugins/ca-pi/includes/routing-table.md +47 -0
  118. package/plugins/ca-pi/includes/security-gate-record.md +22 -0
  119. package/plugins/ca-pi/includes/smarts/core.md +90 -0
  120. package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
  121. package/plugins/ca-pi/routines/INDEX.md +32 -0
  122. package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
  123. package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
  124. package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
  125. package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
  126. package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
  127. package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
  128. package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
  129. package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
  130. package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
  131. package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
  132. package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
  133. package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
  134. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
  135. package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
  136. package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
  137. package/plugins/ca-pi/routines/release/SKILL.md +315 -0
  138. package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
  139. package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
  140. package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
  141. package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
  142. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
  143. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
  144. package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
  145. package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
  146. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
  147. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
  148. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
  149. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
  150. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
  151. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
  152. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
  153. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
  154. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
  155. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
  156. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
  157. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
  158. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
  159. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
  160. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
  161. package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
  162. package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
  163. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
  164. package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
  165. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
  166. package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
  167. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
  168. package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
  169. package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
  170. package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
  171. package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
  172. package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
  173. package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
  174. package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
  175. package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
  176. package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
  177. package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
  178. package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
  179. package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
  180. package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
  181. package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
  182. package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
  183. package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
  184. package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
  185. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
  186. package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
  187. package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
  188. package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
  189. package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
  190. package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
  191. package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
  192. package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
  193. package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
  194. package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
  195. package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
  196. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
  197. package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
  198. package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
  199. package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
  200. package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
  201. package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
  202. package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
  203. package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
  204. package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
  205. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
  206. package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
@@ -0,0 +1,1667 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — PreToolUse(Bash|PowerShell) guard logic (issue #320). Extracted
3
+ # verbatim from pre-bash.py: command parsing/tokenization, heredoc/executor
4
+ # detection, git -C composition, and the H-01/H-02/H-03/H-05/H-09b/H-10b/H-11/
5
+ # H-14/H-18/H-19/H-20 check functions, composed by run_guards() into the same
6
+ # scan pre-bash.py used to run inline. pre-bash.py is now a thin entry point:
7
+ # read stdin, call run_guards(), exit.
8
+ #
9
+ # Branch/push/staging + security gate. Python port of pre-bash.sh (issues #24,
10
+ # #25): no jq, fails loud, blocks via exit 2. Includes H-09b/H-10b — a BLOCKING
11
+ # crypto/secret commit gate (#24). All guards run only in arbiter-enabled repos
12
+ # (the plugin.json activation contract) — pre-bash.py's main() checks that
13
+ # before ever calling run_guards().
14
+ #
15
+ # Ambiguity resolves CLOSED here. Some patterns below block a harmless
16
+ # spelling (e.g. `cp overrides.log backup` copies FROM the log) because the
17
+ # destructive spelling is indistinguishable without a full shell parse;
18
+ # /ca:override is the sanctioned escape hatch, and a false allow on the audit
19
+ # trail is unrecoverable after the fact.
20
+ #
21
+ # Zero import-time side effects; the only filesystem/git access is inside the
22
+ # named readers below (current_branch, head_on_protected_tip, added_lines,
23
+ # _names, read_worktree) — each fails CLOSED (returns None) on a git-read
24
+ # error rather than raising, per this repo's "never raise on malformed input"
25
+ # invariant; the caller (run_guards / _require_branch / _require_tip) decides
26
+ # how to fail closed.
27
+ #
28
+ # Public API:
29
+ # run_guards(payload, root) -> None parse the Bash/PowerShell tool_input
30
+ # from `payload`, run every H-NN gate
31
+ # against it, block() (exit 2) on a
32
+ # violation, else sys.exit(0)
33
+ # git_cwd(cmd, root) -> str the `-C <dir>` target a git invocation
34
+ # actually runs against, else `root`
35
+ # current_branch(cwd) -> str|None current branch, "" if detached, None
36
+ # on a git-read failure
37
+ # is_protected_branch(branch) -> bool True iff branch is main/master
38
+ # (case-insensitive)
39
+ # head_on_protected_tip(cwd) -> bool|None True iff detached HEAD sits on a
40
+ # protected branch's tip
41
+ # added_lines(cwd, ref, paths=None) -> str|None added ('+') diff lines,
42
+ # narrowed to the H-09b/H-10b candidate
43
+ # set, or None on a git-read failure
44
+ # staged_paths(cwd) -> set|None index paths (`git diff --cached
45
+ # --name-only`), or None on failure
46
+ # worktree_paths(cwd) -> set|None tracked-modified + untracked paths,
47
+ # or None on failure
48
+ # read_worktree(cwd, rel) -> str|None worktree content of `rel`, or None if
49
+ # absent/oversize
50
+ # add_violation(args, cwd) -> str|None the reason a `git add` argument set
51
+ # is not explicit-file staging, or None
52
+ # commit_pathspecs(args) -> list[str] worktree paths a `git commit` names
53
+ # as pathspecs
54
+ # _strip_heredoc_bodies(args) -> str remove heredoc operator+body+delimiter
55
+ # from a git-commit arg string (kept
56
+ # underscore-named + re-exported from
57
+ # pre-bash.py — .github/scripts/
58
+ # test_hook_guards.py imports it by
59
+ # this exact name)
60
+ # _state_write_res(basename) -> (redirect_re, write_re, git_restore_re,
61
+ # interp_re) H-22's per-entry
62
+ # shell-flank regex TEMPLATE for one
63
+ # protected-state registry entry's
64
+ # bare filename (T-08, #564; the
65
+ # git-restore and interpreter legs
66
+ # added per findings F5/F6)
67
+ # _build_state_write_res(registry) -> tuple[(rel_path, policy, redirect_re,
68
+ # write_re, git_restore_re,
69
+ # interp_re), ...] the compiled set
70
+ # for every entry in `registry`;
71
+ # `_STATE_WRITE_RES` is this, built
72
+ # once at import from the live
73
+ # `_protectedstatelib.REGISTRY`
74
+ # _check_h22_state(cmd, root) -> None H-22's run_guards() gate — block()s
75
+ # on a shell mutation of a registered
76
+ # protected-state file
77
+
78
+ import os
79
+ import re
80
+ import subprocess
81
+ import sys
82
+
83
+ from _hooklib import (
84
+ AUDIT_LOG_BASENAMES, AUDIT_LOG_NAMES, CRYPTO_RE, DECISION_LOG_BASENAME, DECISIONS_DIR_RE,
85
+ GATE_MARKER_NAMES, MARKER_FRESHNESS_MINUTES, SECRET_RE, SECURITY_DIFF_GIT_ARGS, block,
86
+ content_digest, is_migration_path, line_digest, marker_fresh, sensitive_scan_added_lines,
87
+ )
88
+ from _gitexec import git_executable
89
+ import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE /
90
+ # gitdir: pointer) project_root() climb (#223)
91
+ from hostapi import git_worktree_main_root # noqa: #604 marker-root escalation
92
+ import _protectedstatelib # H-22's shell flank (T-08, #564) — imported as a
93
+ # module (not `from ... import REGISTRY`) so
94
+ # _STATE_WRITE_RES below is built from a live
95
+ # attribute lookup at import time, never a
96
+ # snapshotted name binding.
97
+ from _protectedstatelib import ProtectedPolicy, marker_gated_write_admitted
98
+
99
+ # The most recent git-read failure, surfaced in the H-01/H-09b/H-14 fail-closed
100
+ # block message. "git unavailable or timed out" alone cost a session of root-
101
+ # causing (2026-07-01: the real error was a pathspec-parse artifact, visible in
102
+ # one look at git's stderr); a fail-closed message must carry its evidence.
103
+ _READ_ERRS = []
104
+
105
+
106
+ def _note_read_err(argv, detail):
107
+ _READ_ERRS.append(f"`{' '.join(argv)}` -> {(detail or '').strip()[:200]}")
108
+
109
+
110
+ def _read_err_hint():
111
+ return f" Underlying git error: {_READ_ERRS[-1]}" if _READ_ERRS else ""
112
+
113
+ # `git` followed by any run of global options (-C <dir>, -c k=v, --git-dir=…,
114
+ # --no-pager, …) before the subcommand — `git -C ../x commit` must not slip
115
+ # past a bare `git\s+commit` match.
116
+ # appsec-002 (#175): a literal `--no-verify` / `-n` on `git commit`/`git push`
117
+ # skips `.git/hooks` entirely — the documented "spelling-proof backstop"
118
+ # (git-enforce.py) never runs for that operation, voiding H-01/H-02/H-09b/
119
+ # H-10b/H-14 for it. `-n` is git-commit's short spelling of --no-verify — but
120
+ # NOT only as its own token: git bundles short flags into one cluster
121
+ # (`-nm "x"` = `-n` + `-m x`, the everyday spelling), including with an
122
+ # attached value (`-nm=x`, `-nm123`), so the exact-token check alone missed it
123
+ # (security-reviewer HIGH x2: the bare cluster case, then the attached-value
124
+ # cluster case — see `_commit_no_verify_in_cluster`). `git push` has NO short
125
+ # spelling for this (its own `-n` is `--dry-run`, an unrelated flag), so only
126
+ # the long form is checked there, and it never clusters this way in practice
127
+ # for our purposes.
128
+ # Token-equality (not substring) so a commit MESSAGE merely quoting the text
129
+ # ('-m "explain --no-verify"') is never misclassified — the quoted phrase
130
+ # tokenizes as one whole argument, never equal to the bare flag. Scanning
131
+ # stops at a bare `--` token (end-of-options): `git commit -- --no-verify`
132
+ # passes `--no-verify` as a pathspec, not a flag, and must not be misblocked.
133
+ # The deeper shell-indirection spelling (`g=git; $g commit --no-verify`)
134
+ # defeats this lexical check same as it defeats COMMIT_RE itself; that
135
+ # residual is documented separately, out of scope for this guard.
136
+ COMMIT_NO_VERIFY_FLAGS = frozenset({"--no-verify", "-n"})
137
+ PUSH_NO_VERIFY_FLAGS = frozenset({"--no-verify"})
138
+ # `git commit` short options that consume the REST of their cluster as a
139
+ # value (bundled short-flag form, e.g. `-mMSG`, `-Cref`): once one of these is
140
+ # hit while scanning a cluster left-to-right, every character after it is that
141
+ # option's value, not a further flag — so a `-mn` cluster's trailing `n` is
142
+ # the MESSAGE "n", not `-n`/--no-verify, and must not block.
143
+ COMMIT_SHORT_VALUE_CHARS = frozenset("mcCFtSu")
144
+
145
+
146
+ def _has_literal_flag(args, flags):
147
+ """True iff `args` contains one of `flags` as its own token — quote-aware
148
+ (a fully-quoted token, e.g. a `-m "..."` message, tokenizes as ONE token
149
+ and is compared whole, so it can never equal a single bare flag).
150
+ Scanning stops at a bare `--` (end-of-options): anything after it is a
151
+ pathspec/value, never a flag."""
152
+ for raw in re.findall(r'"[^"]+"|\'[^\']+\'|\S+', args):
153
+ tok = raw.strip("\"'")
154
+ if tok == "--":
155
+ break
156
+ if tok in flags:
157
+ return True
158
+ return False
159
+
160
+
161
+ def _commit_no_verify_in_cluster(args):
162
+ """True iff a `git commit` bundled short-flag cluster token (e.g. `-nm`,
163
+ `-vn`, `-nm=x`, `-nm123`) carries `-n` (no-verify) BEFORE any
164
+ argument-taking short option — or any attached-value byte — in that same
165
+ token ends the flag-cluster portion. Walks each token that starts with
166
+ `-`+letter (and is not `--...`) left-to-right past the leading `-`:
167
+
168
+ * `n` seen first -> no-verify -> BLOCK.
169
+ * a COMMIT_SHORT_VALUE_CHARS member seen first -> that flag consumes the
170
+ REST of the token as its value (e.g. `-mn` is `-m` with value "n") ->
171
+ stop scanning this token, not a match.
172
+ * the first NON-ALPHABETIC character (`=`, a digit, `.`, `"`, …) seen
173
+ first -> an attached value has begun (e.g. `-nm=x`, `-nm123`) -> stop
174
+ scanning this token's REMAINDER, but everything scanned so far still
175
+ counts — this is what closes `-nm=x`/`-nm123` (the `n` before the
176
+ `m`/`=`/digit still blocks) while never mis-reading the attached value
177
+ itself as more flag letters.
178
+
179
+ security-reviewer HIGH (second pass): a `re.fullmatch(r"-[A-Za-z]+", tok)`
180
+ gate previously skipped the whole token whenever ANY character after the
181
+ dashes wasn't a letter — so `-nm=x`/`-nm123`/`-vnm=y` (a leading `-n`
182
+ immediately followed by an attached value) were never even inspected,
183
+ silently letting `-n`/no-verify through. Matching on `-[A-Za-z]` (just the
184
+ first character after `-`) instead of requiring the WHOLE token to be
185
+ letters fixes this at the root: every token that OPENS a short-flag
186
+ cluster is now walked, and the walk itself (not the initial token shape)
187
+ decides where the flag portion ends.
188
+
189
+ Complements the exact-token `-n`/`--no-verify` case in
190
+ COMMIT_NO_VERIFY_FLAGS, which only catches `-n` as its OWN whole token.
191
+ Scanning stops at a bare `--` (end-of-options)."""
192
+ for raw in re.findall(r'"[^"]+"|\'[^\']+\'|\S+', args):
193
+ tok = raw.strip("\"'")
194
+ if tok == "--":
195
+ break
196
+ if tok.startswith("--") or not re.match(r"-[A-Za-z]", tok):
197
+ continue # a long flag, a value, a pathspec, or not a flag at all
198
+ for ch in tok[1:]:
199
+ if ch == "n":
200
+ return True
201
+ if ch in COMMIT_SHORT_VALUE_CHARS:
202
+ break # rest of THIS token is that flag's value; stop here
203
+ if not ch.isalpha():
204
+ break # an attached value (=, digit, ., "…) has begun; stop here
205
+ return False
206
+
207
+
208
+ GIT_OPTION_VALUE = r'(?:"[^"]+"|\'[^\']+\'|\S+)'
209
+ # Git's path/namespace/executable/config global options accept both
210
+ # `--flag=value` and `--flag value`. The generic long-option leg covers the
211
+ # equals form and valueless flags; this explicit leg keeps a separated value
212
+ # from being mistaken for the subcommand (#335 coverage review).
213
+ GIT_LONG_VALUE_OPTION = (
214
+ r"--(?:git-dir|work-tree|namespace|exec-path|super-prefix|config-env)"
215
+ )
216
+ GIT_GLOBAL_OPTION = (
217
+ r"(?:-[Cc]\s+" + GIT_OPTION_VALUE
218
+ + r"|" + GIT_LONG_VALUE_OPTION + r"\s+" + GIT_OPTION_VALUE
219
+ + r"|--[\w-]+(?:=\S+)?|-\w+)"
220
+ )
221
+ GIT = r"\bgit(?:\s+" + GIT_GLOBAL_OPTION + r")*"
222
+ # The args capture stops at an unquoted `|`, `;`, or `&` (the next shell command
223
+ # is not this git command's business) but consumes quoted strings whole — a
224
+ # `;` inside `-m "scoped; and true"` is message content, not a separator.
225
+ # Truncating inside the quoted message left an unterminated `$(cat <<'EOF'`
226
+ # fragment whose words were then parsed as pathspecs, and a token like
227
+ # `/ca:checkpoint)` made `git diff HEAD -- …` fatal ("outside repository"),
228
+ # failing the H-09b scan CLOSED on a clean commit.
229
+ ARGS = r"(?P<args>(?:\"[^\"]*\"|'[^']*'|[^|;&])*)"
230
+ # The `commit` SUBCOMMAND, not every verb starting with it (#485). A word
231
+ # boundary sits between `commit` and `-`, so a bare `commit\b` matched
232
+ # `git commit-graph write` and gated object-database maintenance as though it
233
+ # were a commit — pointing the operator at the crypto-compliance gate for a
234
+ # command that writes no objects and creates no commit, whose only escapes were
235
+ # a pass certifying nothing or an override covering a coverage hole (the habit
236
+ # ADR-0022 and #308 exist to prevent). It bit at the worst moment too: a stale
237
+ # graph is what a batch of `--delete-branch` merges leaves behind.
238
+ #
239
+ # The exclusion is an explicit ALLOW of one proven-safe verb rather than an
240
+ # allowlist of gated ones, so the failure direction stays closed — a `commit-*`
241
+ # name this matcher has never seen is still gated. `commit-tree` deliberately
242
+ # stays in scope: it creates a commit object, and a crafted commit plus
243
+ # `update-ref` is a real path around H-01.
244
+ COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])"
245
+ COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS)
246
+ PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS)
247
+ ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS)
248
+ # #485 AC-4, the audit of the sibling matchers. Neither needs the same
249
+ # treatment, and both were left alone deliberately:
250
+ #
251
+ # push — git has no `push-*` subcommand, so `push\b` has nothing to
252
+ # over-match onto. If one ever ships, it is gated until reviewed.
253
+ # add — `git add--interactive` is a real plumbing helper and it DOES stage,
254
+ # so `add\b` reaching it is correct, not a false positive.
255
+ #
256
+ # What the audit DID surface is a different shape, and it is knowingly not
257
+ # fixed here: these matchers scan the command TEXT, so a git command quoted
258
+ # inside another command's arguments is gated as if it were being run — a
259
+ # `grep "git add"` trips H-03. Skipping quoted occurrences would be wrong, not
260
+ # merely conservative: `sh -c "git add ."` executes what it quotes, so the
261
+ # exemption would be a real bypass of the staging gate. Distinguishing the two
262
+ # needs actual shell tokenization, which is a larger change with a worse
263
+ # failure direction; over-blocking a mention is the fail-CLOSED side of that
264
+ # trade (ORCHESTRATOR §2: security before velocity). Tracked separately rather
265
+ # than half-fixed.
266
+ # reliability-004 (#190): GIT_C_DIR_RE previously matched `-C` ONLY as the
267
+ # FIRST token after `git`, so a global option in front of it (`--no-pager`,
268
+ # `-c k=v`, `--git-dir=…`) hid the -C target entirely and git_cwd() fell back
269
+ # to `root` — while COMMIT_RE/PUSH_RE (built on the same GIT global-options
270
+ # prefix as below) still matched, so the guards fired against the WRONG repo.
271
+ # GIT_OPTS_RUN_RE captures the exact same global-options run GIT already
272
+ # tolerates (group 1); GIT_C_IN_RUN_RE then finds every `-C <dir>` WITHIN that
273
+ # captured run via findall, so a -C preceded by other global options is found
274
+ # regardless of position.
275
+ GIT_OPTS_RUN_RE = re.compile(
276
+ r"\bgit((?:\s+" + GIT_GLOBAL_OPTION + r")*)")
277
+ GIT_C_IN_RUN_RE = re.compile(r"-C\s+(\"[^\"]+\"|'[^']+'|\S+)")
278
+ # Force-push in any spelling: --force, --force-with-lease[=…], -f as its own
279
+ # token (not a ref like `fix-f`), or a forcing `+refspec`.
280
+ FORCE_RE = re.compile(r"(?:^|\s)(?:--force(?:-with-lease|-if-includes)?(?:=\S+)?|-f)(?=\s|$)")
281
+ FORCE_REFSPEC_RE = re.compile(r"\s\+[\w./:~^-]+")
282
+ # Bulk-push flags that publish protected refs with no refspec token to inspect:
283
+ # `--all` pushes every local branch (main included); `--mirror` pushes every ref
284
+ # and can force-update/delete them. Neither names a destination the
285
+ # PROTECTED_DEST scan can see, so they slip the refspec check — block on sight.
286
+ PUSH_ALL_RE = re.compile(r"(?:^|\s)(?:--all|--mirror)(?=\s|$)")
287
+ # Flag spellings that stage a non-explicit file set. `-u/--update` joins
288
+ # -A/--all/. : it stages every tracked modification — wildcard in behavior
289
+ # even though no glob appears in the command.
290
+ WILDCARD_ADD_RE = re.compile(r"(?:^|\s)(?:-A|--all|-u|--update|\.)(?=\s|$)")
291
+ COMMIT_ALL_RE = re.compile(r"(?:^|\s)(?:-[a-zA-Z]*a[a-zA-Z]*|--all)(?=\s|$)")
292
+ GLOB_RE = re.compile(r"[*?\[]")
293
+ # A push destination that resolves to a protected branch, in any spelling:
294
+ # `main`, `HEAD:main`, `feature:main`, `:main` (deletion), `refs/heads/main`.
295
+ # Matched with fullmatch against each non-flag token, so `feature-main` and
296
+ # `main-fix` never trip it.
297
+ PROTECTED_DEST_RE = re.compile(r"(?:\S+:|:)?(?:refs/heads/)?(?:main|master)")
298
+ # Truncation (`>` but not `>>`) or destructive verbs aimed at an audit log
299
+ # (overrides.log, triage.log — both append-only). The verb list includes
300
+ # every common rewrite-in-place spelling (truncate, tee, dd, sed, sponge,
301
+ # cp/copy onto the log); PowerShell's Add-Content is deliberately absent —
302
+ # appending is the one sanctioned write.
303
+ # N-3: Known limitations — this regex catches the common `> file` and `>| file`
304
+ # (force-clobber) truncation forms but NOT every shell spelling that produces a
305
+ # new file descriptor on the log. Specific gaps: triple-chevron (`>>>`, treated
306
+ # as append by some shells), file-descriptor forms like
307
+ # `exec 3>.codearbiter/overrides.log`, and verb-with-VARIABLE-target spellings
308
+ # where the literal log name never appears adjacent to the verb (appsec-003):
309
+ # e.g. `f=.codearbiter/overrides.log; rm "$f"` or PowerShell `$f='overrides.log';
310
+ # rm $f` — the guard is purely lexical and anchored on the literal name, so an
311
+ # indirected target defeats it. These are difficult to close with a single regex
312
+ # and represent an accepted residual risk. The sanctioned bypass for legitimate
313
+ # log management is /ca:override.
314
+ # The optional `\|?` admits `>|` (clobber even under `set -o noclobber`); the
315
+ # leading `(?!>)` still excludes the append form `>>`.
316
+ # `sprint-log.md` joins overrides.log/triage.log as an append-only audit artifact
317
+ # (the /sprint decision record). The bare-name alternation is centralized in
318
+ # _hooklib.AUDIT_LOG_NAMES so the Write/Edit and shell flanks never drift.
319
+ LOG_NAMES = AUDIT_LOG_NAMES
320
+ LOG_TRUNC_RE = re.compile(r"(?<!>)>(?!>)\|?\s*\S*" + LOG_NAMES)
321
+ LOG_DESTROY_RE = re.compile(
322
+ r"\b(rm|del|mv|cp|copy|dd|tee|sed|truncate|sponge"
323
+ # #528: `New-Item -Force` TRUNCATES an existing file (verified in PowerShell:
324
+ # a 20-byte file becomes 0). H-11 already covered this family, so the
325
+ # arbitration log lost it on the way to H-05 — and the flat logs never had
326
+ # it. `touch` and `Add-Content` are deliberately NOT here: neither truncates,
327
+ # and Add-Content is the sanctioned append for exactly these files.
328
+ r"|ni|New-Item"
329
+ r"|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content|Out-File)\b"
330
+ r"[^|;&]*" + LOG_NAMES, re.I,
331
+ )
332
+ # #335: checkout/restore rewrite tracked worktree files through Git itself, so
333
+ # they bypass every filesystem verb above. Keep the match bounded to one shell
334
+ # command and require a literal audit-log basename in that Git invocation.
335
+ # This intentionally does not guess what a pathless `stash apply` or broad
336
+ # checkout/restore pathspec might touch; H-05's shell flank remains lexical.
337
+ LOG_GIT_RESTORE_RE = re.compile(
338
+ GIT + r"\s+(?:checkout|restore)\b[^|;&]*" + LOG_NAMES, re.I,
339
+ )
340
+ # H-11's shell flank: ADRs are authored only via /adr (pre-write/pre-edit
341
+ # guard the Write/Edit tools; this guards redirection and file verbs). Any
342
+ # redirect into .codearbiter/decisions/, or any write/delete verb naming it,
343
+ # blocks — `cat`/`ls`/`grep` reads pass untouched.
344
+ DECISIONS = DECISIONS_DIR_RE + r"\b"
345
+ # #528: the one path under decisions/ that H-11 must NOT claim — see
346
+ # _check_h11_decisions. Matched on the raw command, so both separators.
347
+ #
348
+ # DELIBERATELY CASE-SENSITIVE. H-05, which takes over for this file, is itself
349
+ # case-sensitive on both flanks: _check_h05_audit_log pre-filters with a plain
350
+ # `in` test over AUDIT_LOG_BASENAMES, and LOG_TRUNC_RE carries no re.I. An re.I
351
+ # here therefore stripped `Decision-Log.md` out of H-11's view and handed it to a
352
+ # guard that could not see it — and on Windows/NTFS and default macOS/APFS that
353
+ # spelling resolves to the real file, so `rm …/Decision-Log.md` destroyed the
354
+ # append-only log with nothing firing at all. The two flanks must agree on case.
355
+ #
356
+ # The right edge is anchored so this path cannot SHIELD a sibling token: without
357
+ # it, `touch …/decision-log.md.evil.md` was stripped to a harmless remainder and
358
+ # H-11 stopped seeing a decisions/ write at all.
359
+ DECISION_LOG_SHELL_RE = re.compile(
360
+ DECISIONS_DIR_RE + r"[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"""(?=$|[\s>|;&"'])""",
361
+ )
362
+ # `>>?\|?` covers `>`, `>>`, and the `>|` force-clobber form into decisions/.
363
+ DECISIONS_REDIRECT_RE = re.compile(r">>?\|?\s*\S*" + DECISIONS, re.I)
364
+ DECISIONS_WRITE_RE = re.compile(
365
+ r"\b(rm|del|mv|cp|copy|dd|tee|sed|touch|truncate|ni"
366
+ r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content"
367
+ r"|Out-File|Add-Content)\b[^|;&]*" + DECISIONS, re.I,
368
+ )
369
+
370
+ # H-18's shell flank: .codearbiter/CONTEXT.md is the activation switch every hook
371
+ # gates on (#159). The Write/Edit tools are guarded by pre-write/pre-edit; this
372
+ # guards the shell — a redirect into CONTEXT.md, or a write/delete verb naming
373
+ # it, would flip `arbiter: enabled` off (or corrupt the frontmatter) and make
374
+ # every gate dormant. Init writes CONTEXT.md via the Write tool, never the shell,
375
+ # so no legitimate path is blocked; `cat`/`grep` reads pass untouched. Same
376
+ # lexical limitation as the audit-log/decisions flanks (N-3): the Write/Edit
377
+ # guard is the primary boundary, this is defense in depth.
378
+ CONTEXT_MD = r"\.codearbiter[\\/]+CONTEXT\.md"
379
+ CONTEXT_REDIRECT_RE = re.compile(r">>?\|?\s*\S*" + CONTEXT_MD, re.I)
380
+ CONTEXT_WRITE_RE = re.compile(
381
+ r"\b(rm|del|mv|cp|copy|dd|tee|sed|truncate|ni"
382
+ r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content"
383
+ r"|Out-File|Add-Content)\b[^|;&]*" + CONTEXT_MD, re.I,
384
+ )
385
+
386
+ # H-19's shell flank: the two gate-pass markers (#160) are recorded ONLY by the
387
+ # python producers (security-pass.py / migration-pass.py), which write via
388
+ # os.replace and NEVER name the marker on the command line. So blocking any shell
389
+ # command that names a gate marker as a redirect or write/move/copy target closes
390
+ # the `echo <digest> > .markers/security-gate-passed` (and `cp goodmarker
391
+ # security-gate-passed`) forge without touching the sanctioned producers.
392
+ # adr-authoring-active is intentionally excluded: /adr legitimately `touch`es it,
393
+ # and an empty/forged gate marker fails H-09b/H-14's digest-coverage check anyway
394
+ # — only a marker carrying valid digests forges a pass, which shell verbs against
395
+ # the marker name are how you'd inject.
396
+ GATE_MARKER = r"\.markers[\\/]+" + GATE_MARKER_NAMES
397
+ GATE_MARKER_REDIRECT_RE = re.compile(r">>?\|?\s*\S*" + GATE_MARKER, re.I)
398
+ GATE_MARKER_WRITE_RE = re.compile(
399
+ r"\b(mv|cp|copy|dd|tee|sed|truncate"
400
+ r"|Move-Item|Copy-Item|Clear-Content|Set-Content|Out-File|Add-Content)\b"
401
+ r"[^|;&]*" + GATE_MARKER, re.I,
402
+ )
403
+ # #237: an arbitrary interpreter invocation is a flank the verb list above
404
+ # cannot see — `python -c "open('.markers/security-gate-passed','w')..."`
405
+ # reuses the sanctioned producer's own public helpers to self-compute valid
406
+ # digests, and no mv/cp/tee/sed spelling ever appears on the command line.
407
+ # Interpreter one-liners get their OWN, wider regex rather than joining the
408
+ # verb list above: the payload is a single quoted string handed to the
409
+ # interpreter, and that string may itself contain `;` as a statement
410
+ # separator (`python -c "x=1; open(...security-gate-passed...)"`). The
411
+ # `[^|;&]*` bound on GATE_MARKER_WRITE_RE exists to keep a write verb from
412
+ # reaching across an UNRELATED shell-chained command; applying that same
413
+ # bound here would stop scanning at the interpreter's OWN internal `;` and
414
+ # silently reopen exactly the hole this closes. There is no reliable way to
415
+ # tell a chained shell command from a `;`-separated statement inside an
416
+ # opaque interpreter string without full shell/language tokenization, so
417
+ # this guard fails CLOSED: any command line invoking one of these
418
+ # interpreters that ALSO names a gate marker anywhere on the line is
419
+ # blocked, whether the marker is written or merely read. A read of a gate
420
+ # marker has no legitimate reason to go through a raw interpreter one-liner
421
+ # either (cat/grep already pass the audit-log flanks above untouched).
422
+ #
423
+ # review finding (post-B-2): a `-c`/`-e` payload is ordinary multi-line
424
+ # source — the interpreter token and the marker path can sit on different
425
+ # physical lines of the SAME invocation (`python -c "\nopen('...security-
426
+ # gate-passed'...)\n"`). `[^\n]*` cannot cross that newline, leaving the
427
+ # identical attack open in its multi-line spelling. `[\s\S]*` (DOTALL-
428
+ # equivalent) closes it. This regex is applied to the heredoc-stripped
429
+ # `git_view` at the call site below, gated on `heredoc_shell_fallback` for
430
+ # the raw-`cmd` leg exactly like the commit/push/add guards already are —
431
+ # scanning the raw, unstripped `cmd` unconditionally would make crossing
432
+ # newlines here false-trip on inert PROSE inside a heredoc body fed to a
433
+ # non-shell consumer (`gh pr create --body "$(cat <<EOF … a python -c
434
+ # one-liner wrote .markers/security-gate-passed … EOF)"` — a PR/issue body
435
+ # merely DESCRIBING this very fix), the same D-3 (#223) distinction the
436
+ # other guards already draw.
437
+ # The interpreter alternation, shared by H-19's gate-marker leg below and
438
+ # H-22's per-entry `interp_re` (~line 560). ONE list, because the
439
+ # workstream-B adversary's cross-guard parity probe showed the same
440
+ # omission reaching every guard that carries an interpreter leg: the
441
+ # previous `python3?|node|perl|ruby|sh` missed `py` -- THE canonical
442
+ # Python launcher on Windows, this repo's primary dev host -- and
443
+ # `powershell`/`pwsh` entirely, so `py -c "open('…','w')"` and
444
+ # `powershell -Command "[IO.File]::WriteAllText(…)"` both walked past a
445
+ # guard whose docs affirmatively claimed interpreter coverage.
446
+ #
447
+ # `python2` is spelled out because `python3?` does not match it.
448
+ _INTERP_TOKENS = (r"python3?|python2|py|node|deno|bun|perl|ruby|php"
449
+ r"|sh|bash|zsh|pwsh|powershell")
450
+
451
+ # The inline-code switch that makes an interpreter EXECUTE A STRING rather
452
+ # than run a file. `-c` (python/py/sh/bash/zsh/pwsh), `-e`/`-E` (perl,
453
+ # ruby, node, bun), `-r` (php), `-p`/`--print`/`--eval` (node), and
454
+ # `eval` as a SUBCOMMAND (deno) rather than a flag.
455
+ #
456
+ # The nested-optional `c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?` spells the
457
+ # prefix family of PowerShell's `-Command`: PowerShell accepts any
458
+ # unambiguous abbreviation, so `-Comm`, `-Co` and `-C` are all valid and a
459
+ # literal `-Command|-c` alternation would miss them.
460
+ #
461
+ # Residual, stated rather than left to fall out of the regex: a script
462
+ # piped into an interpreter on stdin (`echo … | python3`) puts the
463
+ # filename BEFORE the interpreter token and is not reached here, and
464
+ # `deno eval` is covered only in its subcommand spelling.
465
+ _INTERP_INLINE_CODE = (
466
+ r"(?:-{1,2}(?:c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?|e|E|r|p"
467
+ r"|eval|print|encodedcommand|ec)\b|\beval\b)"
468
+ )
469
+
470
+ # H-19 widens its interpreter list (above) but deliberately does NOT take
471
+ # the inline-code requirement H-22 adds below. Narrowing an existing
472
+ # security guard is not this sprint's scope, and the argument that makes
473
+ # the requirement correct for H-22 does not transfer: handing a
474
+ # gate-marker path to a script as argv is itself the suspicious act,
475
+ # whereas handing a board filename to `taskwrite.py` is the sanctioned
476
+ # call.
477
+ GATE_MARKER_INTERP_RE = re.compile(
478
+ r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + GATE_MARKER, re.I,
479
+ )
480
+
481
+ # #574: H-05/H-11/H-18 carried NO interpreter leg at all — an inline-code
482
+ # one-liner (`python3 -c "open('.codearbiter/overrides.log','w')..."`) walks
483
+ # past LOG_TRUNC_RE/LOG_DESTROY_RE, DECISIONS_REDIRECT_RE/DECISIONS_WRITE_RE,
484
+ # and CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE alike, since none of those regexes
485
+ # ever look for an interpreter token. Each of the three below takes the SAME
486
+ # token-only shape as GATE_MARKER_INTERP_RE just above, not H-22's narrower
487
+ # `_state_write_res.interp_re` (which additionally requires
488
+ # `_INTERP_INLINE_CODE`): H-22's inline-code requirement exists ONLY because a
489
+ # sanctioned caller invokes an interpreter against the protected file's own
490
+ # name (`python3 "…/taskwrite.py" add -- "fix open-tasks.md schema"`, HIGH-1),
491
+ # and H-05/H-11/H-18 have no equivalent sanctioned interpreter caller to
492
+ # spare — narrowing them the same way would reopen exactly the gap this
493
+ # closes for no offsetting benefit. `[\s\S]*` (not `[^\n]*`), for the same
494
+ # reason GATE_MARKER_INTERP_RE carries it: the interpreter token and the
495
+ # target name may sit on different physical lines of the SAME multi-line
496
+ # `-c`/`-e` payload.
497
+ LOG_INTERP_RE = re.compile(
498
+ r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + LOG_NAMES, re.I,
499
+ )
500
+ DECISIONS_INTERP_RE = re.compile(
501
+ r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + DECISIONS, re.I,
502
+ )
503
+ CONTEXT_INTERP_RE = re.compile(
504
+ r"\b(" + _INTERP_TOKENS + r")\b[\s\S]*" + CONTEXT_MD, re.I,
505
+ )
506
+
507
+ # H-22's shell flank: the protected-state registry (B1/#564) — Write/Edit are
508
+ # guarded via classify_protected's "state" class (pre-write.py/pre-edit.py,
509
+ # T-05a/T-06/T-07); this closes the shell flank the SAME way the four
510
+ # pre-existing classes already do above: a redirect into a registered file,
511
+ # or a write/delete verb naming it, blocks.
512
+ #
513
+ # ONE (redirect_re, write_re) pair PER REGISTRY ENTRY, not a single
514
+ # alternation over every registered basename (T-08 design ruling): a regex
515
+ # defect in one entry's pattern then stays isolated to that entry — and is
516
+ # pinned by that entry's own test — instead of one opaque pattern smearing a
517
+ # failure across every consumer. `_state_write_res` is the per-entry
518
+ # TEMPLATE, called once per registry entry by `_build_state_write_res`.
519
+ #
520
+ # BARE basename anchor, deliberately WITHOUT a `.codearbiter/` directory
521
+ # prefix the way CONTEXT_MD carries one: the redirect-operator prefix
522
+ # (`>>?\|?\s*\S*`) and the verb-then-bounded-window shape
523
+ # (`\b(verb-list)\b[^|;&]*`) below are copied verbatim from
524
+ # CONTEXT_REDIRECT_RE/CONTEXT_WRITE_RE (above, ~line 355), but the NAME
525
+ # fragment itself mirrors AUDIT_LOG_NAMES/LOG_TRUNC_RE/LOG_DESTROY_RE's
526
+ # bare-filename anchor instead. This is not a stylistic choice: B-07/B-08's
527
+ # own non-regression cases, and the T-08b lexical residual, both name the
528
+ # protected file with NO `.codearbiter/` prefix at all — `taskwrite add --
529
+ # "fix open-tasks.md schema"` and the false-blocking `taskwrite add -- "…tee
530
+ # open-tasks.md"` both carry the bare filename inside free-text argv. A
531
+ # directory-anchored pattern could neither reproduce the documented residual
532
+ # nor catch a real `tee open-tasks.md` run with a cwd already inside
533
+ # `.codearbiter/` (where no directory prefix appears in the command text
534
+ # either) — it would under-scan exactly the attack this guard exists for.
535
+ # The right-edge lookahead DECISION_LOG_SHELL_RE (#528, ~line 355) already
536
+ # carries. H-22's bare-basename anchor inherited the over-match closing it
537
+ # fixes without it: `rm .codearbiter/open-tasks.md.bak` matched, because the
538
+ # basename is a literal PREFIX-substring of the ".bak" spelling and nothing
539
+ # required the basename text to END where it should. Requires end-of-string,
540
+ # whitespace, a redirect/pipe/separator, or a quote-close immediately after
541
+ # the basename — never a bare `\b` word boundary alone, which cannot do this
542
+ # job here (a hyphen is a non-word character on BOTH its sides, so `\b` sits
543
+ # at a hyphen exactly as readily as at a `/`; it cannot distinguish
544
+ # "…/open-tasks.md" from "my-open-tasks.md").
545
+ #
546
+ # This closes only the RIGHT-side over-match. The mirror-image LEFT-side one
547
+ # (a longer filename that happens to END with the registered basename, e.g.
548
+ # `my-open-tasks.md`, `> my-open-tasks.md`) is a KNOWN, ACCEPTED residual of
549
+ # the bare-basename anchor design itself (finding F4, #564 follow-up) — a
550
+ # left anchor would require knowing the basename is not itself part of a
551
+ # longer name, which the bare-anchor design (see the module comment above)
552
+ # deliberately does not have enough context to tell apart from a legitimate
553
+ # no-directory-prefix spelling. Declared, not merely implicit: see
554
+ # security-controls.md's "Protected-state registry (H-22)" section.
555
+ _STATE_NAME_RIGHT_EDGE = r"""(?=$|[\s>|;&"'])"""
556
+
557
+ # The write-verb list, extended past the CONTEXT_WRITE_RE/DECISIONS_WRITE_RE
558
+ # baseline it was copied from (finding F6, #564 follow-up) with verbs
559
+ # present in this file's own cited precedents but missing here: `sponge`
560
+ # (already in LOG_DESTROY_RE, ~line 317), plus `ln` (a hardlinked/symlinked
561
+ # name overwrites whatever sits there with `ln -f`), `install` (coreutils'
562
+ # copy-with-permissions — a genuine overwrite verb), `patch` (rewrites a
563
+ # file in place from a diff), and `shred` (secure-delete, the ultimate
564
+ # destroy). `install`/`ln` both carry a real false-positive cost of their
565
+ # own (`npm install`/`pip install` are common phrases; `ln` is a short,
566
+ # common token) — accepted under the SAME "ambiguity resolves CLOSED"
567
+ # stance this file states at its own top (module docstring) and applies
568
+ # throughout (e.g. `cp overrides.log backup`, a mere READ, blocks anyway);
569
+ # declared in security-controls.md rather than left an undeclared gap.
570
+ _STATE_WRITE_VERBS = (
571
+ r"rm|del|mv|cp|copy|dd|tee|sed|sponge|ln|install|patch|shred|truncate|ni"
572
+ r"|New-Item|Remove-Item|Move-Item|Copy-Item|Clear-Content|Set-Content"
573
+ r"|Out-File|Add-Content"
574
+ # workstream-B adversary MEDIUM-5: `unlink` is `rm`'s direct sibling and
575
+ # this list already carries `shred`/`truncate`; `ex`/`vim -es` are
576
+ # editors driven as batch WRITERS (`-c wq`), which is the same act as
577
+ # `sed -i` by another name; `rsync` overwrites a destination path the
578
+ # way `cp` does.
579
+ #
580
+ # `ex`/`vim` consequently block an INTERACTIVE open too, which is
581
+ # intended rather than tolerated: `helper-only` exists to make the
582
+ # sanctioned helper the only writer, and opening the board in an editor
583
+ # is the hand-composed-markdown path the policy prevents — the likelier
584
+ # spelling of it than `vim -es -c wq`. Reading stays open (`cat`,
585
+ # `grep`, `view`, `git log`), pinned by
586
+ # `test_reads_of_a_helper_only_file_still_pass`. `\b` keeps the
587
+ # two-letter `ex` from matching inside `export`/`eslint`/`extract`.
588
+ r"|unlink|ex|vim|rsync"
589
+ )
590
+
591
+ # #575: `install` above exists for coreutils' `install` (a genuine
592
+ # copy-with-permissions overwrite verb) — but a PACKAGE MANAGER's `install`
593
+ # SUBCOMMAND (`pip install -r requirements.txt`, `npm install`, `cargo
594
+ # install`, `apt install`, `brew install`, …) is a different verb entirely
595
+ # wearing the same word, and the T-08b same-line window then reaches a
596
+ # protected basename mentioned anywhere later on that line (a trailing
597
+ # comment, a free-text description) — false-blocking a routine dependency
598
+ # install. A PRECEDING-TOKEN check, not a smarter parser: this pattern
599
+ # recognizes the KNOWN package-manager-subcommand spelling specifically, so
600
+ # `_check_h22_state` can blank JUST that phrase out of the text `write_re`
601
+ # scans, leaving the bare coreutils spelling (nothing package-manager-shaped
602
+ # immediately before `install`) to block exactly as before.
603
+ _PKG_MANAGER_INSTALL_RE = re.compile(
604
+ r"\b(?:pip3?|npm|pnpm|yarn|cargo|apt(?:-get)?|brew|conda|gem|composer"
605
+ r"|dnf|yum|choco|winget)\s+install\b", re.I,
606
+ )
607
+
608
+
609
+ def _strip_pkg_manager_install(cmd):
610
+ """#575: blank out every `<package manager> install` phrase in `cmd` so
611
+ H-22's write-verb leg (`write_re`, built by `_state_write_res` below)
612
+ never mistakes the SUBCOMMAND spelling for the coreutils overwrite verb
613
+ it exists to catch. Mirrors `_check_h11_decisions`'s
614
+ `DECISION_LOG_SHELL_RE.sub(" ", cmd)` — a narrow, single-purpose strip
615
+ applied only where the verb leg runs; every other guard (redirect,
616
+ git-restore, interpreter, and every OTHER H-NN check) still sees the
617
+ unmodified `cmd`."""
618
+ return _PKG_MANAGER_INSTALL_RE.sub(" ", cmd)
619
+
620
+
621
+ def _state_write_res(basename, rel_path=None):
622
+ r"""`(redirect_re, write_re, git_restore_re, interp_re)` for ONE
623
+ protected-state registry entry's bare filename — the compiled set
624
+ `_build_state_write_res` returns one of, per entry. See the module
625
+ comment above for why this is bare-basename, not directory-anchored,
626
+ and `_STATE_NAME_RIGHT_EDGE`/`_STATE_WRITE_VERBS` above for the
627
+ right-anchor and extended verb list (finding F4/F6).
628
+
629
+ `rel_path` (#575, optional — every existing caller that passes only
630
+ `basename` keeps working unchanged) supplies the registry entry's full
631
+ path so `git_restore_re` can ALSO cover a directory-level restore; see
632
+ the git_restore_re docstring section below.
633
+
634
+ `git_restore_re` (finding F5, #564 follow-up): mirrors H-05's
635
+ LOG_GIT_RESTORE_RE (#335) — `git checkout`/`git restore` rewrite a
636
+ TRACKED worktree file through git itself, bypassing every filesystem
637
+ verb above entirely (all three planned registry entries are tracked
638
+ files, so this is not a hypothetical). A SEPARATE pattern, not folded
639
+ into the write-verb list: `checkout`/`restore` are git SUBCOMMANDS, not
640
+ shell verbs, and matching them needs the `GIT` global-options-tolerant
641
+ prefix the write-verb list has no business carrying. Deliberately does
642
+ NOT match `git add` — B-07's non-regression (commit-gate Phase 7 runs
643
+ `git add open-tasks.md` on every retained board flip, which must never
644
+ trip H-22) — and structurally cannot: the subcommand alternation here is
645
+ only `checkout|restore`.
646
+
647
+ #575 (the non-package-manager half of the lexical-residual issue):
648
+ `git checkout HEAD -- .codearbiter/` restores the file's ENCLOSING
649
+ DIRECTORY without naming the file itself — rewriting it (and every
650
+ sibling tracked file under that directory) through git while matching
651
+ no basename at all, so the bare-basename alternative above never fires.
652
+ When `rel_path` carries a directory component, `git_restore_re` gains
653
+ one alternative PER ANCESTOR DIRECTORY, each anchored (via the SAME
654
+ `_STATE_NAME_RIGHT_EDGE`, after an optional single trailing slash) to
655
+ match ONLY the directory itself — never a file living inside it, which
656
+ stays the basename alternative's job and would otherwise let this leg
657
+ swallow every sibling path as a substring of the directory name.
658
+
659
+ `interp_re` (finding F6, #564 follow-up): mirrors GATE_MARKER_INTERP_RE
660
+ (#237) — an arbitrary interpreter one-liner
661
+ (`python -c "open('open-tasks.md','w')..."`) reuses `helper-only`'s own
662
+ sanctioned Python file-I/O route while naming the target file lexically,
663
+ a flank no verb-list spelling above can see. `[\s\S]*` (not `[^\n]*`,
664
+ per the #237 follow-up) so the inline-code switch and the filename may
665
+ sit on different physical lines of the SAME multi-line `-c`/`-e`
666
+ payload — `[^\n]*` cannot cross that newline and would silently reopen
667
+ the identical hole in its multi-line spelling.
668
+
669
+ An INLINE-CODE SWITCH is required between the interpreter and the
670
+ filename (`_INTERP_INLINE_CODE`), and this is the load-bearing half of
671
+ the pattern, not a refinement. Without it the leg matched any command
672
+ line carrying an interpreter token and the basename in any order --
673
+ which is the shape of the SANCTIONED CALL: `python3
674
+ "…/hooks/taskwrite.py" add -- "fix open-tasks.md schema"`. The
675
+ workstream-B adversary drove the real `pre-bash.py` and found every
676
+ such invocation BLOCKED, including `done` and `archive` on an ID-less
677
+ task whose own title names the file -- where the target IS the title,
678
+ so there is no rewording available and no sanctioned route left at
679
+ all. That inverted the enrolment's entire premise.
680
+
681
+ The discriminator is EXECUTES-A-STRING versus RUNS-A-FILE. `python3
682
+ script.py <basename>` passes the name as argv DATA to a file this
683
+ lexical guard could never see inside anyway; `python3 -c "…<basename>…"`
684
+ puts the write in the command line itself, where the guard can see it.
685
+ Declared residual, unchanged by this fix and unchanged in kind: a
686
+ script FILE that writes a registered path is not reachable lexically,
687
+ which is what ADR-0024's cooperative, friction-grade grading says."""
688
+ name = re.escape(basename)
689
+ redirect_re = re.compile(
690
+ r">>?\|?\s*\S*" + name + _STATE_NAME_RIGHT_EDGE, re.I)
691
+ # `[^|;&\n]*`, NOT the `[^|;&]*` this was copied from: a shell verb and
692
+ # the file it targets sit on ONE line, so letting the window cross
693
+ # newlines buys no coverage and costs real false blocks. The window ran
694
+ # from a `sed -i` on line 12 of this branch's own commit 063b0b4 to a
695
+ # `done-tasks.md` on line 15 — meaning the commit that ENROLLED the
696
+ # board files carries a message the guard it installs would refuse
697
+ # (workstream-B adversary MEDIUM-3), with no escape: commit-gate
698
+ # permits `-m` or a heredoc, and `_check_h22_state` scans raw `cmd`
699
+ # without heredoc stripping (the declared LOW-5 residual), so both
700
+ # routes hit it. Also caught `pip install -r reqs.txt # then read
701
+ # open-tasks.md`.
702
+ #
703
+ # The same-line residual (T-08b) is UNCHANGED and still pinned: a write
704
+ # verb and the basename on one line stay indistinguishable from a real
705
+ # redirect at this guard's lexical level.
706
+ write_re = re.compile(
707
+ r"\b(" + _STATE_WRITE_VERBS + r")\b[^|;&\n]*" + name + _STATE_NAME_RIGHT_EDGE, re.I,
708
+ )
709
+ # `[^|;&\n]*` for the SAME reason `write_re` above carries it: a git
710
+ # subcommand and its pathspec sit on one line, so crossing newlines buys
711
+ # no coverage and costs false blocks on multi-line commit bodies. This
712
+ # leg was left on the old unbounded window when `write_re` was fixed --
713
+ # a sibling two lines away, with the identical defect, missed because the
714
+ # fix was applied to the reported pattern rather than to the class.
715
+ #
716
+ # #575: one alternative per ANCESTOR DIRECTORY of `rel_path`, alongside
717
+ # the bare basename — see the docstring section above. `dir_alts` is
718
+ # empty (no directory component) when `rel_path` is omitted or bare, so
719
+ # `restore_target` degrades to exactly the old basename-only pattern.
720
+ restore_target = name
721
+ if rel_path:
722
+ parts = [p for p in rel_path.replace("\\", "/").split("/") if p not in ("", ".")]
723
+ dir_alts = ["/".join(parts[:i]) for i in range(1, len(parts))]
724
+ if dir_alts:
725
+ dir_pattern = "|".join(
726
+ re.escape(d).replace("/", r"[\\/]+") for d in dir_alts)
727
+ restore_target = "(?:" + name + r"|(?:" + dir_pattern + r")[\\/]?)"
728
+ git_restore_re = re.compile(
729
+ GIT + r"\s+(?:checkout|restore)\b[^|;&\n]*" + restore_target
730
+ + _STATE_NAME_RIGHT_EDGE, re.I,
731
+ )
732
+ interp_re = re.compile(
733
+ r"\b(" + _INTERP_TOKENS + r")\b[^\n]*?" + _INTERP_INLINE_CODE
734
+ + r"[\s\S]*" + name + _STATE_NAME_RIGHT_EDGE, re.I,
735
+ )
736
+ return redirect_re, write_re, git_restore_re, interp_re
737
+
738
+
739
+ def _build_state_write_res(registry):
740
+ """`(rel_path, policy, redirect_re, write_re, git_restore_re,
741
+ interp_re)` for every entry in `registry`, keyed on each entry's bare
742
+ basename via `_state_write_res`. An explicit `registry` PARAMETER (not a
743
+ bare comprehension over the module-level default) so a test can rebuild
744
+ this exact tuple against a SYNTHETIC registry — the real one now enrolls
745
+ three consumers (T-33/T-65/T-66: release-targets.md, open-tasks.md,
746
+ done-tasks.md) — the same `registry=`-parameter shape
747
+ `_protectedstatelib.lookup_policy` already uses for the identical
748
+ reason."""
749
+ built = []
750
+ for rel_path, policy in registry.items():
751
+ basename = rel_path.replace("\\", "/").rsplit("/", 1)[-1]
752
+ redirect_re, write_re, git_restore_re, interp_re = _state_write_res(basename, rel_path)
753
+ built.append((rel_path, policy, redirect_re, write_re, git_restore_re, interp_re))
754
+ return tuple(built)
755
+
756
+
757
+ # performance-002/_scopelib.py:109-117 precedent: compiled ONCE at import
758
+ # from the live (code-constant, never disk-loaded — #564 design ruling)
759
+ # registry, not recompiled per call. A test exercises the real logic by
760
+ # rebuilding this EXACT tuple against a synthetic registry
761
+ # (`_build_state_write_res`), never by mutating `_protectedstatelib.REGISTRY`
762
+ # after the fact — this tuple would not see that (it is a one-time
763
+ # import-time snapshot, by design).
764
+ _STATE_WRITE_RES = _build_state_write_res(_protectedstatelib.REGISTRY)
765
+
766
+
767
+ def git_cwd(cmd, root):
768
+ """The directory a `git -C <dir>` invocation actually targets, or `root`
769
+ when there is no `-C` (reliability-004, #190).
770
+
771
+ Scans the SAME global-options run GIT/COMMIT_RE/PUSH_RE already tolerate
772
+ before the subcommand (GIT_OPTS_RUN_RE), so a `-C` preceded by other
773
+ global options (`git --no-pager -C ../x commit`, `git -c k=v -C ../x
774
+ commit`) is found exactly where the guards themselves look, instead of
775
+ only matching `-C` as the first token.
776
+
777
+ Git allows REPEATED `-C` and COMPOSES them sequentially, not
778
+ last-wins: each `-C` is resolved relative to the ACCUMULATED result of
779
+ every preceding `-C` (an absolute value REPLACES the accumulator; a
780
+ relative value is joined onto it) — see `git --help`'s `-C <path>`. A
781
+ naive "take the last -C, resolve if relative against root" is wrong for a
782
+ mixed run: `git -C /abs/main -C . commit` must resolve to `/abs/main`
783
+ (the `.` is relative to `/abs/main`, not to root), and `git -C feat -C
784
+ /abs/main commit` must resolve to `/abs/main` (the later absolute value
785
+ resets the accumulator, discarding the earlier relative one entirely) —
786
+ a last-wins-only implementation fails OPEN on exactly these spellings
787
+ (security-reviewer MEDIUM, #190 follow-up).
788
+
789
+ The accumulator is seeded with `root` (project_root), not the hook
790
+ process's own cwd — the hook's cwd is not guaranteed to be the project
791
+ dir (mirrors _hooklib.project_root's own rationale), so the FIRST
792
+ relative `-C` in a run resolves against `root`, exactly as a bare
793
+ relative `-C` (no preceding `-C`) already did."""
794
+ m = GIT_OPTS_RUN_RE.search(cmd)
795
+ if not m:
796
+ return root
797
+ c_matches = GIT_C_IN_RUN_RE.findall(m.group(1))
798
+ if not c_matches:
799
+ return root
800
+ acc = root
801
+ for raw in c_matches:
802
+ val = raw.strip("\"'")
803
+ acc = val if os.path.isabs(val) else os.path.join(acc, val)
804
+ return acc
805
+
806
+
807
+ def _effective_exec_root(payload, root):
808
+ """The git root that a `-C`-less git command in THIS Bash call actually
809
+ runs against — the command's effective cwd — rather than always the
810
+ pinned `root` (CLAUDE_PROJECT_DIR) (#223).
811
+
812
+ #223's bug: `root` is pinned to CLAUDE_PROJECT_DIR (the MAIN checkout),
813
+ so a `git commit` fired from a LINKED WORKTREE was judged against the
814
+ main checkout's branch — both a false positive (worktree on a feature
815
+ branch, main checkout on main -> wrongly blocked) and a false negative
816
+ (worktree on main/master, main checkout on a feature branch -> H-01
817
+ silently sidestepped, the more serious direction).
818
+
819
+ D-2 (spec pre-release-hardening): this function governs BRANCH/DIFF
820
+ resolution (git_cwd's seed) ONLY. Gate MARKERS always keep reading from
821
+ the pinned `root` regardless of this function's answer — a linked
822
+ worktree has `.codearbiter/` (tracked) but not `.codearbiter/.markers/`
823
+ (gitignored), so marker paths must stay anchored at the main checkout.
824
+ This split existed accidentally before #223 (pre-bash.py:769,828 already
825
+ read markers from `root` while everything else read from `cwd`); this is
826
+ now the intentional, documented contract.
827
+
828
+ Resolution reuses `_gitlib.project_root` — the existing linked-worktree-
829
+ aware precedent (`_gitlib.head_branch` already parses a `.git` FILE's
830
+ `gitdir:` pointer for exactly this case) — rather than a fresh
831
+ `git rev-parse --show-toplevel` spawn. It climbs from the hook payload's
832
+ own `cwd` (a trusted-harness input, same footing as CLAUDE_PROJECT_DIR
833
+ itself — see security-controls.md's Repo resolution section) or, absent
834
+ one, the hook process's own cwd, stopping at the nearest ancestor with a
835
+ `.git` (dir OR file) or a `.codearbiter` directory — a linked worktree's
836
+ `.git` is a FILE, and it also carries its own `.codearbiter/`, so either
837
+ check alone stops the climb at the worktree root, never the main
838
+ checkout.
839
+
840
+ When that climb lands on the SAME root as `root`, this returns `root`
841
+ unchanged — the overwhelmingly common (non-worktree) case sees zero
842
+ behavioral difference. It returns the climbed root only when it names a
843
+ genuinely DIFFERENT filesystem location."""
844
+ exec_root = _gitlib.project_root(payload if isinstance(payload, dict) else {})
845
+ if os.path.normpath(os.path.abspath(exec_root)) == os.path.normpath(os.path.abspath(root)):
846
+ return root
847
+ return exec_root
848
+
849
+
850
+ # D-3 (spec pre-release-hardening, #223): the raw-`cmd` fallback below (used
851
+ # when a heredoc is present) must fire ONLY when the heredoc body can
852
+ # genuinely reach a shell — via EITHER of two independent routes, checked by
853
+ # the two functions below (`_heredoc_fed_to_shell` and `_has_shell_executor`,
854
+ # OR'd together at the call site): (1) the heredoc's DIRECT consumer is
855
+ # itself a shell-like program whose stdin is executed as code (`bash <<EOF`),
856
+ # or (2) some OTHER token in the command is a shell/interpreter executor that
857
+ # runs a command-substitution result carrying the heredoc's output
858
+ # (`bash -c "$(cat <<EOF … EOF)"` — the heredoc's direct consumer is `cat`,
859
+ # route (1) says no, but `bash -c` EXECUTES what `cat` produced, so route (2)
860
+ # must say yes). A heredoc handed to a non-shell consumer with NO executor
861
+ # anywhere in the command (`gh pr create --body "$(cat <<EOF … EOF)"`) is
862
+ # inert prose to this guard even though it is substituted into another
863
+ # command's argument — neither route fires, so the fallback correctly stays
864
+ # off and a PR/issue body merely QUOTING "git commit" does not false-trip.
865
+ SHELL_HEREDOC_CONSUMERS = frozenset({
866
+ "bash", "sh", "zsh", "dash", "ksh",
867
+ "python", "python2", "python3", "perl", "ruby", "node", "nodejs",
868
+ })
869
+
870
+
871
+ def _heredoc_fed_to_shell(cmd):
872
+ """True iff at least one `<<` heredoc operator in `cmd` attaches to a
873
+ shell-like consumer (SHELL_HEREDOC_CONSUMERS) — a program whose stdin IS
874
+ executed, not merely read as inert text.
875
+
876
+ The consumer is the FIRST token of the "simple command" the heredoc
877
+ operator sits at the end of: scan backward from the operator to the
878
+ nearest unquoted `|`, `;`, `&`, or `(` (or the start of the string) —
879
+ that is where the current simple command began — then take its first
880
+ token. This correctly finds `bash` in `bash <<EOF` and `cat` (not `gh`)
881
+ in `gh pr create --body "$(cat <<EOF … EOF)"` (the `(` from `$(` bounds
882
+ the segment)."""
883
+ for m in re.finditer(r"<<", cmd):
884
+ start = m.start()
885
+ seg_start = 0
886
+ in_dq = in_sq = False
887
+ for i in range(start - 1, -1, -1):
888
+ ch = cmd[i]
889
+ if ch == '"' and not in_sq:
890
+ in_dq = not in_dq
891
+ elif ch == "'" and not in_dq:
892
+ in_sq = not in_sq
893
+ elif not in_dq and not in_sq and ch in "|;&(":
894
+ seg_start = i + 1
895
+ break
896
+ segment = cmd[seg_start:start]
897
+ tokens = re.findall(r'"[^"]*"|\'[^\']*\'|\S+', segment)
898
+ if not tokens:
899
+ continue
900
+ head = os.path.basename(tokens[0].strip("\"'"))
901
+ if head in SHELL_HEREDOC_CONSUMERS:
902
+ return True
903
+ return False
904
+
905
+
906
+ # security-reviewer finding (post-A-4): `_heredoc_fed_to_shell` alone answers
907
+ # "is the heredoc's DIRECT consumer a shell" — that is NOT the same question
908
+ # as "does the heredoc's body reach a shell." `bash -c "$(cat <<EOF … EOF)"`
909
+ # has `cat` as the heredoc's direct consumer (correctly classified non-shell
910
+ # by the check above), but `bash -c` then EXECUTES the substituted result of
911
+ # that `cat` — the body reaches a shell by a route the direct-consumer check
912
+ # cannot see (same hole for `eval "$(cat <<EOF … EOF)"`, `sh -c "$(…)"`, and
913
+ # any command-substitution chain ending in a shell/interpreter executor).
914
+ # This second check asks the complementary question: does ANY token in the
915
+ # whole command invoke something that executes a string as code, regardless
916
+ # of where the heredoc sits relative to it. FAILS CLOSED on uncertainty —
917
+ # `eval` and `xargs` (which can forward its input straight into an executor)
918
+ # are treated as always-executor, no flag required.
919
+ SHELL_C_PROGRAMS = frozenset({"bash", "sh", "zsh", "dash", "ksh"})
920
+ INTERP_C_PROGRAMS = frozenset({"python", "python2", "python3"})
921
+ INTERP_E_PROGRAMS = frozenset({"perl", "ruby", "node", "nodejs"})
922
+ ALWAYS_EXECUTOR_PROGRAMS = frozenset({"eval", "xargs"})
923
+
924
+
925
+ def _has_shell_executor(cmd):
926
+ """True iff `cmd` invokes ANYWHERE a program that executes a string as
927
+ code: `bash -c`/`sh -c`/`zsh -c`/`dash -c`/`ksh -c`, `python[23]? -c`,
928
+ `perl -e`/`ruby -e`/`node[js]? -e`, or a bare `eval`/`xargs` (both can
929
+ hand an arbitrary string straight to an executor — `xargs bash -c '...'`,
930
+ `eval "$var"` — so both are treated as executors unconditionally, no
931
+ flag needed, per the fail-closed mandate).
932
+
933
+ Scans the RAW command (heredoc bodies included) — a heredoc body that
934
+ happens to mention one of these tokens as prose causes a conservative
935
+ over-block, never an under-scan, matching this file's established
936
+ ambiguity-resolves-CLOSED stance (D-3)."""
937
+ tokens = [t.strip("\"'") for t in re.findall(r'"[^"]*"|\'[^\']*\'|\S+', cmd)]
938
+ basenames = [os.path.basename(t) for t in tokens]
939
+ if any(b in ALWAYS_EXECUTOR_PROGRAMS for b in basenames):
940
+ return True
941
+ if "-c" in tokens and any(
942
+ b in SHELL_C_PROGRAMS or b in INTERP_C_PROGRAMS for b in basenames):
943
+ return True
944
+ if "-e" in tokens and any(b in INTERP_E_PROGRAMS for b in basenames):
945
+ return True
946
+ return False
947
+
948
+
949
+ def current_branch(cwd):
950
+ """The current branch name, "" for a legitimate detached HEAD, or None when
951
+ git could not answer (nonzero exit / spawn failure / timeout). reliability-001
952
+ (#189): the None sentinel lets H-01 fail CLOSED on a git-read error instead of
953
+ silently treating "unknown" the same as "detached, not on a protected tip" —
954
+ the prior `except: return ""` collapsed those two states and let a commit
955
+ through when branch state genuinely could not be determined."""
956
+ argv = [git_executable(), "branch", "--show-current"]
957
+ try:
958
+ out = subprocess.run(
959
+ argv, cwd=cwd,
960
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
961
+ timeout=5,
962
+ )
963
+ if out.returncode != 0:
964
+ _note_read_err(argv, out.stderr or f"exit {out.returncode}")
965
+ return None
966
+ return out.stdout.strip()
967
+ except Exception as e: # noqa: BLE001
968
+ _note_read_err(argv, repr(e))
969
+ return None
970
+
971
+
972
+ def is_protected_branch(branch):
973
+ """Case-insensitive: `Main`/`MASTER` are the default branch on a case-folding
974
+ ref store and must be treated as protected, just like `main`/`master`."""
975
+ return branch.lower() in ("main", "master")
976
+
977
+
978
+ def head_on_protected_tip(cwd):
979
+ """True when HEAD (typically detached) points at the commit a protected
980
+ branch tips — a commit there still writes onto main/master's history even
981
+ though `git branch --show-current` reports no branch name.
982
+
983
+ One spawn (performance-006): `git show-ref --head refs/heads/main
984
+ refs/heads/master` lists `<sha> HEAD` plus a `<sha> refs/heads/<branch>` line
985
+ for each protected branch that EXISTS — a missing branch is simply omitted
986
+ (exit 0), with no fatal. (`git rev-parse HEAD main master` cannot be used: it
987
+ stops at the first unresolvable arg, so a missing `main` would hide a present
988
+ `master` tip and silently allow a commit onto it.) HEAD sits on a protected
989
+ tip iff its sha matches a listed main/master tip. A non-repo / unborn HEAD
990
+ lists no HEAD sha -> False.
991
+
992
+ reliability-001 (#189): returns None (not False) when git could not answer
993
+ (spawn failure/timeout, or an exit code outside the two legitimate outcomes)
994
+ so H-01 fails CLOSED on a git-read error instead of concluding "not on a
995
+ protected tip" from a failed read."""
996
+ argv = [git_executable(), "show-ref", "--head", "refs/heads/main", "refs/heads/master"]
997
+ try:
998
+ out = subprocess.run(
999
+ argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8",
1000
+ errors="replace", timeout=5,
1001
+ )
1002
+ except Exception as e: # noqa: BLE001
1003
+ _note_read_err(argv, repr(e))
1004
+ return None
1005
+ if out.returncode not in (0, 1):
1006
+ _note_read_err(argv, out.stderr or f"exit {out.returncode}")
1007
+ return None
1008
+ head_sha, protected = None, set()
1009
+ for ln in out.stdout.splitlines():
1010
+ parts = ln.split()
1011
+ if len(parts) != 2:
1012
+ continue
1013
+ sha, ref = parts
1014
+ if ref == "HEAD":
1015
+ head_sha = sha
1016
+ elif ref in ("refs/heads/main", "refs/heads/master"):
1017
+ protected.add(sha)
1018
+ return head_sha is not None and head_sha in protected
1019
+
1020
+
1021
+ def added_lines(cwd, ref, paths=None):
1022
+ """The added (`+`) lines of a diff — what a commit would introduce — or None
1023
+ when git could not produce the diff (nonzero exit / timeout / error). The
1024
+ None return (not "") lets the H-09b/H-10b security scan fail CLOSED on a read
1025
+ error rather than silently passing — an empty diff and an unreadable diff are
1026
+ NOT the same thing. `paths`, when given, scopes the diff to the pathspec(s) a
1027
+ `git commit <path>` names (whose worktree content the --cached scan misses).
1028
+ Decoded as UTF-8 with replacement: `text=True` alone uses the locale code
1029
+ page (cp1252 on stock Windows), where a non-cp1252 byte in the diff raised
1030
+ UnicodeDecodeError into the bare except below and the security gate
1031
+ silently failed OPEN on exactly the platform this layer protects.
1032
+
1033
+ Excludes gate-events.log (#279): `sensitive_scan_added_lines` walks the
1034
+ diff path-aware, dropping lines that belong to the crypto/secret gate's
1035
+ own machine-written audit sink — see its docstring in `_hooklib`. Reads
1036
+ the diff via SECURITY_DIFF_GIT_ARGS (pinned a/ b/ prefixes, no external
1037
+ diff) so a hostile `diff.mnemonicPrefix`/`diff.noprefix`/external-diff
1038
+ config can never break that attribution (#279 review MEDIUM-1)."""
1039
+ argv = [git_executable(), *SECURITY_DIFF_GIT_ARGS, ref] + (
1040
+ ["--", *paths] if paths else [])
1041
+ try:
1042
+ out = subprocess.run(
1043
+ argv, cwd=cwd,
1044
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
1045
+ timeout=10,
1046
+ )
1047
+ if out.returncode != 0:
1048
+ _note_read_err(argv, out.stderr or f"exit {out.returncode}")
1049
+ return None
1050
+ except Exception as e: # noqa: BLE001
1051
+ _note_read_err(argv, repr(e))
1052
+ return None
1053
+ return "\n".join(sensitive_scan_added_lines(out.stdout))
1054
+
1055
+
1056
+ def _names(cwd, args):
1057
+ """A set of repo-relative paths from a `git ... --name-only` style query, or
1058
+ None when git could not answer (nonzero / timeout / error). None (not an
1059
+ empty set) lets the H-14 migration scan fail CLOSED on a read error rather
1060
+ than concluding "no migrations staged" from a failed read."""
1061
+ try:
1062
+ out = subprocess.run(
1063
+ [git_executable()] + args, cwd=cwd, capture_output=True, text=True,
1064
+ encoding="utf-8", errors="replace", timeout=10,
1065
+ )
1066
+ if out.returncode != 0:
1067
+ _note_read_err(["git"] + args, out.stderr or f"exit {out.returncode}")
1068
+ return None
1069
+ except Exception as e: # noqa: BLE001
1070
+ _note_read_err(["git"] + args, repr(e))
1071
+ return None
1072
+ return {p for p in out.stdout.splitlines() if p.strip()}
1073
+
1074
+
1075
+ def staged_paths(cwd):
1076
+ """Paths in the index — what a plain `git commit` would record. None on a
1077
+ git-read failure (caller fails closed)."""
1078
+ return _names(cwd, ["diff", "--cached", "--name-only"])
1079
+
1080
+
1081
+ def worktree_paths(cwd):
1082
+ """Tracked worktree modifications (what `git commit -a` sweeps in) plus
1083
+ untracked files. None if either underlying query failed (caller fails
1084
+ closed)."""
1085
+ tracked = _names(cwd, ["diff", "--name-only"])
1086
+ untracked = _names(cwd, ["ls-files", "--others", "--exclude-standard"])
1087
+ if tracked is None or untracked is None:
1088
+ return None
1089
+ return tracked | untracked
1090
+
1091
+
1092
+ def read_worktree(cwd, rel):
1093
+ """Worktree content of `rel`, or None if absent/oversize. The H-14 producer
1094
+ digests worktree content too, so the backstop's view matches the marker."""
1095
+ p = rel if os.path.isabs(rel) else os.path.join(cwd, rel)
1096
+ try:
1097
+ if os.path.getsize(p) > 1_000_000:
1098
+ return None
1099
+ with open(p, encoding="utf-8", errors="replace") as f:
1100
+ return f.read()
1101
+ except Exception: # noqa: BLE001
1102
+ return None
1103
+
1104
+
1105
+ def add_violation(args, cwd):
1106
+ """The reason a `git add` argument set is not explicit-file staging, or
1107
+ None. Each path token is checked best-effort against the repo the command
1108
+ targets: a glob, a pathspec-magic prefix, or a directory blocks (staging
1109
+ must name files); a token that resolves to nothing is allowed — git will
1110
+ reject it anyway."""
1111
+ for raw in re.findall(r'"[^"]+"|\'[^\']+\'|\S+', args):
1112
+ tok = raw.strip("\"'")
1113
+ if not tok or tok == "--" or tok.startswith("-"):
1114
+ continue # flag spellings are WILDCARD_ADD_RE's job
1115
+ if tok.startswith(":"):
1116
+ return f"pathspec magic ('{tok}')"
1117
+ if GLOB_RE.search(tok):
1118
+ return f"a glob pattern ('{tok}')"
1119
+ p = tok if os.path.isabs(tok) else os.path.join(cwd, tok)
1120
+ if os.path.isdir(p):
1121
+ return f"a directory ('{tok}')"
1122
+ return None
1123
+
1124
+
1125
+ # Flags whose NEXT token is a value, not a pathspec — so `git commit -m "msg" f`
1126
+ # names `f`, not `msg`. (`--flag=value` is self-contained; a short bundle ending
1127
+ # in one of these chars takes the next token, e.g. `-am msg`.)
1128
+ COMMIT_VALUE_FLAGS = frozenset({
1129
+ "-m", "--message", "-F", "--file", "-C", "--reuse-message",
1130
+ "-c", "--reedit-message", "--author", "--date", "-t", "--template",
1131
+ "--fixup", "--squash", "--trailer",
1132
+ })
1133
+
1134
+
1135
+ # A redirect operator token (`>`, `>>`, `2>`, `<`, `<<`, `<<'EOF'`, …). Never a
1136
+ # git pathspec — feeding one to `git diff -- <op>` can ERROR (not "diff to
1137
+ # nothing"), which is what failed the H-09b/H-14 scan CLOSED on heredoc commits.
1138
+ REDIRECT_RE = re.compile(r"\d*[<>]")
1139
+
1140
+
1141
+ # A heredoc: `<<` (optional `-`), optional quote, a word delimiter, optional
1142
+ # matching quote, the rest of that line, then body lines, up to a line that is
1143
+ # the delimiter alone. The body is stdin content (the commit message via `-F -`),
1144
+ # never git arguments — parsing it as pathspecs failed H-09b/H-10b/H-14 CLOSED on
1145
+ # the recommended `git commit -F - <<EOF` form.
1146
+ # Group 3 captures the REST OF THE OPERATOR LINE after `<<WORD` — `git commit -F
1147
+ # - <<EOF realfile.py` passes `realfile.py` to git as a pathspec (only the body
1148
+ # is stdin), so that tail is re-emitted, not swallowed with the body, or the
1149
+ # worktree-union scan would under-scan it (errs open).
1150
+ HEREDOC_RE = re.compile(
1151
+ r"<<-?[ \t]*([\"']?)(\w+)\1([^\n]*)\n(?:.*?\n)?[ \t]*\2[ \t]*(?:\n|$)",
1152
+ re.DOTALL)
1153
+
1154
+
1155
+ def _strip_heredoc_bodies(args):
1156
+ """Remove heredoc operator+body+delimiter from a git-commit arg string so the
1157
+ body is never parsed as flags/pathspecs (it false-blocked H-09b/H-10b/H-14 on
1158
+ `git commit -F - <<EOF`). `\\`-newline continuations are joined first; the
1159
+ operator-line tail (group 3) is preserved.
1160
+
1161
+ CRITICALLY nothing but the heredoc body is cut. An earlier attempt bounded to
1162
+ the first newline (`split("\\n", 1)[0]`), which also truncated at a literal
1163
+ newline inside a quoted `-m "subject\\n\\nbody"` message and dropped a trailing
1164
+ pathspec — silently under-scanning a pathspec-scoped commit and reopening the
1165
+ v2.rev.0015 worktree-union bypass. Stripping only the heredoc (and keeping the
1166
+ operator-line tail) leaves a multi-line message and any real pathspec intact."""
1167
+ joined = re.sub(r"\\\r?\n", " ", args) # honor line-continuations
1168
+ return HEREDOC_RE.sub(r"\3 ", joined)
1169
+
1170
+
1171
+ def commit_pathspecs(args):
1172
+ """The worktree paths a `git commit` names as pathspecs. A `git commit <path>`
1173
+ records the WORKTREE content of <path>, bypassing the index — content the
1174
+ index-only `--cached` scan never sees — so the security/migration gates must
1175
+ union the worktree diff for these paths. Best-effort parse: everything after
1176
+ a `--` separator is a pathspec; otherwise bare tokens that are neither a flag,
1177
+ a value-taking flag's value, nor a redirect operator. Bias is to OVER-include
1178
+ — a non-path token diffs to nothing (harmless), whereas under-including would
1179
+ reopen the bypass, so any ambiguity is treated as a pathspec. Callers pass a
1180
+ `_strip_heredoc_bodies`-cleaned string so a heredoc body never reaches here."""
1181
+ toks = [t.strip("\"'") for t in re.findall(r'"[^"]+"|\'[^\']+\'|\S+', args)]
1182
+ if "--" in toks:
1183
+ return [t for t in toks[toks.index("--") + 1:] if t]
1184
+ out, expect_value = [], False
1185
+ for t in toks:
1186
+ if expect_value:
1187
+ expect_value = False
1188
+ continue
1189
+ if REDIRECT_RE.match(t): # a redirect operator, not a pathspec
1190
+ continue
1191
+ if t.startswith("-"):
1192
+ if "=" in t: # --message=... carries its own value
1193
+ continue
1194
+ if t in COMMIT_VALUE_FLAGS or (
1195
+ re.fullmatch(r"-[A-Za-z]+", t) and t[-1] in "mFCct"):
1196
+ expect_value = True # next token is this flag's value, not a path
1197
+ continue
1198
+ if t:
1199
+ out.append(t)
1200
+ return out
1201
+
1202
+
1203
+ def _require_branch(cwd):
1204
+ """current_branch(cwd), or fail-closed BLOCK on H-01 when git could not
1205
+ answer. reliability-001 (#189): ambiguity resolves CLOSED here exactly as
1206
+ H-09b/H-14 already do on a git-read failure — an unreadable branch state
1207
+ must not silently evaluate as "not protected"."""
1208
+ branch = current_branch(cwd)
1209
+ if branch is None:
1210
+ block("H-01", "branch state could not be determined (git unavailable or timed "
1211
+ "out) — failing closed (ORCHESTRATOR §2). Retry, or verify you are "
1212
+ "not on main/master before committing/pushing." + _read_err_hint())
1213
+ return branch
1214
+
1215
+
1216
+ def _require_tip(cwd):
1217
+ """head_on_protected_tip(cwd), or fail-closed BLOCK on H-01 when git could
1218
+ not answer (reliability-001, #189)."""
1219
+ tip = head_on_protected_tip(cwd)
1220
+ if tip is None:
1221
+ block("H-01", "HEAD's protected-branch-tip state could not be determined (git "
1222
+ "unavailable or timed out) — failing closed (ORCHESTRATOR §2). "
1223
+ "Retry, or verify HEAD before committing." + _read_err_hint())
1224
+ return tip
1225
+
1226
+
1227
+ def _check_h20_commit_no_verify(commit):
1228
+ """H-20: block a literal --no-verify / -n on `git commit` — it skips
1229
+ .git/hooks (the git-enforce backstop) entirely, voiding every enforcement
1230
+ hook for that commit (appsec-002, #175). Checks BOTH the exact-token
1231
+ spelling (`-n` / `--no-verify` on its own) and the bundled short-flag
1232
+ cluster spelling (`-nm "x"`) — the everyday way `-n` actually gets typed
1233
+ alongside `-m`, missed by an exact-token-only check (security-reviewer
1234
+ HIGH, first pass)."""
1235
+ if commit and (_has_literal_flag(commit.group("args"), COMMIT_NO_VERIFY_FLAGS)
1236
+ or _commit_no_verify_in_cluster(commit.group("args"))):
1237
+ block("H-20", "'--no-verify' / '-n' on git commit skips the .git/hooks "
1238
+ "git-enforce backstop entirely (appsec-002) — every commit-time "
1239
+ "gate (H-01/H-02/H-09b/H-10b/H-14) would go unenforced for this "
1240
+ "commit. Remove the flag; use /override for a sanctioned bypass.")
1241
+
1242
+
1243
+ def _check_h01_commit_protected_branch(commit, cwd):
1244
+ """H-01: no commit directly to main/master — case-insensitive, and a detached
1245
+ HEAD sitting on a protected branch's tip counts (the commit lands on its
1246
+ history regardless of the absent branch name)."""
1247
+ if commit:
1248
+ branch = _require_branch(cwd)
1249
+ if is_protected_branch(branch) or (not branch and _require_tip(cwd)):
1250
+ target = branch or "main/master (detached HEAD)"
1251
+ block("H-01", f"Direct commit to {target} is prohibited (ORCHESTRATOR §3). "
1252
+ f"Create a feature branch.")
1253
+
1254
+
1255
+ def _check_push_gates(push, cwd):
1256
+ """H-20/H-02/H-01: the push-time gate cluster — no-verify, force-push, bulk
1257
+ (--all/--mirror), protected-destination refspec, and bare push from a
1258
+ protected branch."""
1259
+ if not push:
1260
+ return
1261
+ pargs = push.group("args")
1262
+
1263
+ # H-20: block a literal --no-verify on `git push` (same rationale as
1264
+ # the commit flank above). `git push` has no short `-n` spelling for
1265
+ # this — its own `-n` is `--dry-run`, an unrelated flag — so only the
1266
+ # long form is checked here.
1267
+ if _has_literal_flag(pargs, PUSH_NO_VERIFY_FLAGS):
1268
+ block("H-20", "'--no-verify' on git push skips the .git/hooks git-enforce "
1269
+ "backstop entirely (appsec-002) — every push-time gate would "
1270
+ "go unenforced for this push. Remove the flag; use /override "
1271
+ "for a sanctioned bypass.")
1272
+
1273
+ # H-02: no force-push — any spelling, including --force-with-lease and +refspec
1274
+ if FORCE_RE.search(pargs) or FORCE_REFSPEC_RE.search(pargs):
1275
+ block("H-02", "Force-push is prohibited (ORCHESTRATOR §3).")
1276
+
1277
+ # H-01: `--all` / `--mirror` publish protected refs (main included) with
1278
+ # no inspectable destination token — block regardless of current branch.
1279
+ if PUSH_ALL_RE.search(pargs):
1280
+ block("H-01", "'git push --all' / '--mirror' publish every local ref "
1281
+ "(including main) (ORCHESTRATOR §3) — main moves only via a "
1282
+ "merged PR. Push an explicit feature refspec.")
1283
+
1284
+ # H-01: no push whose destination is a protected branch. H-01's branch
1285
+ # check alone left `git push origin HEAD:main` (and `feature:main`,
1286
+ # `:main`) as a refspec-shaped hole — a direct write to main from any
1287
+ # branch with no commit involved.
1288
+ toks = [t.strip("\"'").lstrip("+") for t in pargs.split()
1289
+ if t and not t.startswith("-")]
1290
+ for tok in toks:
1291
+ if PROTECTED_DEST_RE.fullmatch(tok):
1292
+ block("H-01", f"Pushing to a protected branch ('{tok}') is prohibited "
1293
+ f"(ORCHESTRATOR §3) — main moves only via a merged PR.")
1294
+ # Bare `git push` (no refspec) publishes the current branch. A git-read
1295
+ # failure here fails CLOSED (reliability-001, #189) — a bare push whose
1296
+ # branch state is unknown must not be waved through as "not protected".
1297
+ if len(toks) < 2 and is_protected_branch(_require_branch(cwd)):
1298
+ block("H-01", "Bare `git push` from main/master publishes the protected "
1299
+ "branch (ORCHESTRATOR §3) — main moves only via a merged PR.")
1300
+
1301
+
1302
+ def _check_h03_wildcard_add(add, cwd):
1303
+ """H-03: no wildcard git staging — stage explicitly (commit-gate). Both the
1304
+ flag spellings (-A/--all/-u/.) and the argument spellings (globs,
1305
+ directories, pathspec magic) — `git add src/` stages everything beneath
1306
+ src/ just as surely as `git add -A` does."""
1307
+ if not add:
1308
+ return
1309
+ if WILDCARD_ADD_RE.search(add.group("args")):
1310
+ block("H-03", "'git add -A' / 'git add .' / 'git add --all' / 'git add -u' "
1311
+ "are prohibited. Stage files explicitly (commit-gate skill).")
1312
+ why = add_violation(add.group("args"), cwd)
1313
+ if why:
1314
+ block("H-03", f"Wildcard staging is prohibited — {why} stages a "
1315
+ f"non-explicit file set. Stage files explicitly, one path "
1316
+ f"per file (commit-gate skill).")
1317
+
1318
+
1319
+ def _check_h05_audit_log(cmd):
1320
+ """H-05: the audit trail is append-only — block truncation/removal of the
1321
+ audit logs via shell verbs (Write/Edit are guarded separately). The
1322
+ substring pre-filter is a cheap short-circuit before the two regexes
1323
+ below (avoids running them against every command); it is DERIVED from
1324
+ _hooklib.AUDIT_LOG_BASENAMES (the single authoritative name list)
1325
+ instead of a hand-copied literal list, so a future audit log added there
1326
+ can never silently skip this shell flank (the exact drift
1327
+ security-controls.md § Audit trail centralization exists to prevent)."""
1328
+ # #574: LOG_INTERP_RE closes the interpreter-one-liner flank
1329
+ # (`python3 -c "open('.codearbiter/overrides.log','w')..."`) — the
1330
+ # verb-list and redirect legs above never look for an interpreter token
1331
+ # at all, so this shape walked past both.
1332
+ if any(n in cmd for n in AUDIT_LOG_BASENAMES) and (
1333
+ LOG_TRUNC_RE.search(cmd) or LOG_DESTROY_RE.search(cmd)
1334
+ or LOG_GIT_RESTORE_RE.search(cmd) or LOG_INTERP_RE.search(cmd)):
1335
+ block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, "
1336
+ "gate-events.log, decisions/decision-log.md) are append-only "
1337
+ "(ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit "
1338
+ "trail is prohibited; append with '>>' only.")
1339
+
1340
+
1341
+ def _check_h11_decisions(cmd):
1342
+ """H-11: ADRs exist only via /adr — the Write/Edit tools are guarded by
1343
+ pre-write/pre-edit, and this closes the shell flank (`echo > decisions/…`,
1344
+ `touch`, `cp`, `rm`, `sed -i`, …). Reads are untouched.
1345
+
1346
+ #528: decisions/decision-log.md is the append-only arbitration log, not an
1347
+ ADR, and belongs to H-05 — which already covers it, since LOG_NAMES is
1348
+ composed from the same _hooklib alternation. Blank its path out of the
1349
+ string H-11 scans so an append to it is not read as an ADR write. Only that
1350
+ exact path is removed, so a directory-level operation
1351
+ (`rm -rf .codearbiter/decisions`) still carries a decisions/ reference and
1352
+ still blocks."""
1353
+ # #574: DECISIONS_INTERP_RE closes the interpreter-one-liner flank the
1354
+ # same way LOG_INTERP_RE does for H-05 — scanned against the SAME
1355
+ # decision-log-stripped `cmd` as the two legs above, so an interpreter
1356
+ # append to decisions/decision-log.md stays the #528 carve-out's to
1357
+ # police (via H-05's own LOG_INTERP_RE), not a false H-11 block.
1358
+ cmd = DECISION_LOG_SHELL_RE.sub(" ", cmd)
1359
+ if (DECISIONS_REDIRECT_RE.search(cmd) or DECISIONS_WRITE_RE.search(cmd)
1360
+ or DECISIONS_INTERP_RE.search(cmd)):
1361
+ block("H-11", "ADR files under .codearbiter/decisions/ are authored only via "
1362
+ "/adr and are immutable history (ORCHESTRATOR §6) — shell writes, "
1363
+ "edits, and deletions there are prohibited.")
1364
+
1365
+
1366
+ def _check_h18_context_md(cmd):
1367
+ """H-18: CONTEXT.md is the activation switch (#159) — shell flank. A shell
1368
+ rewrite/delete of it would make every gate dormant; init writes it via the
1369
+ Write tool, so nothing legitimate is blocked. Reads pass.
1370
+
1371
+ #574: CONTEXT_INTERP_RE closes the interpreter-one-liner flank the same
1372
+ way LOG_INTERP_RE/DECISIONS_INTERP_RE do for H-05/H-11."""
1373
+ if (CONTEXT_REDIRECT_RE.search(cmd) or CONTEXT_WRITE_RE.search(cmd)
1374
+ or CONTEXT_INTERP_RE.search(cmd)):
1375
+ block("H-18", ".codearbiter/CONTEXT.md is the activation switch every enforcement "
1376
+ "hook reads (#159) — shell rewrites, edits, or deletions that could flip "
1377
+ "`arbiter: enabled` off or corrupt its frontmatter are prohibited. Edit it "
1378
+ "through the sanctioned init path.")
1379
+
1380
+
1381
+ def _check_h19_gate_marker(git_view, cmd, heredoc_shell_fallback):
1382
+ """H-19: the gate-pass markers (#160) are recorded only by the sanctioned
1383
+ python producers — shell flank against `echo <digest> > security-gate-passed`
1384
+ and `cp`/`sed`/`tee` forges naming a gate marker, plus an interpreter
1385
+ one-liner (#237).
1386
+
1387
+ review finding (post-B-2): scan `git_view` (heredoc bodies stripped),
1388
+ with the raw-`cmd` leg gated on `heredoc_shell_fallback` — mirroring
1389
+ how commit/push/add already work post-D-3 (#223). Scanning raw `cmd`
1390
+ unconditionally, as this used to, means a heredoc body fed to a
1391
+ NON-shell consumer that merely QUOTES a gate-marker path (a PR/issue
1392
+ body describing this very fix) false-trips H-19; scanning only
1393
+ `git_view` would miss the genuine case where the heredoc's body DOES
1394
+ reach an executor (`bash -c "$(cat <<EOF … EOF)"`)."""
1395
+ def _gate_marker_hit(view):
1396
+ return (GATE_MARKER_REDIRECT_RE.search(view)
1397
+ or GATE_MARKER_WRITE_RE.search(view)
1398
+ or GATE_MARKER_INTERP_RE.search(view))
1399
+
1400
+ if _gate_marker_hit(git_view) or (heredoc_shell_fallback and _gate_marker_hit(cmd)):
1401
+ block("H-19", "The .codearbiter/.markers/ security-gate-passed / migration-gate-passed "
1402
+ "tokens are recorded only by the sanctioned gate producers (#160) — a shell "
1403
+ "redirect, write verb, or interpreter invocation (python/node/perl/ruby/sh) "
1404
+ "naming a gate marker forges a security/migration gate pass and is "
1405
+ "prohibited.")
1406
+
1407
+
1408
+ def _check_h22_state(cmd, root):
1409
+ """H-22: the protected-state registry's shell flank (B-04/B1, #564).
1410
+ Walks the precompiled per-entry pairs (`_STATE_WRITE_RES`); a command
1411
+ that redirects into, or runs a write/delete verb against, a registered
1412
+ basename either admits (marker-gated, under a FRESH authoring marker —
1413
+ `_protectedstatelib.marker_gated_write_admitted`, the H-11 pattern) or
1414
+ blocks outright (helper-only/append-only — flank-IDENTICAL: the
1415
+ distinction between them lives entirely in what the sanctioned helper's
1416
+ OWN append verb is allowed to do, never in this shell guard, which has
1417
+ no marker path for either).
1418
+
1419
+ Marker checks read from the pinned `root` (project_root), never `cwd` —
1420
+ the SAME split `_check_h09b_h10b_crypto_secret`/`_check_h14_migration`
1421
+ already draw (D-2, `_effective_exec_root`'s own docstring): a linked
1422
+ worktree has `.codearbiter/` (tracked) but not `.codearbiter/.markers/`
1423
+ (gitignored), so marker paths must stay anchored at the main checkout.
1424
+
1425
+ Git verbs are deliberately ABSENT from the write-verb list (the same
1426
+ list `CONTEXT_WRITE_RE`/`DECISIONS_WRITE_RE` already use) — `git add
1427
+ open-tasks.md` (commit-gate Phase 7, run on every retained board flip)
1428
+ must never reach a block here (B-07), or commit-gate would block itself
1429
+ on its own sanctioned board-flip staging. `git checkout`/`git restore`
1430
+ ARE covered, but via the separate `git_restore_re` leg (finding F5,
1431
+ #564 follow-up) — never the general write-verb list — precisely so that
1432
+ isolation holds: `git_restore_re`'s subcommand alternation is only
1433
+ `checkout|restore`, so it structurally cannot also catch `git add`.
1434
+
1435
+ #575: `write_re` is matched against `_strip_pkg_manager_install(cmd)`,
1436
+ not raw `cmd` -- every OTHER leg (redirect/git-restore/interp) still
1437
+ scans the unmodified command text; only the write-verb leg needs the
1438
+ strip, since `install` is the one verb in `_STATE_WRITE_VERBS` that is
1439
+ also a common package-manager subcommand."""
1440
+ verb_scan_cmd = _strip_pkg_manager_install(cmd)
1441
+ for rel_path, policy, redirect_re, write_re, git_restore_re, interp_re in _STATE_WRITE_RES:
1442
+ if not (redirect_re.search(cmd) or write_re.search(verb_scan_cmd)
1443
+ or git_restore_re.search(cmd) or interp_re.search(cmd)):
1444
+ continue
1445
+ if policy == ProtectedPolicy.MARKER_GATED and marker_gated_write_admitted(rel_path, root):
1446
+ continue
1447
+ if policy == ProtectedPolicy.MARKER_GATED:
1448
+ block("H-22", f"'{rel_path}' is marker-gated protected project state (#564) — a "
1449
+ f"shell redirect or write/delete verb naming it is admitted only "
1450
+ f"under a fresh authoring marker. Mint the marker via the sanctioned "
1451
+ f"authoring lane, or /override.")
1452
+ else:
1453
+ block("H-22", f"'{rel_path}' is protected project state (#564, "
1454
+ f"policy={policy.value}) — shell redirects and write/delete verbs "
1455
+ f"naming it are prohibited outright; there is no marker path for "
1456
+ f"this policy. Use the sanctioned helper.")
1457
+
1458
+
1459
+ def _marker_root(root):
1460
+ """`root`, escalated to the MAIN checkout when `root` itself names a
1461
+ LINKED git worktree's own checkout (#604) — see
1462
+ `hostapi.git_worktree_main_root`'s docstring.
1463
+
1464
+ `root` (this file's own `project_root()`-derived parameter, D-2) already
1465
+ names the main checkout in the common case: the harness sets
1466
+ `CLAUDE_PROJECT_DIR` once at session start, before a session's cwd ever
1467
+ moves into a linked worktree, so this is a no-op for the reported bug's
1468
+ own scenario. It matters only when THIS hook process ALSO ran without
1469
+ `CLAUDE_PROJECT_DIR` set (uncommon for a registered hook subprocess, but
1470
+ possible) — without this, that edge case would have the guard read from
1471
+ the worktree while `security-pass.py`'s `marker_root()` (hostapi.py)
1472
+ writes to the main checkout, reopening the exact split this closes."""
1473
+ return git_worktree_main_root(root) or root
1474
+
1475
+
1476
+ def _check_h09b_h10b_crypto_secret(commit, add, cwd, root):
1477
+ """H-09b / H-10b: BLOCK a commit that introduces crypto/secret changes without
1478
+ a recorded security-gate pass. The crypto-compliance / secret-handling skills
1479
+ record the pass via hooks/security-pass.py — a marker holding the digest of
1480
+ every sensitive line the gate approved. Two checks, both required:
1481
+ freshness (< 30 min) AND coverage (every sensitive line being committed is
1482
+ in the approved set). Coverage is what closes the TOCTOU window: a pass
1483
+ minted for one diff can no longer launder a different diff committed inside
1484
+ the freshness window. Scans the staged diff, plus the worktree diff when
1485
+ the commit uses -a/--all or the same command stages files."""
1486
+ if not commit:
1487
+ return
1488
+ cargs = _strip_heredoc_bodies(commit.group("args"))
1489
+ # Scan the staged diff, plus the worktree diff when the commit pulls in
1490
+ # worktree content: -a/--all (whole tree), an in-command `git add`, OR a
1491
+ # `git commit <pathspec>` (the named paths only — a pathspec commit
1492
+ # records worktree content the --cached scan never sees). A None from
1493
+ # added_lines means git could not read the diff -> fail CLOSED.
1494
+ parts = [added_lines(cwd, "--cached")]
1495
+ if COMMIT_ALL_RE.search(cargs) or add:
1496
+ parts.append(added_lines(cwd, "HEAD"))
1497
+ else:
1498
+ pathspecs = commit_pathspecs(cargs)
1499
+ if pathspecs:
1500
+ parts.append(added_lines(cwd, "HEAD", pathspecs))
1501
+ if any(p is None for p in parts):
1502
+ block("H-09b", "the diff for the crypto/secret security scan could not be "
1503
+ "read (git unavailable or timed out) — failing closed "
1504
+ "(ORCHESTRATOR §2). Retry, or run the crypto-compliance / "
1505
+ "secret-handling gate, then commit." + _read_err_hint())
1506
+ added = "\n".join(parts)
1507
+ sensitive = [ln for ln in added.splitlines()
1508
+ if CRYPTO_RE.search(ln) or SECRET_RE.search(ln)]
1509
+ if sensitive:
1510
+ touches_crypto = bool(CRYPTO_RE.search(added))
1511
+ kind = "crypto/TLS" if touches_crypto else "secret"
1512
+ tag = "H-09b" if touches_crypto else "H-10b"
1513
+ skill = "crypto-compliance" if touches_crypto else "secret-handling"
1514
+ marker = os.path.join(_marker_root(root), ".codearbiter", ".markers", "security-gate-passed")
1515
+ if not marker_fresh(marker, MARKER_FRESHNESS_MINUTES):
1516
+ block(tag, f"This commit introduces {kind} changes, but no security-gate pass is "
1517
+ f"recorded (.codearbiter/.markers/security-gate-passed). Run the "
1518
+ f"{skill} gate (it records the pass), then commit. To bypass a "
1519
+ f"security gate, /override requires its heavier "
1520
+ f"security-acknowledgement path.")
1521
+ try:
1522
+ with open(marker, encoding="utf-8") as f:
1523
+ approved = set(f.read().split())
1524
+ except Exception: # noqa: BLE001
1525
+ approved = set()
1526
+ uncovered = [ln for ln in sensitive if line_digest(ln) not in approved]
1527
+ if uncovered:
1528
+ block(tag, f"{len(uncovered)} {kind} line(s) in this commit are not covered "
1529
+ f"by the recorded security-gate pass — the pass is bound to the "
1530
+ f"exact lines it reviewed, and these changed (or appeared) after "
1531
+ f"it ran. Re-run the {skill} gate so it reviews the current diff "
1532
+ f"and re-records the binding, then commit.")
1533
+
1534
+
1535
+ def _check_h14_migration(commit, add, cwd, root):
1536
+ """H-14: BLOCK a commit that stages a database migration without a recorded
1537
+ migration-review pass. commit-gate (and /review, /pr, /checkpoint, sprint)
1538
+ dispatch migration-reviewer and run hooks/migration-pass.py on PASS — a
1539
+ marker holding the content digest of every migration file the reviewer
1540
+ approved. Coverage is by content digest, no freshness window: an immutable
1541
+ migration stays approved while unchanged, and any edit changes the digest
1542
+ -> uncovered -> BLOCK (closes the TOCTOU window and enforces migration
1543
+ immutability at commit time). This closes the narrow #77 gap — a migration
1544
+ committed via bare /commit or the /feature small lane, where no lane
1545
+ dispatched the reviewer and no hook fired. A missing/unreadable marker is
1546
+ treated as no coverage (fail-closed), consistent with this layer's
1547
+ "ambiguity resolves CLOSED" stance."""
1548
+ if not commit:
1549
+ return
1550
+ cargs = _strip_heredoc_bodies(commit.group("args"))
1551
+ # Index paths, plus worktree paths when the commit pulls them in: -a/add
1552
+ # (whole tree) or a `git commit <pathspec>` (named paths only). A None
1553
+ # from any path query means git could not read the file list -> fail
1554
+ # CLOSED, consistent with this layer's "ambiguity resolves CLOSED" stance.
1555
+ staged = staged_paths(cwd)
1556
+ failed = staged is None
1557
+ extra = set()
1558
+ if COMMIT_ALL_RE.search(cargs) or add:
1559
+ wt = worktree_paths(cwd)
1560
+ failed = failed or wt is None
1561
+ extra = wt or set()
1562
+ else:
1563
+ pathspecs = commit_pathspecs(cargs)
1564
+ if pathspecs:
1565
+ ps = _names(cwd, ["diff", "HEAD", "--name-only", "--", *pathspecs])
1566
+ failed = failed or ps is None
1567
+ extra = ps or set()
1568
+ if failed:
1569
+ block("H-14", "the file list for the migration scan could not be read "
1570
+ "(git unavailable or timed out) — failing closed "
1571
+ "(ORCHESTRATOR §2). Retry, or run the migration-review gate, "
1572
+ "then commit." + _read_err_hint())
1573
+ staged |= extra
1574
+ migs = sorted(p for p in staged if is_migration_path(p, root))
1575
+ if migs:
1576
+ marker = os.path.join(_marker_root(root), ".codearbiter", ".markers", "migration-gate-passed")
1577
+ try:
1578
+ with open(marker, encoding="utf-8") as f:
1579
+ approved = set(f.read().split())
1580
+ except Exception: # noqa: BLE001 — missing/unreadable marker -> no coverage
1581
+ approved = set()
1582
+ uncovered = []
1583
+ for rel in migs:
1584
+ text = read_worktree(cwd, rel)
1585
+ if text is None or content_digest(text) not in approved:
1586
+ uncovered.append(rel)
1587
+ if uncovered:
1588
+ block("H-14", f"{len(uncovered)} staged migration file(s) lack a recorded "
1589
+ f"migration-review pass: {', '.join(uncovered)}. commit-gate "
1590
+ f"dispatches migration-reviewer and records the pass via "
1591
+ f"hooks/migration-pass.py; run that review, then commit. To "
1592
+ f"bypass a migration gate, /override logs the exception.")
1593
+
1594
+
1595
+ def run_guards(payload, root, ti):
1596
+ """Run every H-NN gate against `ti` (the already host-normalized Bash/
1597
+ PowerShell tool_input — see pre-bash.py's `_run`, which resolves it via
1598
+ `get_host().normalize_tool_input(...)` before calling here) and `payload`
1599
+ (the raw hook JSON dict, needed for `_effective_exec_root`'s worktree
1600
+ climb). block()-ing (stderr + exit 2) on the first violation found, else
1601
+ sys.exit(0). This is the composed body pre-bash.py's `_run` used to run
1602
+ inline (issue #320) — moved here verbatim, split into one function per
1603
+ gate, each still carrying its H-NN ID."""
1604
+ cmd = ti.get("command", "") or ""
1605
+
1606
+ # Heredoc bodies are stdin text, not arguments — match the git command over
1607
+ # a body-stripped view so message content (which may contain `;`/`|`/`&`,
1608
+ # or mention `git -C`) never truncates the args capture, poisons the cwd
1609
+ # extraction, or leaks words into the pathspec parse. The RAW command stays
1610
+ # as a fallback matcher, but ONLY when the heredoc's consumer is a shell
1611
+ # (D-3, #223, `_heredoc_fed_to_shell` above): a heredoc fed TO a shell
1612
+ # (`bash <<EOF … EOF`) genuinely executes its body, so a commit/push/add
1613
+ # visible only in the raw text must still be guarded there — ambiguity
1614
+ # resolves CLOSED. A heredoc fed to a non-shell consumer (`gh`, `cat`, …)
1615
+ # is inert prose and must NOT fall back to the raw-text scan, or a PR/issue
1616
+ # body merely QUOTING "git commit" false-trips H-01/H-09b/H-14.
1617
+ git_view = _strip_heredoc_bodies(cmd) if "<<" in cmd else cmd
1618
+ # Both legs are OR'd: the heredoc's direct consumer being a shell
1619
+ # (`bash <<EOF`) is one route to execution; a shell/interpreter executor
1620
+ # appearing ANYWHERE in the command (`bash -c "$(cat <<EOF … EOF)"`,
1621
+ # `eval "$(cat <<EOF … EOF)"`) is another that the direct-consumer check
1622
+ # alone cannot see (security-reviewer finding, post-A-4) — either is
1623
+ # sufficient to keep the fallback closed.
1624
+ heredoc_shell_fallback = "<<" in cmd and (
1625
+ _heredoc_fed_to_shell(cmd) or _has_shell_executor(cmd))
1626
+
1627
+ commit = COMMIT_RE.search(git_view) or (
1628
+ heredoc_shell_fallback and COMMIT_RE.search(cmd))
1629
+ push_probe = PUSH_RE.search(git_view) or (
1630
+ heredoc_shell_fallback and PUSH_RE.search(cmd))
1631
+
1632
+ # #223: -C-less git commands run against the command's EFFECTIVE cwd, not
1633
+ # unconditionally CLAUDE_PROJECT_DIR — see _effective_exec_root's
1634
+ # docstring (D-2: gate MARKERS stay pinned to `root` regardless; only
1635
+ # branch/diff resolution follows the command's real cwd).
1636
+ exec_root = _effective_exec_root(payload, root)
1637
+ cwd = git_cwd(git_view, exec_root)
1638
+
1639
+ # reliability-004 (#190): fail CLOSED for commit/push when the `-C` target
1640
+ # git_cwd() resolved does not exist as a directory — an unresolvable -C
1641
+ # target must not silently fall through to scanning some OTHER directory
1642
+ # (or crash mid-scan); ambiguity resolves CLOSED here exactly as the
1643
+ # git-read failures elsewhere in this file do.
1644
+ if (commit or push_probe) and not os.path.isdir(cwd):
1645
+ block("H-01", f"'git -C {cwd}' does not resolve to an existing directory — "
1646
+ f"failing closed (ORCHESTRATOR §2). Verify the -C target exists "
1647
+ f"before committing/pushing.")
1648
+
1649
+ _check_h20_commit_no_verify(commit)
1650
+ _check_h01_commit_protected_branch(commit, cwd)
1651
+
1652
+ push = PUSH_RE.search(git_view) or (
1653
+ heredoc_shell_fallback and PUSH_RE.search(cmd))
1654
+ _check_push_gates(push, cwd)
1655
+
1656
+ add = ADD_RE.search(git_view) or (
1657
+ heredoc_shell_fallback and ADD_RE.search(cmd))
1658
+ _check_h03_wildcard_add(add, cwd)
1659
+ _check_h05_audit_log(cmd)
1660
+ _check_h11_decisions(cmd)
1661
+ _check_h18_context_md(cmd)
1662
+ _check_h19_gate_marker(git_view, cmd, heredoc_shell_fallback)
1663
+ _check_h22_state(cmd, root)
1664
+ _check_h09b_h10b_crypto_secret(commit, add, cwd, root)
1665
+ _check_h14_migration(commit, add, cwd, root)
1666
+
1667
+ sys.exit(0)