@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,231 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter v2 — PostToolUse(Write|Edit) reminders. Scope-touch nudges.
|
|
3
|
+
# Python port of post-write-edit.sh (#25): no jq, fails loud.
|
|
4
|
+
#
|
|
5
|
+
# These are advisory (non-blocking) nudges fired right after a write. The
|
|
6
|
+
# load-bearing crypto/secret ENFORCEMENT is the blocking pre-commit gate in
|
|
7
|
+
# pre-bash.py (H-09b/H-10b); these reminders surface the touch early so the gate
|
|
8
|
+
# isn't a surprise at commit time. Kept: H-07 (dependency review), H-09 (crypto),
|
|
9
|
+
# H-10 (secret). Added: H-12 (file governed by an ADR with a `governs:` field).
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
19
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
20
|
+
from _hooklib import ( # noqa: E402
|
|
21
|
+
CRYPTO_RE, SECRET_RE, arbiter_active, get_host, is_ci_path, is_deploy_path,
|
|
22
|
+
project_root, read_input, remind, repo_rel, set_host, utf8_stdio,
|
|
23
|
+
)
|
|
24
|
+
from _sloplib import find_prose_separator_dashes, in_antislop_doc_scope # noqa: E402
|
|
25
|
+
|
|
26
|
+
DEP_MANIFEST_RE = re.compile(
|
|
27
|
+
r"(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|requirements\.txt"
|
|
28
|
+
r"|pyproject\.toml|go\.mod|Cargo\.toml)$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Narrow, high-signal auth patterns for the H-17 advisory (#73). Deliberately
|
|
32
|
+
# tight — auth has no clean deterministic trigger, so this nudges on the
|
|
33
|
+
# unmistakable markers (passport, jwt verify/sign, framework guards/decorators,
|
|
34
|
+
# common auth middleware names) rather than every mention of "login"/"session",
|
|
35
|
+
# which would be noise. Advisory only: there is no auth commit gate.
|
|
36
|
+
AUTH_RE = re.compile(
|
|
37
|
+
r"(passport\.(?:authenticate|use)"
|
|
38
|
+
r"|\bjsonwebtoken\b|jwt\.(?:verify|sign)"
|
|
39
|
+
r"|@(?:UseGuards|CanActivate|Authorize|RequireAuth)\b"
|
|
40
|
+
r"|\b(?:AuthGuard|requireAuth|isAuthenticated|authenticateUser|withAuth)\b"
|
|
41
|
+
r"|@login_required\b)",
|
|
42
|
+
re.I,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
GOVERNS_RE = re.compile(r"^governs:\s*(.+)$", re.I)
|
|
46
|
+
TITLE_RE = re.compile(r"^title:\s*(.+)$", re.I)
|
|
47
|
+
STATUS_RE = re.compile(r"^status:\s*(.+)$", re.I)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def adr_identifier(filename):
|
|
51
|
+
"""The ADR identifier for `filename` — its filename STEM, not its number.
|
|
52
|
+
|
|
53
|
+
`0014-githook-shim-dropin-fail-closed.md` → `0014-githook-shim-dropin-fail-closed`.
|
|
54
|
+
|
|
55
|
+
#416: two ADRs in this repo share the number 0014, so the old
|
|
56
|
+
`filename.split("-")[0]` derivation collapsed two distinct decisions onto one
|
|
57
|
+
id and nudged "governed by ADR-0014" without saying which. The stem is unique
|
|
58
|
+
by construction, so keying on it makes the collision unrepresentable.
|
|
59
|
+
|
|
60
|
+
Deliberately mirrored in _readinjectlib.py — the two indexers are separate
|
|
61
|
+
payload files that cannot import each other's private helpers; the shared
|
|
62
|
+
contract test (.github/scripts/test_adr_identity.py) pins them to the same
|
|
63
|
+
answer so the derivation cannot drift apart again.
|
|
64
|
+
"""
|
|
65
|
+
return os.path.splitext(str(filename))[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Bumped whenever the SHAPE or the derivation of a cached index entry changes.
|
|
69
|
+
# The mtime stamp alone only invalidates on an ADR edit, so without this a
|
|
70
|
+
# payload upgrade would keep serving pre-#416 numeric ids out of an already-
|
|
71
|
+
# written cache until someone happened to touch a decisions/ file.
|
|
72
|
+
GOVERNS_CACHE_SCHEMA = 2
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def governs_index(root):
|
|
76
|
+
"""path-glob → ADR map from decisions/ frontmatter, cached against the
|
|
77
|
+
directory's latest mtime so the scan doesn't repeat on every write."""
|
|
78
|
+
ddir = os.path.join(root, ".codearbiter", "decisions")
|
|
79
|
+
if not os.path.isdir(ddir):
|
|
80
|
+
return []
|
|
81
|
+
files = [f for f in os.listdir(ddir) if re.match(r"[0-9]+-.+\.md$", f)]
|
|
82
|
+
if not files:
|
|
83
|
+
return []
|
|
84
|
+
stamp = max(os.path.getmtime(os.path.join(ddir, f)) for f in files)
|
|
85
|
+
cache_path = os.path.join(root, ".codearbiter", ".markers", "governs-cache.json")
|
|
86
|
+
try:
|
|
87
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
88
|
+
cache = json.load(f)
|
|
89
|
+
if (cache.get("stamp") == stamp
|
|
90
|
+
and cache.get("schema") == GOVERNS_CACHE_SCHEMA):
|
|
91
|
+
return cache.get("index", [])
|
|
92
|
+
except Exception: # noqa: BLE001 — absent/corrupt cache just rebuilds
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
index = []
|
|
96
|
+
for fn in files:
|
|
97
|
+
title, status, globs = fn, "", []
|
|
98
|
+
try:
|
|
99
|
+
with open(os.path.join(ddir, fn), encoding="utf-8", errors="replace") as f:
|
|
100
|
+
for i, ln in enumerate(f):
|
|
101
|
+
if i > 25:
|
|
102
|
+
break
|
|
103
|
+
if m := GOVERNS_RE.match(ln.strip()):
|
|
104
|
+
globs = [g.strip() for g in m.group(1).split(",") if g.strip()]
|
|
105
|
+
elif m := TITLE_RE.match(ln.strip()):
|
|
106
|
+
title = m.group(1).strip()
|
|
107
|
+
elif m := STATUS_RE.match(ln.strip()):
|
|
108
|
+
status = m.group(1).strip().lower()
|
|
109
|
+
except Exception: # noqa: BLE001
|
|
110
|
+
continue
|
|
111
|
+
if globs and status not in ("superseded", "rejected"):
|
|
112
|
+
adr = adr_identifier(fn)
|
|
113
|
+
index.append({"adr": adr, "title": title, "globs": globs})
|
|
114
|
+
try:
|
|
115
|
+
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
|
116
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
117
|
+
json.dump({"stamp": stamp, "schema": GOVERNS_CACHE_SCHEMA,
|
|
118
|
+
"index": index}, f)
|
|
119
|
+
except Exception: # noqa: BLE001 — cache is an optimization, never required
|
|
120
|
+
pass
|
|
121
|
+
return index
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _remind_op(root, op):
|
|
125
|
+
"""Run every advisory nudge against ONE canonical file op
|
|
126
|
+
(hostapi.Host.iter_file_ops, ADR-0011 M2). Under Claude Code this receives
|
|
127
|
+
exactly the one op its Write/Edit payload maps to — op["added_text"] is
|
|
128
|
+
content-or-new_string, precisely the pre-seam `ti.get("content") or
|
|
129
|
+
ti.get("new_string")` read — so the Claude reminder path is unchanged. A
|
|
130
|
+
Codex apply_patch envelope fans out to one op per touched file, each
|
|
131
|
+
getting the same per-file nudges over its added lines."""
|
|
132
|
+
fpath = op.get("file_path", "") or ""
|
|
133
|
+
content = op.get("added_text") or ""
|
|
134
|
+
|
|
135
|
+
# H-12: the touched file is governed by an accepted ADR — surface it so the
|
|
136
|
+
# recorded decision pushes back at edit time, not at the next checkpoint.
|
|
137
|
+
rel = repo_rel(fpath, root)
|
|
138
|
+
if rel:
|
|
139
|
+
for entry in governs_index(root):
|
|
140
|
+
if any(fnmatch.fnmatch(rel, g) for g in entry["globs"]):
|
|
141
|
+
remind("H-12", f"{rel} is governed by ADR-{entry['adr']} ({entry['title']}). "
|
|
142
|
+
f"If this change contradicts it, route to "
|
|
143
|
+
f"{get_host().cmd_ref('reconcile')} or "
|
|
144
|
+
f"{get_host().cmd_ref('adr')} — do not drift silently.")
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
# H-07: dependency manifest changed — review before committing.
|
|
148
|
+
if DEP_MANIFEST_RE.search(fpath):
|
|
149
|
+
remind("H-07", "Dependency manifest changed. Dispatch dependency-reviewer before "
|
|
150
|
+
"committing (ORCHESTRATOR §5).")
|
|
151
|
+
|
|
152
|
+
# H-15: CI/CD workflow touched — advisory (#73). A bad workflow only runs
|
|
153
|
+
# once merged, so this is a nudge, not a commit gate; dispatch security-reviewer
|
|
154
|
+
# before the PR merges.
|
|
155
|
+
if rel and is_ci_path(rel, root):
|
|
156
|
+
remind("H-15", "CI/CD workflow changed. Dispatch security-reviewer before merging "
|
|
157
|
+
"(it reviews workflow/secrets/permissions exposure). Advisory — "
|
|
158
|
+
"not a commit block.")
|
|
159
|
+
|
|
160
|
+
# H-16: deployment / IaC manifest touched — advisory (#73). IaC bites only on
|
|
161
|
+
# apply, so this nudges; security-reviewer is the enforcement point at PR.
|
|
162
|
+
if rel and is_deploy_path(rel, root):
|
|
163
|
+
remind("H-16", "Deployment/IaC manifest changed. Dispatch security-reviewer before "
|
|
164
|
+
"merging (it reviews exposed ports, public ACLs, disabled "
|
|
165
|
+
"encryption, privilege). Advisory — not a commit block.")
|
|
166
|
+
|
|
167
|
+
# H-17: authentication/authorization logic touched — advisory (#73). Narrow,
|
|
168
|
+
# high-signal patterns only; the dangerous crypto/secret primitives are still
|
|
169
|
+
# hard-blocked by H-09b/H-10b at commit time.
|
|
170
|
+
if AUTH_RE.search(content):
|
|
171
|
+
remind("H-17", "Auth/authorization logic detected. Dispatch security-reviewer "
|
|
172
|
+
"before merging (authn on every protected route; authz at the "
|
|
173
|
+
"right layer). Advisory — not a commit block.")
|
|
174
|
+
|
|
175
|
+
# H-09: crypto/TLS pattern — the pre-commit gate will BLOCK until the
|
|
176
|
+
# crypto-compliance gate records a pass; run it now to avoid a commit-time stop.
|
|
177
|
+
if CRYPTO_RE.search(content):
|
|
178
|
+
remind("H-09", "Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch "
|
|
179
|
+
"auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable "
|
|
180
|
+
"TLS verification). The commit will block until the gate records a pass.")
|
|
181
|
+
|
|
182
|
+
# H-10: possible hardcoded secret.
|
|
183
|
+
if SECRET_RE.search(content):
|
|
184
|
+
remind("H-10", "Possible hardcoded secret. Run the secret-handling check before "
|
|
185
|
+
"committing. The commit will block until the gate records a pass.")
|
|
186
|
+
|
|
187
|
+
# H-13: a user-facing doc was written with an em/en dash used as a prose
|
|
188
|
+
# separator (anti-slop-design core §3.A, the single highest-signal AI tell).
|
|
189
|
+
# Advisory nudge to run the copy self-audit before it ships. Scope is community
|
|
190
|
+
# docs + docs/**, never codeArbiter's own framework bodies (see _sloplib).
|
|
191
|
+
if rel and in_antislop_doc_scope(rel):
|
|
192
|
+
hits = find_prose_separator_dashes(content)
|
|
193
|
+
if hits:
|
|
194
|
+
shown = ", ".join(str(h["line"]) for h in hits[:5])
|
|
195
|
+
more = "" if len(hits) <= 5 else f" (+{len(hits) - 5} more)"
|
|
196
|
+
remind("H-13", f"{rel}: em/en dash used as a prose separator on line(s) "
|
|
197
|
+
f"{shown}{more} (anti-slop-design §3.A). Restructure the prose "
|
|
198
|
+
f"and run the §3.A/§3.B copy self-audit before committing.")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
utf8_stdio()
|
|
203
|
+
root = project_root()
|
|
204
|
+
if not arbiter_active(root):
|
|
205
|
+
sys.exit(0)
|
|
206
|
+
# Host seam (ADR-0011, M2): one canonical op per touched file — exactly one
|
|
207
|
+
# under Claude Code (verdict-identical to the pre-seam single-payload body),
|
|
208
|
+
# one per patched file under Codex.
|
|
209
|
+
for op in get_host().iter_file_ops(read_input()):
|
|
210
|
+
_remind_op(root, op)
|
|
211
|
+
sys.exit(0)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run(host, argv=None):
|
|
215
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
216
|
+
plugin's loaded Host. Wraps main() unchanged — main() still communicates
|
|
217
|
+
via sys.exit/stdout/stderr, and its return value stays discarded exactly
|
|
218
|
+
as the old bare `main()` guard discarded it (so the process still exits 0
|
|
219
|
+
on a normal fall-through).
|
|
220
|
+
|
|
221
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
222
|
+
`set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
|
|
223
|
+
to the SAME instance the caller passed here — no second
|
|
224
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
225
|
+
`fake_host`."""
|
|
226
|
+
return _entrylib.dispatch(host, argv, main, set_host,
|
|
227
|
+
pass_argv=False, propagate_result=False)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter v2 — PreToolUse(Bash|PowerShell) guard. Thin entry point (#320):
|
|
3
|
+
# reads stdin, dispatches to _bashguardlib.run_guards() for the actual
|
|
4
|
+
# command-parsing / H-NN gate logic, exit code. Python port of pre-bash.sh
|
|
5
|
+
# (issues #24, #25): no jq, fails loud, blocks via exit 2.
|
|
6
|
+
#
|
|
7
|
+
# All guards run only in arbiter-enabled repos (the plugin.json activation
|
|
8
|
+
# contract); elsewhere this exits 0 immediately.
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
16
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
17
|
+
from _hooklib import ( # noqa: E402
|
|
18
|
+
arbiter_active, block, get_host, project_root, read_input, set_host,
|
|
19
|
+
tool_input, utf8_stdio,
|
|
20
|
+
)
|
|
21
|
+
import _bashguardlib # noqa: E402 — guard logic lives here (#320)
|
|
22
|
+
|
|
23
|
+
# Backward-compat re-exports: pre-existing tests load this module directly
|
|
24
|
+
# (importlib) and reach into it by name — kept resolving exactly as they did
|
|
25
|
+
# before the #320 extraction, rather than editing those tests.
|
|
26
|
+
# .github/scripts/test_hook_guards.py: pre_bash._strip_heredoc_bodies,
|
|
27
|
+
# pre_bash.commit_pathspecs (now _bashguardlib's implementations)
|
|
28
|
+
# plugins/ca/hooks/tests/test_repo_resolution.py: pre_bash.git_cwd
|
|
29
|
+
_strip_heredoc_bodies = _bashguardlib._strip_heredoc_bodies
|
|
30
|
+
commit_pathspecs = _bashguardlib.commit_pathspecs
|
|
31
|
+
git_cwd = _bashguardlib.git_cwd
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run(root):
|
|
35
|
+
# tool_input()/get_host() are resolved HERE (module-level names on this
|
|
36
|
+
# entry file), not inside _bashguardlib, so that
|
|
37
|
+
# test_guard_crash_failclosed.py's `self.mod.tool_input = <raiser>`
|
|
38
|
+
# monkeypatch — which replaces the attribute on THIS module object —
|
|
39
|
+
# still reaches the call (module-level rebinding is invisible to a name
|
|
40
|
+
# already imported into a different module's namespace).
|
|
41
|
+
payload = read_input()
|
|
42
|
+
ti = get_host().normalize_tool_input(payload.get("tool_name", "") or "",
|
|
43
|
+
tool_input(payload))
|
|
44
|
+
_bashguardlib.run_guards(payload, root, ti)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
utf8_stdio()
|
|
49
|
+
root = project_root()
|
|
50
|
+
if not arbiter_active(root):
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
# reliability-002 (#189): everything past this point runs only in an
|
|
53
|
+
# arbiter-enabled repo, so a dormant/non-codeArbiter repo can never be
|
|
54
|
+
# bricked by a crash here. An uncaught exception in the scan path below
|
|
55
|
+
# (H-01/H-03/H-05/H-09b/H-10b/H-11/H-14/H-18/H-19/H-20) must fail CLOSED (exit 2,
|
|
56
|
+
# a BLOCK) rather than exit 1 — a non-2 exit is a NON-blocking error under
|
|
57
|
+
# the Claude Code hook contract (_hooklib.py:11-15), which would silently
|
|
58
|
+
# ALLOW the very tool call this guard exists to scan. read_input()'s
|
|
59
|
+
# documented fail-OPEN behavior on malformed stdin is unaffected: it catches
|
|
60
|
+
# its own parse errors internally and returns {} before this wrapper is
|
|
61
|
+
# ever reached.
|
|
62
|
+
try:
|
|
63
|
+
_run(root)
|
|
64
|
+
except SystemExit:
|
|
65
|
+
raise
|
|
66
|
+
except Exception: # noqa: BLE001 — the fail-closed backstop of last resort
|
|
67
|
+
traceback.print_exc(file=sys.stderr)
|
|
68
|
+
block("H-00", "pre-bash guard crashed while scanning this command — failing "
|
|
69
|
+
"closed (ORCHESTRATOR §2) rather than silently allowing an "
|
|
70
|
+
"unscanned command. See the traceback above; retry, or report it.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run(host, argv=None):
|
|
74
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
75
|
+
plugin's loaded Host. Wraps main() unchanged — main() still communicates
|
|
76
|
+
via sys.exit/stdout/stderr, and its return value stays discarded exactly
|
|
77
|
+
as the old bare `main()` guard discarded it (so the process still exits 0
|
|
78
|
+
on a normal fall-through).
|
|
79
|
+
|
|
80
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
81
|
+
`set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
|
|
82
|
+
to the SAME instance the caller passed here — no second
|
|
83
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
84
|
+
`fake_host`."""
|
|
85
|
+
return _entrylib.dispatch(host, argv, main, set_host,
|
|
86
|
+
pass_argv=False, propagate_result=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
sys.exit(run(hostapi.load_host()) or 0)
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter v2 — PreToolUse(Edit|MultiEdit|NotebookEdit) guard. Activation +
|
|
3
|
+
# audit-log + marker + ADR integrity. Python port of pre-edit.sh (#25): no jq,
|
|
4
|
+
# fails loud, blocks via exit 2.
|
|
5
|
+
#
|
|
6
|
+
# Guards (arbiter-enabled repo only), symlink-resolved via classify_protected
|
|
7
|
+
# (#162 — raw path AND realpath-resolved repo-relative form):
|
|
8
|
+
# H-18 CONTEXT.md may not be edited to drop `arbiter: enabled` (#159).
|
|
9
|
+
# H-19 .codearbiter/.markers/* are never edited via the Edit tools (#160).
|
|
10
|
+
# H-05 audit-log Edits must be pure, tail-anchored appends: old_string must
|
|
11
|
+
# be the file's REAL current trailing content, new_string must extend
|
|
12
|
+
# it, and replace_all is rejected outright (reliability-003, #172).
|
|
13
|
+
# H-11 ADRs under decisions/ are edited only via /adr.
|
|
14
|
+
# H-22 the protected-state registry (B1/#564): the SAME generic branch as
|
|
15
|
+
# pre-write.py — marker-gated admits only under a fresh authoring
|
|
16
|
+
# marker; helper-only and append-only are hard-blocked, no marker
|
|
17
|
+
# path at all.
|
|
18
|
+
#
|
|
19
|
+
# NotebookEdit is guarded too (a notebook has no append/frontmatter semantics, so
|
|
20
|
+
# a protected target is refused outright) — defense in depth; none of the
|
|
21
|
+
# protected files is a .ipynb, but the tool must not be a hole.
|
|
22
|
+
#
|
|
23
|
+
# Host seam (ADR-0011, #261): this file reasons entirely over the canonical
|
|
24
|
+
# per-file ops from hostapi.Host.iter_file_ops (the same seam pre-write.py
|
|
25
|
+
# already routes through) — it never branches on a native tool name
|
|
26
|
+
# ("MultiEdit", "NotebookEdit"). The properties that used to be read off the
|
|
27
|
+
# raw tool_input (old_string, replace_all, "this is a MultiEdit batch entry",
|
|
28
|
+
# "this is a notebook with no content semantics") are host-neutral op fields
|
|
29
|
+
# (old_string, replace_all, batched, notebook) instead — a future host whose
|
|
30
|
+
# edit tool shares those same shapes gets the same guard semantics without
|
|
31
|
+
# pre-edit.py knowing its tool names.
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import traceback
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
import hostapi # noqa: E402 — host seam (ADR-0011)
|
|
39
|
+
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
|
|
40
|
+
from _hooklib import ( # noqa: E402
|
|
41
|
+
MARKER_FRESHNESS_MINUTES, arbiter_active, block, classify_protected,
|
|
42
|
+
frontmatter_enabled_text, get_host, is_tail_append, marker_fresh,
|
|
43
|
+
project_root, read_input, set_host, utf8_stdio,
|
|
44
|
+
)
|
|
45
|
+
from _protectedstatelib import ( # noqa: E402
|
|
46
|
+
ProtectedPolicy, marker_gated_write_admitted, resolve_registered_path,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_real_text(root, fpath):
|
|
51
|
+
"""Best-effort current on-disk content of `fpath` (symlink-resolved), or
|
|
52
|
+
"" if unreadable. Shared by the #159 CONTEXT.md replay and the H-05
|
|
53
|
+
tail-anchor check (reliability-003, #172) — both need the file's REAL
|
|
54
|
+
current content, not just an edit op's own old_string, to verify what the
|
|
55
|
+
edit actually does to the file."""
|
|
56
|
+
path = fpath if os.path.isabs(fpath) else os.path.join(root, fpath)
|
|
57
|
+
try:
|
|
58
|
+
with open(os.path.realpath(path), encoding="utf-8", errors="replace") as f:
|
|
59
|
+
return f.read()
|
|
60
|
+
except Exception: # noqa: BLE001
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resulting_context(root, fpath, ops):
|
|
65
|
+
"""Best-effort resulting content of CONTEXT.md after ALL of `ops` (one or
|
|
66
|
+
more canonical edit ops targeting the same file — a MultiEdit call fans
|
|
67
|
+
out to several, applied as one atomic batch) are replayed in order, so
|
|
68
|
+
the #159 guard can vet whether the FULL call keeps the repo
|
|
69
|
+
arbiter-enabled. Reads the real (symlink-resolved) file and replays each
|
|
70
|
+
op's old_string/added_text/replace_all against the running text, exactly
|
|
71
|
+
as the pre-seam per-tool replay did — driven off op fields instead of a
|
|
72
|
+
native tool_input shape. An old_string that isn't present leaves the text
|
|
73
|
+
unchanged (the tool would error anyway) — harmless for the check."""
|
|
74
|
+
text = _read_real_text(root, fpath)
|
|
75
|
+
for op in ops:
|
|
76
|
+
old = op.get("old_string", "") or ""
|
|
77
|
+
new = op.get("added_text", "") or ""
|
|
78
|
+
if op.get("replace_all"):
|
|
79
|
+
text = text.replace(old, new)
|
|
80
|
+
else:
|
|
81
|
+
text = text.replace(old, new, 1)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run(root):
|
|
86
|
+
payload = read_input()
|
|
87
|
+
# Host seam (ADR-0011, M2/#261): iter_file_ops maps this host's native
|
|
88
|
+
# payload to canonical per-file ops. Under Claude Code, Edit/NotebookEdit
|
|
89
|
+
# map to exactly one op and MultiEdit fans to one op per edits[] entry —
|
|
90
|
+
# all sharing the SAME file_path, since each of these tools targets one
|
|
91
|
+
# file per call. Codex does not register this entry at all — its edits
|
|
92
|
+
# arrive as apply_patch and are guarded per-op by pre-write.py
|
|
93
|
+
# (docs/parity.md).
|
|
94
|
+
ops = list(get_host().iter_file_ops(payload))
|
|
95
|
+
if not ops:
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
fpath = ops[0].get("file_path", "") or ""
|
|
99
|
+
classes = classify_protected(fpath, root)
|
|
100
|
+
|
|
101
|
+
# The tag that names each protected class, in message priority order.
|
|
102
|
+
_CLASS_TAG = (("marker", "H-19"), ("context", "H-18"),
|
|
103
|
+
("audit", "H-05"), ("decisions", "H-11"),
|
|
104
|
+
("state", "H-22"))
|
|
105
|
+
|
|
106
|
+
# H-21: an "opaque" op is a host's signal that it could not map this
|
|
107
|
+
# payload to a known per-file shape at all (e.g. a FailClosedHost, or a
|
|
108
|
+
# future host's unparseable envelope) — ambiguity resolves CLOSED
|
|
109
|
+
# (ORCHESTRATOR §2), mirroring pre-write.py's opaque handling.
|
|
110
|
+
if any(op.get("kind") == "opaque" for op in ops):
|
|
111
|
+
block("H-21", "This edit could not be decomposed into per-file operations, so its "
|
|
112
|
+
"targets cannot be guarded. Failing closed. Retry, or report it.")
|
|
113
|
+
|
|
114
|
+
# A "notebook" op (NotebookEdit): its target has no append/frontmatter
|
|
115
|
+
# semantics to reason about — refuse any protected target outright with a
|
|
116
|
+
# class-appropriate tag (defense in depth). A notebook call always fans to
|
|
117
|
+
# exactly one op, so checking ops[0] speaks for the whole call.
|
|
118
|
+
if ops[0].get("notebook"):
|
|
119
|
+
for cls, tag in _CLASS_TAG:
|
|
120
|
+
if cls in classes:
|
|
121
|
+
block(tag, "NotebookEdit may not target a protected .codearbiter artifact "
|
|
122
|
+
"(CONTEXT.md, audit logs, ADRs, or markers) — use the sanctioned "
|
|
123
|
+
"skill path.")
|
|
124
|
+
sys.exit(0)
|
|
125
|
+
|
|
126
|
+
# H-19: markers are never edited via the Edit tools (#160) — the sanctioned
|
|
127
|
+
# producers write them; an Edit is tampering. Block outright.
|
|
128
|
+
if "marker" in classes:
|
|
129
|
+
block("H-19", "The .codearbiter/.markers/ gate tokens are not editable via the Edit "
|
|
130
|
+
"tools (#160) — editing one forges or tampers with a security/migration/ADR "
|
|
131
|
+
"gate pass. Markers are recorded only by the sanctioned gate producers.")
|
|
132
|
+
|
|
133
|
+
# H-18: an edit to CONTEXT.md may not drop `arbiter: enabled` or corrupt the
|
|
134
|
+
# frontmatter (#159) — replay every op against the file in order (a
|
|
135
|
+
# MultiEdit's edits apply as one atomic batch; the resulting text after
|
|
136
|
+
# ALL of them is what matters, not any single intermediate op) and
|
|
137
|
+
# require the FINAL result stays enabled.
|
|
138
|
+
if "context" in classes:
|
|
139
|
+
enabled, malformed = frontmatter_enabled_text(_resulting_context(root, fpath, ops))
|
|
140
|
+
if malformed or not enabled:
|
|
141
|
+
block("H-18", "This Edit would remove or alter the `arbiter: enabled` frontmatter in "
|
|
142
|
+
".codearbiter/CONTEXT.md (#159) — the activation switch every enforcement "
|
|
143
|
+
"hook reads. Disabling it from inside the repo would make every gate "
|
|
144
|
+
"dormant. Keep `arbiter: enabled` in a well-formed frontmatter block.")
|
|
145
|
+
|
|
146
|
+
# H-05: the audit logs (and the /sprint decision record) are append-only — an
|
|
147
|
+
# Edit must be a verifiable TAIL-ANCHORED append: old_string must be the
|
|
148
|
+
# file's REAL current trailing content, and new_string must extend it.
|
|
149
|
+
# (path set: _hooklib.is_audit_log)
|
|
150
|
+
if "audit" in classes:
|
|
151
|
+
# A batched op (MultiEdit's edits[] fan) applies as part of a batch and
|
|
152
|
+
# no single entry's old_string/new_string can express a verifiable
|
|
153
|
+
# pure append to an append-only file — the sanctioned append path is a
|
|
154
|
+
# single Edit (or '>>'). Block it outright rather than reason about
|
|
155
|
+
# the batch.
|
|
156
|
+
if any(op.get("batched") for op in ops):
|
|
157
|
+
block("H-05", "MultiEdit cannot guarantee a pure append to an append-only "
|
|
158
|
+
".codearbiter audit log (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) "
|
|
159
|
+
"(ORCHESTRATOR §7). Append with a single Edit or '>>'.")
|
|
160
|
+
# Exactly one non-batched op reaches this point (Edit).
|
|
161
|
+
op = ops[0]
|
|
162
|
+
# reliability-003 (#172): replace_all can never be a verifiable append —
|
|
163
|
+
# it rewrites EVERY occurrence of old_string, not just the tail (a
|
|
164
|
+
# single-occurrence replace_all is indistinguishable from a targeted
|
|
165
|
+
# rewrite, and a multi-occurrence one alters interior lines outright).
|
|
166
|
+
# Reject it outright before even looking at old_string/new_string.
|
|
167
|
+
if op.get("replace_all"):
|
|
168
|
+
block("H-05", "An Edit with replace_all=true on an append-only .codearbiter "
|
|
169
|
+
"audit log (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) cannot be a "
|
|
170
|
+
"verifiable pure append (ORCHESTRATOR §7) — replace_all rewrites "
|
|
171
|
+
"every matching occurrence, not just the file's tail. Append with "
|
|
172
|
+
"'>>', or a single non-replace_all Edit whose old_string is the "
|
|
173
|
+
"file's current trailing content.")
|
|
174
|
+
old = op.get("old_string", "") or ""
|
|
175
|
+
new = op.get("added_text", "") or ""
|
|
176
|
+
# migration-003: `new.startswith("")` is ALWAYS True, so an empty
|
|
177
|
+
# old_string defeats the append check entirely — it could prepend or
|
|
178
|
+
# replace arbitrary content with the guard never firing. An empty
|
|
179
|
+
# old_string can never be a verifiable append (there is no existing tail
|
|
180
|
+
# to extend); block it outright.
|
|
181
|
+
if not old:
|
|
182
|
+
block("H-05", "An Edit with an empty old_string on an append-only .codearbiter "
|
|
183
|
+
"audit log (overrides.log, triage.log, sprint-log.md, decisions/decision-log.md) cannot be a "
|
|
184
|
+
"verifiable pure append (ORCHESTRATOR §7) — every string starts with "
|
|
185
|
+
"the empty string. Append with '>>', or a single Edit whose old_string "
|
|
186
|
+
"is the file's current trailing content.")
|
|
187
|
+
if not new.startswith(old):
|
|
188
|
+
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, "
|
|
189
|
+
"sprint-log.md) are append-only (ORCHESTRATOR §7). This Edit alters "
|
|
190
|
+
"existing audit lines; only pure appends are permitted (new text "
|
|
191
|
+
"must extend the old text).")
|
|
192
|
+
# reliability-003 (#172): new.startswith(old) alone is not enough — an
|
|
193
|
+
# old_string that matches an INTERIOR line (not the file's actual
|
|
194
|
+
# trailing content) still satisfies startswith, but inserts content
|
|
195
|
+
# BETWEEN existing lines rather than truly appending at the end.
|
|
196
|
+
# Tail-anchor against the REAL on-disk content: old_string must be
|
|
197
|
+
# exactly what the file currently ends with.
|
|
198
|
+
current = _read_real_text(root, fpath)
|
|
199
|
+
if not is_tail_append(current, old, new):
|
|
200
|
+
block("H-05", "The .codearbiter audit logs (overrides.log, triage.log, "
|
|
201
|
+
"sprint-log.md) are append-only (ORCHESTRATOR §7). This Edit's "
|
|
202
|
+
"old_string is not the file's current TRAILING content — a mid-file "
|
|
203
|
+
"insertion reorders the audit record even though new_string extends "
|
|
204
|
+
"old_string. Append with '>>', or a single Edit whose old_string is "
|
|
205
|
+
"exactly the file's current tail.")
|
|
206
|
+
|
|
207
|
+
# H-11: ADRs may only be edited via /adr — any .md anywhere under decisions/
|
|
208
|
+
# (a non-numbered draft or a nested path is still an immutable decision).
|
|
209
|
+
# (path set: _hooklib.is_decisions_path)
|
|
210
|
+
if "decisions" in classes:
|
|
211
|
+
marker = os.path.join(root, ".codearbiter", ".markers", "adr-authoring-active")
|
|
212
|
+
if not os.path.isfile(marker):
|
|
213
|
+
block("H-11", "ADR files are edited only via /adr (ORCHESTRATOR §3) — user "
|
|
214
|
+
"attribution required.")
|
|
215
|
+
if not marker_fresh(marker, MARKER_FRESHNESS_MINUTES):
|
|
216
|
+
block("H-11", f"ADR authoring marker is stale (>{MARKER_FRESHNESS_MINUTES} min). Re-run /adr.")
|
|
217
|
+
|
|
218
|
+
# H-22: the protected-state registry (B1/#564) — the SAME generic branch
|
|
219
|
+
# pre-write.py carries (T-06/T-07 design ruling: one branch, not a
|
|
220
|
+
# per-tool special case). classify_protected already decided "state" is
|
|
221
|
+
# a hit; resolve_registered_path only resolves WHICH registered path and
|
|
222
|
+
# WHICH policy — never a second, independent membership check.
|
|
223
|
+
# marker-gated admits only under a fresh authoring marker; helper-only
|
|
224
|
+
# and append-only are flank-IDENTICAL here — both hard-block
|
|
225
|
+
# unconditionally, with NO marker path and no tail-anchored-append
|
|
226
|
+
# admission (unlike H-05's audit logs, an append-only registry entry's
|
|
227
|
+
# append verb lives in the sanctioned HELPER, not in this Edit flank).
|
|
228
|
+
if "state" in classes:
|
|
229
|
+
rel, policy = resolve_registered_path(fpath, root)
|
|
230
|
+
if policy == ProtectedPolicy.MARKER_GATED:
|
|
231
|
+
if not marker_gated_write_admitted(rel, root):
|
|
232
|
+
block("H-22", f"'{rel}' is marker-gated protected project state (#564) — an "
|
|
233
|
+
f"Edit is admitted only under a fresh authoring marker. Run the "
|
|
234
|
+
f"sanctioned authoring lane, or "
|
|
235
|
+
+ get_host().cmd_ref("override") + ".")
|
|
236
|
+
else:
|
|
237
|
+
block("H-22", f"'{rel}' is protected project state (#564) — the Edit tools may "
|
|
238
|
+
f"never target it; there is no marker path for this policy. Use "
|
|
239
|
+
f"the sanctioned helper.")
|
|
240
|
+
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main():
|
|
245
|
+
utf8_stdio()
|
|
246
|
+
root = project_root()
|
|
247
|
+
if not arbiter_active(root):
|
|
248
|
+
sys.exit(0)
|
|
249
|
+
# reliability-002 (#189): scoped to arbiter-enabled repos only (above), so a
|
|
250
|
+
# dormant/non-codeArbiter repo can never be bricked by a crash here. An
|
|
251
|
+
# uncaught exception in the scan below must fail CLOSED (exit 2 = BLOCK),
|
|
252
|
+
# not exit 1 — a non-2 exit is a NON-blocking error under the Claude Code
|
|
253
|
+
# hook contract (_hooklib.py:11-15) and would silently ALLOW the Edit.
|
|
254
|
+
# read_input()'s documented fail-OPEN parse behavior is unaffected: it
|
|
255
|
+
# catches its own errors and returns {} before this wrapper is reached.
|
|
256
|
+
try:
|
|
257
|
+
_run(root)
|
|
258
|
+
except SystemExit:
|
|
259
|
+
raise
|
|
260
|
+
except Exception: # noqa: BLE001 — the fail-closed backstop of last resort
|
|
261
|
+
traceback.print_exc(file=sys.stderr)
|
|
262
|
+
block("H-00", "pre-edit guard crashed while scanning this Edit — failing "
|
|
263
|
+
"closed (ORCHESTRATOR §2) rather than silently allowing an "
|
|
264
|
+
"unscanned edit. See the traceback above; retry, or report it.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def run(host, argv=None):
|
|
268
|
+
"""Host-seam entry point (ADR-0011): the __main__ guard calls this with the
|
|
269
|
+
plugin's loaded Host. Wraps main() unchanged — main() still communicates
|
|
270
|
+
via sys.exit/stdout/stderr, and its return value stays discarded exactly
|
|
271
|
+
as the old bare `main()` guard discarded it (so the process still exits 0
|
|
272
|
+
on a normal fall-through).
|
|
273
|
+
|
|
274
|
+
Wires `host` live (#257): primes `_hooklib`'s process-cached Host via
|
|
275
|
+
`set_host()` BEFORE main() runs, so main()'s `get_host()` call resolves
|
|
276
|
+
to the SAME instance the caller passed here — no second
|
|
277
|
+
`hostapi.load_host()`, and `run(fake_host)` genuinely exercises
|
|
278
|
+
`fake_host`."""
|
|
279
|
+
return _entrylib.dispatch(host, argv, main, set_host,
|
|
280
|
+
pass_argv=False, propagate_result=False)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
sys.exit(run(hostapi.load_host()) or 0)
|