@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.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- 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)
|