diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log
index f8a5e231..26dc84a6 100644
--- a/.codearbiter/gate-events.log
+++ b/.codearbiter/gate-events.log
@@ -1137,3 +1137,18 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages
[2026-07-30T11:31:49Z] BLOCK [H-18] host=claude hook=pre-bash.py | .codearbiter/CONTEXT.md is the activation switch every enforcement hook reads (#159) — shell rewrites, edits, or deletions that could flip `arbiter: enabled` off or corrupt its frontmatter are prohibited. Edit it through the sanctioned init path.
[2026-07-30T11:33:50Z] BLOCK [H-19] host=claude hook=pre-bash.py | The .codearbiter/.markers/ security-gate-passed / migration-gate-passed tokens are recorded only by the sanctioned gate producers (#160) — a shell redirect, write verb, or interpreter invocation (python/node/perl/ruby/sh) naming a gate marker forges a security/migration gate pass and is prohibited.
[2026-07-30T11:34:13Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5).
+[2026-07-30T12:21:41Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass.
+[2026-07-30T12:23:09Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5).
+[2026-07-30T12:23:44Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass.
+[2026-07-30T13:16:47Z] REMIND [H-09] host=codex hook=post-write-edit.py | Crypto/TLS pattern detected. Run the crypto-compliance check + dispatch auth-crypto-reviewer (no MD5/SHA1/DES/3DES/RC2/RC4/Blowfish; do not disable TLS verification). The commit will block until the gate records a pass.
+[2026-07-30T13:25:09Z] BLOCK [H-05] host=codex hook=pre-write.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) are append-only (ORCHESTRATOR §7). A patch cannot express a verifiable pure append; append with '>>' instead.
+[2026-07-30T13:26:09Z] BLOCK [H-05] host=codex hook=pre-write.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md) are append-only (ORCHESTRATOR §7). A patch cannot express a verifiable pure append; append with '>>' instead.
+[2026-07-30T13:27:06Z] BLOCK [H-03] host=codex hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill).
+[2026-07-30T13:49:16Z] BLOCK [H-01] host=codex hook=pre-bash.py | Direct commit to main is prohibited (ORCHESTRATOR §3). Create a feature branch.
+[2026-07-30T13:50:00Z] BLOCK [H-03] host=codex hook=pre-bash.py | 'git add -A' / 'git add .' / 'git add --all' / 'git add -u' are prohibited. Stage files explicitly (commit-gate skill).
+[2026-07-30T13:53:13Z] BLOCK [H-09b] host=codex hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path.
+[2026-07-30T14:06:09Z] REMIND [H-07] host=codex hook=post-write-edit.py | Dependency manifest changed. Dispatch dependency-reviewer before committing (ORCHESTRATOR §5).
+[2026-07-30T16:41:09Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log, decisions/decision-log.md) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only.
+[2026-07-30T16:41:40Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log, decisions/decision-log.md) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only.
+[2026-07-30T17:53:52Z] BLOCK [H-09b] host=codex hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path.
+[2026-07-30T17:57:02Z] BLOCK [H-09b] host=codex hook=pre-bash.py | This commit introduces crypto/TLS changes, but no security-gate pass is recorded (.codearbiter/.markers/security-gate-passed). Run the crypto-compliance gate (it records the pass), then commit. To bypass a security gate, /override requires its heavier security-acknowledgement path.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b0d3696..4eab8732 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,24 @@ predate the plugin rewrite and are grouped by date.
## [Unreleased]
+## [2.10.6] — 2026-07-30
+
+### Changed
+
+- **`_hooklib` sheds its protected-path classifiers (#321, slice 2 of 4).** The
+ hook core drops from 1,050 to 880 lines - 1,263 before the partition began.
+ `_protectedlib` now owns which repo paths are append-only audit logs (H-05),
+ ADR decision files (H-11), the activation manifest, and the gate-marker
+ directory. `repo_rel` joined `norm_path` on the `_pathnorm` floor, because it
+ references no module symbol and the remaining slices need it too.
+
+ Measured the same way slice 1 was: one outward reference, zero inward.
+ `_hooklib` re-exports every moved name, so all consumers are untouched and
+ parity rests on 1,185 pre-existing hook tests that did not move. The public
+ surface is preserved exactly - nothing lost, nothing gained.
+
+ No behaviour change.
+
## [2.10.5] — 2026-07-30
### Changed
diff --git a/README.md b/README.md
index f56a4c9e..dd7f4286 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Every intent routes through a gated skill or reviewer agent. Nothing commits unt
-
+
diff --git a/core/pysrc/_hooklib.py b/core/pysrc/_hooklib.py
index 91f7eee9..f4f74486 100644
--- a/core/pysrc/_hooklib.py
+++ b/core/pysrc/_hooklib.py
@@ -97,12 +97,32 @@
import hostapi
-# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, and
-# norm_path to the _pathnorm floor beneath it. Re-exported here, unchanged, so
+# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, the
+# protected-path classifiers to _protectedlib, and the path primitives to the
+# _pathnorm floor beneath both. Re-exported here, unchanged, so
# every one of the 59 consuming files keeps importing from _hooklib and the
# pre-existing suites prove parity without moving. New code SHOULD import from
# the owning module; this facade exists so the partition costs no caller a diff.
-from _pathnorm import norm_path # noqa: F401
+from _pathnorm import norm_path, repo_rel # noqa: F401
+from _protectedlib import ( # noqa: F401
+ AUDIT_LOG_BASENAMES,
+ AUDIT_LOG_FLAT_BASENAMES,
+ AUDIT_LOG_NAMES,
+ AUDIT_LOG_RE,
+ CONTEXT_MD_RE,
+ DECISIONS_DIR_RE,
+ DECISIONS_PATH_RE,
+ DECISION_LOG_BASENAME,
+ DECISION_LOG_RE,
+ GATE_MARKER_NAMES,
+ MARKERS_RE,
+ classify_protected,
+ is_audit_log,
+ is_context_md,
+ is_decisions_path,
+ is_marker_path,
+ is_tail_append,
+)
from _sensitivelib import ( # noqa: F401
CRYPTO_RE,
SECRET_RE,
@@ -220,180 +240,6 @@ def _reset_root_cache():
ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)
-# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
-# here (architecture-004) so the three pre-* hooks import ONE definition instead
-# of re-encoding the regex inline (the exact drift this module exists to
-# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
-# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
-#
-# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
-# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
-# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
-# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
-# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
-# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
-# flanks derive from one source.
-#
-# gate-events.log (observability-001, #186) joins this set: it is the durable,
-# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
-# an append-only audit artifact exactly like the other three, so it gets the
-# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
-# alternation, with no separate guard to maintain. Note this protects it only
-# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
-# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
-#
-# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
-# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
-# these as plain strings too (a cheap `n in cmd` substring pre-filter before
-# running the regexes below), so it imports this tuple directly instead of
-# re-deriving/hand-copying the name set (the exact drift this centralization
-# exists to prevent — a filter that silently skips a future audit log because
-# its literal name was never added to a second, hand-maintained copy).
-# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
-# is unchanged from the prior hand-written pattern (same four literal
-# filenames, same (?:...) grouping), only the source of truth moved.
-AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
-# #528: the SMARTS arbitration log is an append-only audit artifact that happens
-# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
-# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
-# to it, H-11 refused every write without the /adr authoring marker, and only
-# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
-# session made a decision it could not record. Its own format doc states H-05's
-# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
-# H-05 is the correct guard: append freely, never rewrite.
-#
-# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
-# directly under .codearbiter/ and this one is nested a level deeper. Both halves
-# of the reclassification are load-bearing: adding it here WITHOUT removing it
-# from the H-11 set below leaves the append blocked, because classify_protected
-# reports every class a path hits and pre-write checks them independently.
-DECISION_LOG_BASENAME = "decision-log.md"
-DECISION_LOG_RE = re.compile(
- r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
-)
-# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
-# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
-# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
-# log cannot silently skip the shell flank — adding the name only to the regex
-# alternation below would sail past that pre-filter and leave the log deletable
-# from the shell. (Caught by test_hook_guards.py, which the comment on that
-# pre-filter predicted verbatim.)
-AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
-AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
-# The path anchor stays scoped to the FLAT logs — those sit directly under
-# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
-# both patterns rather than loosening this one into matching any nesting.
-AUDIT_LOG_RE = re.compile(
- r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
-)
-DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
-DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
-
-# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
-# master switch every hook gates on via arbiter_active(); .markers/ holds the
-# gate-pass tokens (security-gate-passed, migration-gate-passed,
-# adr-authoring-active). Both were writable project state with no Write/Edit
-# guard — the token strings are centralized here beside the audit-log/decisions
-# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
-CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
-MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
-# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
-# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
-# legitimately shell-written (the sanctioned producers are the python
-# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
-# which /adr legitimately `touch`es.
-GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
-
-
-def is_audit_log(rel):
- """True iff `rel` is one of the append-only .codearbiter audit logs
- (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
- arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
- n = norm_path(rel)
- return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
-
-
-def is_tail_append(current, old, new):
- """True iff an Edit's (old_string, new_string) pair is a verifiable,
- TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
- content) — the H-05 guard (reliability-003, #172).
-
- `new.startswith(old)` alone is not sufficient: `old` could be any interior
- line that happens to be a prefix of `new`, which inserts content BETWEEN
- existing lines rather than appending at the end. This requires TWO things:
- `current` must literally END with `old` (old_string is the file's actual
- trailing content, not just some substring elsewhere), and `new` must
- extend `old`. An empty `old` is never a valid append — every string
- "ends with" the empty string, so the tail-anchor check would trivially
- pass and reopen the migration-003 empty-old_string hole this closes.
-
- `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
- that happens to also match the tail is not self-evidently an append — this
- keeps the guard correct on its own terms rather than depending on the Edit
- tool's own (client-side, not re-verified here) uniqueness enforcement for
- a non-replace_all Edit."""
- if not old:
- return False
- if current.count(old) != 1:
- return False
- return current.endswith(old) and new.startswith(old)
-
-
-def is_decisions_path(rel):
- """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
- the H-11 guard set (a non-numbered draft or a nested path still counts).
-
- decisions/decision-log.md is the ONE exception (#528): it is the append-only
- arbitration log, not immutable ADR history, and is governed by H-05 instead.
- The carve-out is exactly one path wide and anchored — `old-decision-log.md`
- and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
- cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
- NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
- n = norm_path(rel)
- if DECISION_LOG_RE.search(n):
- return False
- return bool(DECISIONS_PATH_RE.search(n))
-
-
-def is_context_md(rel):
- """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
- the master switch arbiter_active() reads. Guarded so it can't be flipped to
- `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
- return bool(CONTEXT_MD_RE.search(norm_path(rel)))
-
-
-def is_marker_path(rel):
- """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
- gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
- hand-written marker must not be admitted by the Write/Edit tools."""
- return bool(MARKERS_RE.search(norm_path(rel)))
-
-
-def classify_protected(fpath, root):
- """The set of protected classes a Write/Edit `fpath` targets, resolving
- symlinks (#162). Each classifier runs against BOTH the raw normalized path
- AND the realpath-resolved repo-relative form: a symlink alias whose visible
- path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
- can no longer launder a write past the guard. Centralized so pre-write.py and
- pre-edit.py apply the identical symlink-safe check to every class (H-05,
- H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
-
- Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
- a target outside the repo (which cannot be a `.codearbiter` path), so that
- flank is simply skipped."""
- hits = set()
- for p in (norm_path(fpath), repo_rel(fpath, root)):
- if not p:
- continue
- if is_audit_log(p):
- hits.add("audit")
- if is_decisions_path(p):
- hits.add("decisions")
- if is_context_md(p):
- hits.add("context")
- if is_marker_path(p):
- hits.add("marker")
- return hits
@@ -524,22 +370,6 @@ def project_root(payload=None):
return _ROOT_CACHE[key]
-def repo_rel(fpath, root):
- """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
-
- realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
- (project_root) canonicalizes symlinks and 8.3 short names, but the
- `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
- `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
- same repo via divergent forms. A purely lexical relpath on those forms
- yields a bogus `..`-prefixed path, which silently suppressed every
- path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
- Windows runners while ubuntu passed)."""
- if not fpath:
- return ""
- rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
- rel = rel.replace(os.sep, "/")
- return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/core/pysrc/_pathnorm.py b/core/pysrc/_pathnorm.py
index d763b550..c63dc6ae 100644
--- a/core/pysrc/_pathnorm.py
+++ b/core/pysrc/_pathnorm.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# codeArbiter - repo-relative path normalization. The dependency FLOOR of the
+# codeArbiter - repo-relative path primitives. The dependency FLOOR of the
# guard-classifier modules (issue #321).
#
# Two lines of code in its own module on purpose. Every path classifier -
@@ -11,8 +11,34 @@
#
# So it goes BELOW all of them. Nothing here may import from a sibling core
# module; that is what makes it a floor rather than another god module.
+#
+# `repo_rel` joined in slice 2 on the same test: it references no other module
+# symbol, and both the protected-path classifiers and the activation helpers
+# need it. Its docstring is the canonical statement of why a LEXICAL relpath is
+# wrong here - the 8.3 / symlink divergence that also produced #539 and #541.
+
+
+import os
def norm_path(p):
"""Normalize separators so guard regexes match Windows backslash paths."""
return (p or "").replace("\\", "/")
+
+
+def repo_rel(fpath, root):
+ """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
+
+ realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
+ (project_root) canonicalizes symlinks and 8.3 short names, but the
+ `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
+ `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
+ same repo via divergent forms. A purely lexical relpath on those forms
+ yields a bogus `..`-prefixed path, which silently suppressed every
+ path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
+ Windows runners while ubuntu passed)."""
+ if not fpath:
+ return ""
+ rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
+ rel = rel.replace(os.sep, "/")
+ return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/core/pysrc/_protectedlib.py b/core/pysrc/_protectedlib.py
new file mode 100644
index 00000000..d71d770a
--- /dev/null
+++ b/core/pysrc/_protectedlib.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# codeArbiter - the protected-path classifiers: which repo paths are append-only
+# audit logs (H-05), ADR decision files (H-11), the activation manifest, and the
+# gate-marker directory.
+#
+# Extracted from _hooklib (issue #321, architecture-002) as slice 2. Measured the
+# same way slice 1 was: the cluster referenced exactly ONE symbol from the rest
+# of _hooklib (repo_rel, which moved to the _pathnorm floor because it
+# references no module symbol at all and the remaining slices need it too), and
+# NOTHING in the rest of _hooklib referenced the cluster. A one-way edge with no
+# back-reference is what makes a slice safe to move without touching a consumer.
+#
+# WHY THESE BELONG TOGETHER: they answer one question - "what class of protected
+# thing is this path?" - and the guards branch on the answer as a set.
+# classify_protected returns EVERY class a path hits, which is load-bearing:
+# #528/#529 showed that adding decision-log.md to the audit set while leaving it
+# in the decisions set still blocked, because pre-write.py checks the classes
+# independently. Splitting these across modules would let one classification
+# move without the others and reopen exactly that.
+#
+# _hooklib re-exports every name below, so no consumer changed and the
+# pre-existing hook suites prove parity without moving.
+
+from __future__ import annotations
+
+import re
+
+from _pathnorm import norm_path, repo_rel
+
+
+# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
+# here (architecture-004) so the three pre-* hooks import ONE definition instead
+# of re-encoding the regex inline (the exact drift this module exists to
+# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
+# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
+#
+# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
+# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
+# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
+# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
+# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
+# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
+# flanks derive from one source.
+#
+# gate-events.log (observability-001, #186) joins this set: it is the durable,
+# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
+# an append-only audit artifact exactly like the other three, so it gets the
+# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
+# alternation, with no separate guard to maintain. Note this protects it only
+# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
+# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
+#
+# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
+# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
+# these as plain strings too (a cheap `n in cmd` substring pre-filter before
+# running the regexes below), so it imports this tuple directly instead of
+# re-deriving/hand-copying the name set (the exact drift this centralization
+# exists to prevent — a filter that silently skips a future audit log because
+# its literal name was never added to a second, hand-maintained copy).
+# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
+# is unchanged from the prior hand-written pattern (same four literal
+# filenames, same (?:...) grouping), only the source of truth moved.
+AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
+# #528: the SMARTS arbitration log is an append-only audit artifact that happens
+# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
+# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
+# to it, H-11 refused every write without the /adr authoring marker, and only
+# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
+# session made a decision it could not record. Its own format doc states H-05's
+# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
+# H-05 is the correct guard: append freely, never rewrite.
+#
+# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
+# directly under .codearbiter/ and this one is nested a level deeper. Both halves
+# of the reclassification are load-bearing: adding it here WITHOUT removing it
+# from the H-11 set below leaves the append blocked, because classify_protected
+# reports every class a path hits and pre-write checks them independently.
+DECISION_LOG_BASENAME = "decision-log.md"
+DECISION_LOG_RE = re.compile(
+ r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
+)
+# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
+# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
+# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
+# log cannot silently skip the shell flank — adding the name only to the regex
+# alternation below would sail past that pre-filter and leave the log deletable
+# from the shell. (Caught by test_hook_guards.py, which the comment on that
+# pre-filter predicted verbatim.)
+AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
+AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
+# The path anchor stays scoped to the FLAT logs — those sit directly under
+# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
+# both patterns rather than loosening this one into matching any nesting.
+AUDIT_LOG_RE = re.compile(
+ r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
+)
+DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
+DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
+
+# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
+# master switch every hook gates on via arbiter_active(); .markers/ holds the
+# gate-pass tokens (security-gate-passed, migration-gate-passed,
+# adr-authoring-active). Both were writable project state with no Write/Edit
+# guard — the token strings are centralized here beside the audit-log/decisions
+# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
+CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
+MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
+# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
+# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
+# legitimately shell-written (the sanctioned producers are the python
+# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
+# which /adr legitimately `touch`es.
+GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
+
+
+def is_audit_log(rel):
+ """True iff `rel` is one of the append-only .codearbiter audit logs
+ (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
+ arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
+ n = norm_path(rel)
+ return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
+
+
+def is_tail_append(current, old, new):
+ """True iff an Edit's (old_string, new_string) pair is a verifiable,
+ TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
+ content) — the H-05 guard (reliability-003, #172).
+
+ `new.startswith(old)` alone is not sufficient: `old` could be any interior
+ line that happens to be a prefix of `new`, which inserts content BETWEEN
+ existing lines rather than appending at the end. This requires TWO things:
+ `current` must literally END with `old` (old_string is the file's actual
+ trailing content, not just some substring elsewhere), and `new` must
+ extend `old`. An empty `old` is never a valid append — every string
+ "ends with" the empty string, so the tail-anchor check would trivially
+ pass and reopen the migration-003 empty-old_string hole this closes.
+
+ `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
+ that happens to also match the tail is not self-evidently an append — this
+ keeps the guard correct on its own terms rather than depending on the Edit
+ tool's own (client-side, not re-verified here) uniqueness enforcement for
+ a non-replace_all Edit."""
+ if not old:
+ return False
+ if current.count(old) != 1:
+ return False
+ return current.endswith(old) and new.startswith(old)
+
+
+def is_decisions_path(rel):
+ """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
+ the H-11 guard set (a non-numbered draft or a nested path still counts).
+
+ decisions/decision-log.md is the ONE exception (#528): it is the append-only
+ arbitration log, not immutable ADR history, and is governed by H-05 instead.
+ The carve-out is exactly one path wide and anchored — `old-decision-log.md`
+ and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
+ cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
+ NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
+ n = norm_path(rel)
+ if DECISION_LOG_RE.search(n):
+ return False
+ return bool(DECISIONS_PATH_RE.search(n))
+
+
+def is_context_md(rel):
+ """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
+ the master switch arbiter_active() reads. Guarded so it can't be flipped to
+ `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
+ return bool(CONTEXT_MD_RE.search(norm_path(rel)))
+
+
+def is_marker_path(rel):
+ """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
+ gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
+ hand-written marker must not be admitted by the Write/Edit tools."""
+ return bool(MARKERS_RE.search(norm_path(rel)))
+
+
+def classify_protected(fpath, root):
+ """The set of protected classes a Write/Edit `fpath` targets, resolving
+ symlinks (#162). Each classifier runs against BOTH the raw normalized path
+ AND the realpath-resolved repo-relative form: a symlink alias whose visible
+ path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
+ can no longer launder a write past the guard. Centralized so pre-write.py and
+ pre-edit.py apply the identical symlink-safe check to every class (H-05,
+ H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
+
+ Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
+ a target outside the repo (which cannot be a `.codearbiter` path), so that
+ flank is simply skipped."""
+ hits = set()
+ for p in (norm_path(fpath), repo_rel(fpath, root)):
+ if not p:
+ continue
+ if is_audit_log(p):
+ hits.add("audit")
+ if is_decisions_path(p):
+ hits.add("decisions")
+ if is_context_md(p):
+ hits.add("context")
+ if is_marker_path(p):
+ hits.add("marker")
+ return hits
diff --git a/package.json b/package.json
index cbc91e51..794364a5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
- "version": "0.1.40",
+ "version": "0.1.41",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
diff --git a/plugins/ca-codex/.codex-plugin/plugin.json b/plugins/ca-codex/.codex-plugin/plugin.json
index e4ff6553..7c601631 100644
--- a/plugins/ca-codex/.codex-plugin/plugin.json
+++ b/plugins/ca-codex/.codex-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ca-codex",
"description": "Governance kernel for OpenAI Codex CLI: the full codeArbiter surface — 37 ca-prefixed governance skills (spec-driven /feature pipeline, nine-gate commit gate, ADRs, audits) plus enforcement hooks (persona injection, blocking pre-exec and pre-write gates, append-only audit trail) — sharing one .codearbiter/ store with the Claude Code sibling plugin. Standalone: opt a repo in with ca-init; enforcement stays dormant until .codearbiter/CONTEXT.md carries 'arbiter: enabled'. Requires Python 3 and Codex >= 0.143.0. CI continuously verifies, through a real Codex host at 0.143.0 and 0.145.0, that the plugin installs, reads back enabled, and ships every hook script it declares; an advisory lane tracks npm latest for upstream drift. Hook FIRING - live persona injection and live blocks inside a turn - is verified by hand per release against docs/codex-parity-testing.md, because a turn needs a model and a provider credential cannot gate fork pull requests.",
- "version": "0.3.4",
+ "version": "0.3.5",
"author": {
"name": "arbiterForge"
},
diff --git a/plugins/ca-codex/CHANGELOG.md b/plugins/ca-codex/CHANGELOG.md
index 0a6ffc15..59bcae72 100644
--- a/plugins/ca-codex/CHANGELOG.md
+++ b/plugins/ca-codex/CHANGELOG.md
@@ -10,6 +10,26 @@ All notable changes to the **ca-codex** plugin are recorded here. Format follows
## [Unreleased]
+## [Unreleased]
+
+## [0.3.5] — 2026-07-30
+
+### Changed
+
+- **`_hooklib` sheds its protected-path classifiers (#321, slice 2 of 4).** The
+ hook core drops from 1,050 to 880 lines - 1,263 before the partition began.
+ `_protectedlib` now owns which repo paths are append-only audit logs (H-05),
+ ADR decision files (H-11), the activation manifest, and the gate-marker
+ directory. `repo_rel` joined `norm_path` on the `_pathnorm` floor, because it
+ references no module symbol and the remaining slices need it too.
+
+ Measured the same way slice 1 was: one outward reference, zero inward.
+ `_hooklib` re-exports every moved name, so all consumers are untouched and
+ parity rests on 1,185 pre-existing hook tests that did not move. The public
+ surface is preserved exactly - nothing lost, nothing gained.
+
+ No behaviour change.
+
## [0.3.4] — 2026-07-30
### Changed
diff --git a/plugins/ca-codex/hooks/_hooklib.py b/plugins/ca-codex/hooks/_hooklib.py
index 91f7eee9..f4f74486 100644
--- a/plugins/ca-codex/hooks/_hooklib.py
+++ b/plugins/ca-codex/hooks/_hooklib.py
@@ -97,12 +97,32 @@
import hostapi
-# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, and
-# norm_path to the _pathnorm floor beneath it. Re-exported here, unchanged, so
+# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, the
+# protected-path classifiers to _protectedlib, and the path primitives to the
+# _pathnorm floor beneath both. Re-exported here, unchanged, so
# every one of the 59 consuming files keeps importing from _hooklib and the
# pre-existing suites prove parity without moving. New code SHOULD import from
# the owning module; this facade exists so the partition costs no caller a diff.
-from _pathnorm import norm_path # noqa: F401
+from _pathnorm import norm_path, repo_rel # noqa: F401
+from _protectedlib import ( # noqa: F401
+ AUDIT_LOG_BASENAMES,
+ AUDIT_LOG_FLAT_BASENAMES,
+ AUDIT_LOG_NAMES,
+ AUDIT_LOG_RE,
+ CONTEXT_MD_RE,
+ DECISIONS_DIR_RE,
+ DECISIONS_PATH_RE,
+ DECISION_LOG_BASENAME,
+ DECISION_LOG_RE,
+ GATE_MARKER_NAMES,
+ MARKERS_RE,
+ classify_protected,
+ is_audit_log,
+ is_context_md,
+ is_decisions_path,
+ is_marker_path,
+ is_tail_append,
+)
from _sensitivelib import ( # noqa: F401
CRYPTO_RE,
SECRET_RE,
@@ -220,180 +240,6 @@ def _reset_root_cache():
ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)
-# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
-# here (architecture-004) so the three pre-* hooks import ONE definition instead
-# of re-encoding the regex inline (the exact drift this module exists to
-# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
-# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
-#
-# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
-# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
-# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
-# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
-# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
-# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
-# flanks derive from one source.
-#
-# gate-events.log (observability-001, #186) joins this set: it is the durable,
-# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
-# an append-only audit artifact exactly like the other three, so it gets the
-# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
-# alternation, with no separate guard to maintain. Note this protects it only
-# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
-# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
-#
-# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
-# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
-# these as plain strings too (a cheap `n in cmd` substring pre-filter before
-# running the regexes below), so it imports this tuple directly instead of
-# re-deriving/hand-copying the name set (the exact drift this centralization
-# exists to prevent — a filter that silently skips a future audit log because
-# its literal name was never added to a second, hand-maintained copy).
-# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
-# is unchanged from the prior hand-written pattern (same four literal
-# filenames, same (?:...) grouping), only the source of truth moved.
-AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
-# #528: the SMARTS arbitration log is an append-only audit artifact that happens
-# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
-# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
-# to it, H-11 refused every write without the /adr authoring marker, and only
-# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
-# session made a decision it could not record. Its own format doc states H-05's
-# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
-# H-05 is the correct guard: append freely, never rewrite.
-#
-# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
-# directly under .codearbiter/ and this one is nested a level deeper. Both halves
-# of the reclassification are load-bearing: adding it here WITHOUT removing it
-# from the H-11 set below leaves the append blocked, because classify_protected
-# reports every class a path hits and pre-write checks them independently.
-DECISION_LOG_BASENAME = "decision-log.md"
-DECISION_LOG_RE = re.compile(
- r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
-)
-# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
-# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
-# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
-# log cannot silently skip the shell flank — adding the name only to the regex
-# alternation below would sail past that pre-filter and leave the log deletable
-# from the shell. (Caught by test_hook_guards.py, which the comment on that
-# pre-filter predicted verbatim.)
-AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
-AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
-# The path anchor stays scoped to the FLAT logs — those sit directly under
-# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
-# both patterns rather than loosening this one into matching any nesting.
-AUDIT_LOG_RE = re.compile(
- r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
-)
-DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
-DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
-
-# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
-# master switch every hook gates on via arbiter_active(); .markers/ holds the
-# gate-pass tokens (security-gate-passed, migration-gate-passed,
-# adr-authoring-active). Both were writable project state with no Write/Edit
-# guard — the token strings are centralized here beside the audit-log/decisions
-# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
-CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
-MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
-# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
-# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
-# legitimately shell-written (the sanctioned producers are the python
-# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
-# which /adr legitimately `touch`es.
-GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
-
-
-def is_audit_log(rel):
- """True iff `rel` is one of the append-only .codearbiter audit logs
- (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
- arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
- n = norm_path(rel)
- return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
-
-
-def is_tail_append(current, old, new):
- """True iff an Edit's (old_string, new_string) pair is a verifiable,
- TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
- content) — the H-05 guard (reliability-003, #172).
-
- `new.startswith(old)` alone is not sufficient: `old` could be any interior
- line that happens to be a prefix of `new`, which inserts content BETWEEN
- existing lines rather than appending at the end. This requires TWO things:
- `current` must literally END with `old` (old_string is the file's actual
- trailing content, not just some substring elsewhere), and `new` must
- extend `old`. An empty `old` is never a valid append — every string
- "ends with" the empty string, so the tail-anchor check would trivially
- pass and reopen the migration-003 empty-old_string hole this closes.
-
- `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
- that happens to also match the tail is not self-evidently an append — this
- keeps the guard correct on its own terms rather than depending on the Edit
- tool's own (client-side, not re-verified here) uniqueness enforcement for
- a non-replace_all Edit."""
- if not old:
- return False
- if current.count(old) != 1:
- return False
- return current.endswith(old) and new.startswith(old)
-
-
-def is_decisions_path(rel):
- """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
- the H-11 guard set (a non-numbered draft or a nested path still counts).
-
- decisions/decision-log.md is the ONE exception (#528): it is the append-only
- arbitration log, not immutable ADR history, and is governed by H-05 instead.
- The carve-out is exactly one path wide and anchored — `old-decision-log.md`
- and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
- cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
- NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
- n = norm_path(rel)
- if DECISION_LOG_RE.search(n):
- return False
- return bool(DECISIONS_PATH_RE.search(n))
-
-
-def is_context_md(rel):
- """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
- the master switch arbiter_active() reads. Guarded so it can't be flipped to
- `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
- return bool(CONTEXT_MD_RE.search(norm_path(rel)))
-
-
-def is_marker_path(rel):
- """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
- gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
- hand-written marker must not be admitted by the Write/Edit tools."""
- return bool(MARKERS_RE.search(norm_path(rel)))
-
-
-def classify_protected(fpath, root):
- """The set of protected classes a Write/Edit `fpath` targets, resolving
- symlinks (#162). Each classifier runs against BOTH the raw normalized path
- AND the realpath-resolved repo-relative form: a symlink alias whose visible
- path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
- can no longer launder a write past the guard. Centralized so pre-write.py and
- pre-edit.py apply the identical symlink-safe check to every class (H-05,
- H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
-
- Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
- a target outside the repo (which cannot be a `.codearbiter` path), so that
- flank is simply skipped."""
- hits = set()
- for p in (norm_path(fpath), repo_rel(fpath, root)):
- if not p:
- continue
- if is_audit_log(p):
- hits.add("audit")
- if is_decisions_path(p):
- hits.add("decisions")
- if is_context_md(p):
- hits.add("context")
- if is_marker_path(p):
- hits.add("marker")
- return hits
@@ -524,22 +370,6 @@ def project_root(payload=None):
return _ROOT_CACHE[key]
-def repo_rel(fpath, root):
- """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
-
- realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
- (project_root) canonicalizes symlinks and 8.3 short names, but the
- `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
- `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
- same repo via divergent forms. A purely lexical relpath on those forms
- yields a bogus `..`-prefixed path, which silently suppressed every
- path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
- Windows runners while ubuntu passed)."""
- if not fpath:
- return ""
- rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
- rel = rel.replace(os.sep, "/")
- return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/plugins/ca-codex/hooks/_pathnorm.py b/plugins/ca-codex/hooks/_pathnorm.py
index d763b550..c63dc6ae 100644
--- a/plugins/ca-codex/hooks/_pathnorm.py
+++ b/plugins/ca-codex/hooks/_pathnorm.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# codeArbiter - repo-relative path normalization. The dependency FLOOR of the
+# codeArbiter - repo-relative path primitives. The dependency FLOOR of the
# guard-classifier modules (issue #321).
#
# Two lines of code in its own module on purpose. Every path classifier -
@@ -11,8 +11,34 @@
#
# So it goes BELOW all of them. Nothing here may import from a sibling core
# module; that is what makes it a floor rather than another god module.
+#
+# `repo_rel` joined in slice 2 on the same test: it references no other module
+# symbol, and both the protected-path classifiers and the activation helpers
+# need it. Its docstring is the canonical statement of why a LEXICAL relpath is
+# wrong here - the 8.3 / symlink divergence that also produced #539 and #541.
+
+
+import os
def norm_path(p):
"""Normalize separators so guard regexes match Windows backslash paths."""
return (p or "").replace("\\", "/")
+
+
+def repo_rel(fpath, root):
+ """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
+
+ realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
+ (project_root) canonicalizes symlinks and 8.3 short names, but the
+ `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
+ `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
+ same repo via divergent forms. A purely lexical relpath on those forms
+ yields a bogus `..`-prefixed path, which silently suppressed every
+ path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
+ Windows runners while ubuntu passed)."""
+ if not fpath:
+ return ""
+ rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
+ rel = rel.replace(os.sep, "/")
+ return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/plugins/ca-codex/hooks/_protectedlib.py b/plugins/ca-codex/hooks/_protectedlib.py
new file mode 100644
index 00000000..d71d770a
--- /dev/null
+++ b/plugins/ca-codex/hooks/_protectedlib.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# codeArbiter - the protected-path classifiers: which repo paths are append-only
+# audit logs (H-05), ADR decision files (H-11), the activation manifest, and the
+# gate-marker directory.
+#
+# Extracted from _hooklib (issue #321, architecture-002) as slice 2. Measured the
+# same way slice 1 was: the cluster referenced exactly ONE symbol from the rest
+# of _hooklib (repo_rel, which moved to the _pathnorm floor because it
+# references no module symbol at all and the remaining slices need it too), and
+# NOTHING in the rest of _hooklib referenced the cluster. A one-way edge with no
+# back-reference is what makes a slice safe to move without touching a consumer.
+#
+# WHY THESE BELONG TOGETHER: they answer one question - "what class of protected
+# thing is this path?" - and the guards branch on the answer as a set.
+# classify_protected returns EVERY class a path hits, which is load-bearing:
+# #528/#529 showed that adding decision-log.md to the audit set while leaving it
+# in the decisions set still blocked, because pre-write.py checks the classes
+# independently. Splitting these across modules would let one classification
+# move without the others and reopen exactly that.
+#
+# _hooklib re-exports every name below, so no consumer changed and the
+# pre-existing hook suites prove parity without moving.
+
+from __future__ import annotations
+
+import re
+
+from _pathnorm import norm_path, repo_rel
+
+
+# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
+# here (architecture-004) so the three pre-* hooks import ONE definition instead
+# of re-encoding the regex inline (the exact drift this module exists to
+# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
+# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
+#
+# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
+# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
+# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
+# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
+# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
+# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
+# flanks derive from one source.
+#
+# gate-events.log (observability-001, #186) joins this set: it is the durable,
+# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
+# an append-only audit artifact exactly like the other three, so it gets the
+# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
+# alternation, with no separate guard to maintain. Note this protects it only
+# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
+# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
+#
+# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
+# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
+# these as plain strings too (a cheap `n in cmd` substring pre-filter before
+# running the regexes below), so it imports this tuple directly instead of
+# re-deriving/hand-copying the name set (the exact drift this centralization
+# exists to prevent — a filter that silently skips a future audit log because
+# its literal name was never added to a second, hand-maintained copy).
+# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
+# is unchanged from the prior hand-written pattern (same four literal
+# filenames, same (?:...) grouping), only the source of truth moved.
+AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
+# #528: the SMARTS arbitration log is an append-only audit artifact that happens
+# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
+# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
+# to it, H-11 refused every write without the /adr authoring marker, and only
+# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
+# session made a decision it could not record. Its own format doc states H-05's
+# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
+# H-05 is the correct guard: append freely, never rewrite.
+#
+# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
+# directly under .codearbiter/ and this one is nested a level deeper. Both halves
+# of the reclassification are load-bearing: adding it here WITHOUT removing it
+# from the H-11 set below leaves the append blocked, because classify_protected
+# reports every class a path hits and pre-write checks them independently.
+DECISION_LOG_BASENAME = "decision-log.md"
+DECISION_LOG_RE = re.compile(
+ r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
+)
+# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
+# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
+# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
+# log cannot silently skip the shell flank — adding the name only to the regex
+# alternation below would sail past that pre-filter and leave the log deletable
+# from the shell. (Caught by test_hook_guards.py, which the comment on that
+# pre-filter predicted verbatim.)
+AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
+AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
+# The path anchor stays scoped to the FLAT logs — those sit directly under
+# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
+# both patterns rather than loosening this one into matching any nesting.
+AUDIT_LOG_RE = re.compile(
+ r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
+)
+DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
+DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
+
+# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
+# master switch every hook gates on via arbiter_active(); .markers/ holds the
+# gate-pass tokens (security-gate-passed, migration-gate-passed,
+# adr-authoring-active). Both were writable project state with no Write/Edit
+# guard — the token strings are centralized here beside the audit-log/decisions
+# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
+CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
+MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
+# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
+# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
+# legitimately shell-written (the sanctioned producers are the python
+# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
+# which /adr legitimately `touch`es.
+GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
+
+
+def is_audit_log(rel):
+ """True iff `rel` is one of the append-only .codearbiter audit logs
+ (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
+ arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
+ n = norm_path(rel)
+ return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
+
+
+def is_tail_append(current, old, new):
+ """True iff an Edit's (old_string, new_string) pair is a verifiable,
+ TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
+ content) — the H-05 guard (reliability-003, #172).
+
+ `new.startswith(old)` alone is not sufficient: `old` could be any interior
+ line that happens to be a prefix of `new`, which inserts content BETWEEN
+ existing lines rather than appending at the end. This requires TWO things:
+ `current` must literally END with `old` (old_string is the file's actual
+ trailing content, not just some substring elsewhere), and `new` must
+ extend `old`. An empty `old` is never a valid append — every string
+ "ends with" the empty string, so the tail-anchor check would trivially
+ pass and reopen the migration-003 empty-old_string hole this closes.
+
+ `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
+ that happens to also match the tail is not self-evidently an append — this
+ keeps the guard correct on its own terms rather than depending on the Edit
+ tool's own (client-side, not re-verified here) uniqueness enforcement for
+ a non-replace_all Edit."""
+ if not old:
+ return False
+ if current.count(old) != 1:
+ return False
+ return current.endswith(old) and new.startswith(old)
+
+
+def is_decisions_path(rel):
+ """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
+ the H-11 guard set (a non-numbered draft or a nested path still counts).
+
+ decisions/decision-log.md is the ONE exception (#528): it is the append-only
+ arbitration log, not immutable ADR history, and is governed by H-05 instead.
+ The carve-out is exactly one path wide and anchored — `old-decision-log.md`
+ and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
+ cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
+ NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
+ n = norm_path(rel)
+ if DECISION_LOG_RE.search(n):
+ return False
+ return bool(DECISIONS_PATH_RE.search(n))
+
+
+def is_context_md(rel):
+ """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
+ the master switch arbiter_active() reads. Guarded so it can't be flipped to
+ `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
+ return bool(CONTEXT_MD_RE.search(norm_path(rel)))
+
+
+def is_marker_path(rel):
+ """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
+ gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
+ hand-written marker must not be admitted by the Write/Edit tools."""
+ return bool(MARKERS_RE.search(norm_path(rel)))
+
+
+def classify_protected(fpath, root):
+ """The set of protected classes a Write/Edit `fpath` targets, resolving
+ symlinks (#162). Each classifier runs against BOTH the raw normalized path
+ AND the realpath-resolved repo-relative form: a symlink alias whose visible
+ path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
+ can no longer launder a write past the guard. Centralized so pre-write.py and
+ pre-edit.py apply the identical symlink-safe check to every class (H-05,
+ H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
+
+ Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
+ a target outside the repo (which cannot be a `.codearbiter` path), so that
+ flank is simply skipped."""
+ hits = set()
+ for p in (norm_path(fpath), repo_rel(fpath, root)):
+ if not p:
+ continue
+ if is_audit_log(p):
+ hits.add("audit")
+ if is_decisions_path(p):
+ hits.add("decisions")
+ if is_context_md(p):
+ hits.add("context")
+ if is_marker_path(p):
+ hits.add("marker")
+ return hits
diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md
index e198b405..72ec01f3 100644
--- a/plugins/ca-pi/CHANGELOG.md
+++ b/plugins/ca-pi/CHANGELOG.md
@@ -4,6 +4,26 @@ All notable changes to `ca-pi` are documented in this file.
## [Unreleased]
+## [Unreleased]
+
+## [0.1.41] - 2026-07-30
+
+### Changed
+
+- **`_hooklib` sheds its protected-path classifiers (#321, slice 2 of 4).** The
+ hook core drops from 1,050 to 880 lines - 1,263 before the partition began.
+ `_protectedlib` now owns which repo paths are append-only audit logs (H-05),
+ ADR decision files (H-11), the activation manifest, and the gate-marker
+ directory. `repo_rel` joined `norm_path` on the `_pathnorm` floor, because it
+ references no module symbol and the remaining slices need it too.
+
+ Measured the same way slice 1 was: one outward reference, zero inward.
+ `_hooklib` re-exports every moved name, so all consumers are untouched and
+ parity rests on 1,185 pre-existing hook tests that did not move. The public
+ surface is preserved exactly - nothing lost, nothing gained.
+
+ No behaviour change.
+
## [0.1.40] - 2026-07-30
### Changed
diff --git a/plugins/ca-pi/hooks/_hooklib.py b/plugins/ca-pi/hooks/_hooklib.py
index 91f7eee9..f4f74486 100644
--- a/plugins/ca-pi/hooks/_hooklib.py
+++ b/plugins/ca-pi/hooks/_hooklib.py
@@ -97,12 +97,32 @@
import hostapi
-# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, and
-# norm_path to the _pathnorm floor beneath it. Re-exported here, unchanged, so
+# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, the
+# protected-path classifiers to _protectedlib, and the path primitives to the
+# _pathnorm floor beneath both. Re-exported here, unchanged, so
# every one of the 59 consuming files keeps importing from _hooklib and the
# pre-existing suites prove parity without moving. New code SHOULD import from
# the owning module; this facade exists so the partition costs no caller a diff.
-from _pathnorm import norm_path # noqa: F401
+from _pathnorm import norm_path, repo_rel # noqa: F401
+from _protectedlib import ( # noqa: F401
+ AUDIT_LOG_BASENAMES,
+ AUDIT_LOG_FLAT_BASENAMES,
+ AUDIT_LOG_NAMES,
+ AUDIT_LOG_RE,
+ CONTEXT_MD_RE,
+ DECISIONS_DIR_RE,
+ DECISIONS_PATH_RE,
+ DECISION_LOG_BASENAME,
+ DECISION_LOG_RE,
+ GATE_MARKER_NAMES,
+ MARKERS_RE,
+ classify_protected,
+ is_audit_log,
+ is_context_md,
+ is_decisions_path,
+ is_marker_path,
+ is_tail_append,
+)
from _sensitivelib import ( # noqa: F401
CRYPTO_RE,
SECRET_RE,
@@ -220,180 +240,6 @@ def _reset_root_cache():
ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)
-# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
-# here (architecture-004) so the three pre-* hooks import ONE definition instead
-# of re-encoding the regex inline (the exact drift this module exists to
-# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
-# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
-#
-# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
-# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
-# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
-# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
-# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
-# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
-# flanks derive from one source.
-#
-# gate-events.log (observability-001, #186) joins this set: it is the durable,
-# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
-# an append-only audit artifact exactly like the other three, so it gets the
-# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
-# alternation, with no separate guard to maintain. Note this protects it only
-# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
-# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
-#
-# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
-# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
-# these as plain strings too (a cheap `n in cmd` substring pre-filter before
-# running the regexes below), so it imports this tuple directly instead of
-# re-deriving/hand-copying the name set (the exact drift this centralization
-# exists to prevent — a filter that silently skips a future audit log because
-# its literal name was never added to a second, hand-maintained copy).
-# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
-# is unchanged from the prior hand-written pattern (same four literal
-# filenames, same (?:...) grouping), only the source of truth moved.
-AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
-# #528: the SMARTS arbitration log is an append-only audit artifact that happens
-# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
-# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
-# to it, H-11 refused every write without the /adr authoring marker, and only
-# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
-# session made a decision it could not record. Its own format doc states H-05's
-# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
-# H-05 is the correct guard: append freely, never rewrite.
-#
-# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
-# directly under .codearbiter/ and this one is nested a level deeper. Both halves
-# of the reclassification are load-bearing: adding it here WITHOUT removing it
-# from the H-11 set below leaves the append blocked, because classify_protected
-# reports every class a path hits and pre-write checks them independently.
-DECISION_LOG_BASENAME = "decision-log.md"
-DECISION_LOG_RE = re.compile(
- r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
-)
-# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
-# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
-# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
-# log cannot silently skip the shell flank — adding the name only to the regex
-# alternation below would sail past that pre-filter and leave the log deletable
-# from the shell. (Caught by test_hook_guards.py, which the comment on that
-# pre-filter predicted verbatim.)
-AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
-AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
-# The path anchor stays scoped to the FLAT logs — those sit directly under
-# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
-# both patterns rather than loosening this one into matching any nesting.
-AUDIT_LOG_RE = re.compile(
- r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
-)
-DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
-DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
-
-# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
-# master switch every hook gates on via arbiter_active(); .markers/ holds the
-# gate-pass tokens (security-gate-passed, migration-gate-passed,
-# adr-authoring-active). Both were writable project state with no Write/Edit
-# guard — the token strings are centralized here beside the audit-log/decisions
-# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
-CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
-MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
-# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
-# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
-# legitimately shell-written (the sanctioned producers are the python
-# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
-# which /adr legitimately `touch`es.
-GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
-
-
-def is_audit_log(rel):
- """True iff `rel` is one of the append-only .codearbiter audit logs
- (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
- arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
- n = norm_path(rel)
- return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
-
-
-def is_tail_append(current, old, new):
- """True iff an Edit's (old_string, new_string) pair is a verifiable,
- TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
- content) — the H-05 guard (reliability-003, #172).
-
- `new.startswith(old)` alone is not sufficient: `old` could be any interior
- line that happens to be a prefix of `new`, which inserts content BETWEEN
- existing lines rather than appending at the end. This requires TWO things:
- `current` must literally END with `old` (old_string is the file's actual
- trailing content, not just some substring elsewhere), and `new` must
- extend `old`. An empty `old` is never a valid append — every string
- "ends with" the empty string, so the tail-anchor check would trivially
- pass and reopen the migration-003 empty-old_string hole this closes.
-
- `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
- that happens to also match the tail is not self-evidently an append — this
- keeps the guard correct on its own terms rather than depending on the Edit
- tool's own (client-side, not re-verified here) uniqueness enforcement for
- a non-replace_all Edit."""
- if not old:
- return False
- if current.count(old) != 1:
- return False
- return current.endswith(old) and new.startswith(old)
-
-
-def is_decisions_path(rel):
- """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
- the H-11 guard set (a non-numbered draft or a nested path still counts).
-
- decisions/decision-log.md is the ONE exception (#528): it is the append-only
- arbitration log, not immutable ADR history, and is governed by H-05 instead.
- The carve-out is exactly one path wide and anchored — `old-decision-log.md`
- and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
- cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
- NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
- n = norm_path(rel)
- if DECISION_LOG_RE.search(n):
- return False
- return bool(DECISIONS_PATH_RE.search(n))
-
-
-def is_context_md(rel):
- """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
- the master switch arbiter_active() reads. Guarded so it can't be flipped to
- `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
- return bool(CONTEXT_MD_RE.search(norm_path(rel)))
-
-
-def is_marker_path(rel):
- """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
- gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
- hand-written marker must not be admitted by the Write/Edit tools."""
- return bool(MARKERS_RE.search(norm_path(rel)))
-
-
-def classify_protected(fpath, root):
- """The set of protected classes a Write/Edit `fpath` targets, resolving
- symlinks (#162). Each classifier runs against BOTH the raw normalized path
- AND the realpath-resolved repo-relative form: a symlink alias whose visible
- path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
- can no longer launder a write past the guard. Centralized so pre-write.py and
- pre-edit.py apply the identical symlink-safe check to every class (H-05,
- H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
-
- Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
- a target outside the repo (which cannot be a `.codearbiter` path), so that
- flank is simply skipped."""
- hits = set()
- for p in (norm_path(fpath), repo_rel(fpath, root)):
- if not p:
- continue
- if is_audit_log(p):
- hits.add("audit")
- if is_decisions_path(p):
- hits.add("decisions")
- if is_context_md(p):
- hits.add("context")
- if is_marker_path(p):
- hits.add("marker")
- return hits
@@ -524,22 +370,6 @@ def project_root(payload=None):
return _ROOT_CACHE[key]
-def repo_rel(fpath, root):
- """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
-
- realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
- (project_root) canonicalizes symlinks and 8.3 short names, but the
- `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
- `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
- same repo via divergent forms. A purely lexical relpath on those forms
- yields a bogus `..`-prefixed path, which silently suppressed every
- path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
- Windows runners while ubuntu passed)."""
- if not fpath:
- return ""
- rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
- rel = rel.replace(os.sep, "/")
- return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/plugins/ca-pi/hooks/_pathnorm.py b/plugins/ca-pi/hooks/_pathnorm.py
index d763b550..c63dc6ae 100644
--- a/plugins/ca-pi/hooks/_pathnorm.py
+++ b/plugins/ca-pi/hooks/_pathnorm.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# codeArbiter - repo-relative path normalization. The dependency FLOOR of the
+# codeArbiter - repo-relative path primitives. The dependency FLOOR of the
# guard-classifier modules (issue #321).
#
# Two lines of code in its own module on purpose. Every path classifier -
@@ -11,8 +11,34 @@
#
# So it goes BELOW all of them. Nothing here may import from a sibling core
# module; that is what makes it a floor rather than another god module.
+#
+# `repo_rel` joined in slice 2 on the same test: it references no other module
+# symbol, and both the protected-path classifiers and the activation helpers
+# need it. Its docstring is the canonical statement of why a LEXICAL relpath is
+# wrong here - the 8.3 / symlink divergence that also produced #539 and #541.
+
+
+import os
def norm_path(p):
"""Normalize separators so guard regexes match Windows backslash paths."""
return (p or "").replace("\\", "/")
+
+
+def repo_rel(fpath, root):
+ """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
+
+ realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
+ (project_root) canonicalizes symlinks and 8.3 short names, but the
+ `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
+ `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
+ same repo via divergent forms. A purely lexical relpath on those forms
+ yields a bogus `..`-prefixed path, which silently suppressed every
+ path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
+ Windows runners while ubuntu passed)."""
+ if not fpath:
+ return ""
+ rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
+ rel = rel.replace(os.sep, "/")
+ return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/plugins/ca-pi/hooks/_protectedlib.py b/plugins/ca-pi/hooks/_protectedlib.py
new file mode 100644
index 00000000..d71d770a
--- /dev/null
+++ b/plugins/ca-pi/hooks/_protectedlib.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# codeArbiter - the protected-path classifiers: which repo paths are append-only
+# audit logs (H-05), ADR decision files (H-11), the activation manifest, and the
+# gate-marker directory.
+#
+# Extracted from _hooklib (issue #321, architecture-002) as slice 2. Measured the
+# same way slice 1 was: the cluster referenced exactly ONE symbol from the rest
+# of _hooklib (repo_rel, which moved to the _pathnorm floor because it
+# references no module symbol at all and the remaining slices need it too), and
+# NOTHING in the rest of _hooklib referenced the cluster. A one-way edge with no
+# back-reference is what makes a slice safe to move without touching a consumer.
+#
+# WHY THESE BELONG TOGETHER: they answer one question - "what class of protected
+# thing is this path?" - and the guards branch on the answer as a set.
+# classify_protected returns EVERY class a path hits, which is load-bearing:
+# #528/#529 showed that adding decision-log.md to the audit set while leaving it
+# in the decisions set still blocked, because pre-write.py checks the classes
+# independently. Splitting these across modules would let one classification
+# move without the others and reopen exactly that.
+#
+# _hooklib re-exports every name below, so no consumer changed and the
+# pre-existing hook suites prove parity without moving.
+
+from __future__ import annotations
+
+import re
+
+from _pathnorm import norm_path, repo_rel
+
+
+# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
+# here (architecture-004) so the three pre-* hooks import ONE definition instead
+# of re-encoding the regex inline (the exact drift this module exists to
+# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
+# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
+#
+# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
+# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
+# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
+# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
+# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
+# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
+# flanks derive from one source.
+#
+# gate-events.log (observability-001, #186) joins this set: it is the durable,
+# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
+# an append-only audit artifact exactly like the other three, so it gets the
+# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
+# alternation, with no separate guard to maintain. Note this protects it only
+# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
+# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
+#
+# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
+# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
+# these as plain strings too (a cheap `n in cmd` substring pre-filter before
+# running the regexes below), so it imports this tuple directly instead of
+# re-deriving/hand-copying the name set (the exact drift this centralization
+# exists to prevent — a filter that silently skips a future audit log because
+# its literal name was never added to a second, hand-maintained copy).
+# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
+# is unchanged from the prior hand-written pattern (same four literal
+# filenames, same (?:...) grouping), only the source of truth moved.
+AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
+# #528: the SMARTS arbitration log is an append-only audit artifact that happens
+# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
+# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
+# to it, H-11 refused every write without the /adr authoring marker, and only
+# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
+# session made a decision it could not record. Its own format doc states H-05's
+# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
+# H-05 is the correct guard: append freely, never rewrite.
+#
+# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
+# directly under .codearbiter/ and this one is nested a level deeper. Both halves
+# of the reclassification are load-bearing: adding it here WITHOUT removing it
+# from the H-11 set below leaves the append blocked, because classify_protected
+# reports every class a path hits and pre-write checks them independently.
+DECISION_LOG_BASENAME = "decision-log.md"
+DECISION_LOG_RE = re.compile(
+ r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
+)
+# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
+# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
+# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
+# log cannot silently skip the shell flank — adding the name only to the regex
+# alternation below would sail past that pre-filter and leave the log deletable
+# from the shell. (Caught by test_hook_guards.py, which the comment on that
+# pre-filter predicted verbatim.)
+AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
+AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
+# The path anchor stays scoped to the FLAT logs — those sit directly under
+# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
+# both patterns rather than loosening this one into matching any nesting.
+AUDIT_LOG_RE = re.compile(
+ r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
+)
+DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
+DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
+
+# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
+# master switch every hook gates on via arbiter_active(); .markers/ holds the
+# gate-pass tokens (security-gate-passed, migration-gate-passed,
+# adr-authoring-active). Both were writable project state with no Write/Edit
+# guard — the token strings are centralized here beside the audit-log/decisions
+# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
+CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
+MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
+# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
+# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
+# legitimately shell-written (the sanctioned producers are the python
+# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
+# which /adr legitimately `touch`es.
+GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
+
+
+def is_audit_log(rel):
+ """True iff `rel` is one of the append-only .codearbiter audit logs
+ (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
+ arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
+ n = norm_path(rel)
+ return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
+
+
+def is_tail_append(current, old, new):
+ """True iff an Edit's (old_string, new_string) pair is a verifiable,
+ TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
+ content) — the H-05 guard (reliability-003, #172).
+
+ `new.startswith(old)` alone is not sufficient: `old` could be any interior
+ line that happens to be a prefix of `new`, which inserts content BETWEEN
+ existing lines rather than appending at the end. This requires TWO things:
+ `current` must literally END with `old` (old_string is the file's actual
+ trailing content, not just some substring elsewhere), and `new` must
+ extend `old`. An empty `old` is never a valid append — every string
+ "ends with" the empty string, so the tail-anchor check would trivially
+ pass and reopen the migration-003 empty-old_string hole this closes.
+
+ `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
+ that happens to also match the tail is not self-evidently an append — this
+ keeps the guard correct on its own terms rather than depending on the Edit
+ tool's own (client-side, not re-verified here) uniqueness enforcement for
+ a non-replace_all Edit."""
+ if not old:
+ return False
+ if current.count(old) != 1:
+ return False
+ return current.endswith(old) and new.startswith(old)
+
+
+def is_decisions_path(rel):
+ """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
+ the H-11 guard set (a non-numbered draft or a nested path still counts).
+
+ decisions/decision-log.md is the ONE exception (#528): it is the append-only
+ arbitration log, not immutable ADR history, and is governed by H-05 instead.
+ The carve-out is exactly one path wide and anchored — `old-decision-log.md`
+ and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
+ cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
+ NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
+ n = norm_path(rel)
+ if DECISION_LOG_RE.search(n):
+ return False
+ return bool(DECISIONS_PATH_RE.search(n))
+
+
+def is_context_md(rel):
+ """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
+ the master switch arbiter_active() reads. Guarded so it can't be flipped to
+ `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
+ return bool(CONTEXT_MD_RE.search(norm_path(rel)))
+
+
+def is_marker_path(rel):
+ """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
+ gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
+ hand-written marker must not be admitted by the Write/Edit tools."""
+ return bool(MARKERS_RE.search(norm_path(rel)))
+
+
+def classify_protected(fpath, root):
+ """The set of protected classes a Write/Edit `fpath` targets, resolving
+ symlinks (#162). Each classifier runs against BOTH the raw normalized path
+ AND the realpath-resolved repo-relative form: a symlink alias whose visible
+ path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
+ can no longer launder a write past the guard. Centralized so pre-write.py and
+ pre-edit.py apply the identical symlink-safe check to every class (H-05,
+ H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
+
+ Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
+ a target outside the repo (which cannot be a `.codearbiter` path), so that
+ flank is simply skipped."""
+ hits = set()
+ for p in (norm_path(fpath), repo_rel(fpath, root)):
+ if not p:
+ continue
+ if is_audit_log(p):
+ hits.add("audit")
+ if is_decisions_path(p):
+ hits.add("decisions")
+ if is_context_md(p):
+ hits.add("context")
+ if is_marker_path(p):
+ hits.add("marker")
+ return hits
diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json
index 3b6e11c0..e3eeec25 100644
--- a/plugins/ca-pi/package.json
+++ b/plugins/ca-pi/package.json
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
- "version": "0.1.40",
+ "version": "0.1.41",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
diff --git a/plugins/ca/.claude-plugin/plugin.json b/plugins/ca/.claude-plugin/plugin.json
index b3d77dc5..79db2219 100644
--- a/plugins/ca/.claude-plugin/plugin.json
+++ b/plugins/ca/.claude-plugin/plugin.json
@@ -2,7 +2,7 @@
"name": "ca",
"displayName": "codeArbiter",
"description": "Orchestration layer for Claude Code. Routes every intent through gated skills and reviewer agents, drives spec-driven TDD, mechanically enforces the commit and audit-trail gates, decides via SMARTS, and keeps an append-only audit trail. Requires Python 3 on PATH. Dormant until you opt a repo in; run /ca:init to activate.",
- "version": "2.10.5",
+ "version": "2.10.6",
"author": { "name": "arbiterForge" },
"license": "AGPL-3.0-only",
"homepage": "https://github.com/arbiterForge/codeArbiter",
diff --git a/plugins/ca/hooks/_hooklib.py b/plugins/ca/hooks/_hooklib.py
index 91f7eee9..f4f74486 100755
--- a/plugins/ca/hooks/_hooklib.py
+++ b/plugins/ca/hooks/_hooklib.py
@@ -97,12 +97,32 @@
import hostapi
-# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, and
-# norm_path to the _pathnorm floor beneath it. Re-exported here, unchanged, so
+# Issue #321 - the H-09b/H-10b sensitive-line scan moved to _sensitivelib, the
+# protected-path classifiers to _protectedlib, and the path primitives to the
+# _pathnorm floor beneath both. Re-exported here, unchanged, so
# every one of the 59 consuming files keeps importing from _hooklib and the
# pre-existing suites prove parity without moving. New code SHOULD import from
# the owning module; this facade exists so the partition costs no caller a diff.
-from _pathnorm import norm_path # noqa: F401
+from _pathnorm import norm_path, repo_rel # noqa: F401
+from _protectedlib import ( # noqa: F401
+ AUDIT_LOG_BASENAMES,
+ AUDIT_LOG_FLAT_BASENAMES,
+ AUDIT_LOG_NAMES,
+ AUDIT_LOG_RE,
+ CONTEXT_MD_RE,
+ DECISIONS_DIR_RE,
+ DECISIONS_PATH_RE,
+ DECISION_LOG_BASENAME,
+ DECISION_LOG_RE,
+ GATE_MARKER_NAMES,
+ MARKERS_RE,
+ classify_protected,
+ is_audit_log,
+ is_context_md,
+ is_decisions_path,
+ is_marker_path,
+ is_tail_append,
+)
from _sensitivelib import ( # noqa: F401
CRYPTO_RE,
SECRET_RE,
@@ -220,180 +240,6 @@ def _reset_root_cache():
ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)
-# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
-# here (architecture-004) so the three pre-* hooks import ONE definition instead
-# of re-encoding the regex inline (the exact drift this module exists to
-# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
-# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
-#
-# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
-# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
-# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
-# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
-# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
-# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
-# flanks derive from one source.
-#
-# gate-events.log (observability-001, #186) joins this set: it is the durable,
-# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
-# an append-only audit artifact exactly like the other three, so it gets the
-# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
-# alternation, with no separate guard to maintain. Note this protects it only
-# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
-# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
-#
-# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
-# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
-# these as plain strings too (a cheap `n in cmd` substring pre-filter before
-# running the regexes below), so it imports this tuple directly instead of
-# re-deriving/hand-copying the name set (the exact drift this centralization
-# exists to prevent — a filter that silently skips a future audit log because
-# its literal name was never added to a second, hand-maintained copy).
-# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
-# is unchanged from the prior hand-written pattern (same four literal
-# filenames, same (?:...) grouping), only the source of truth moved.
-AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
-# #528: the SMARTS arbitration log is an append-only audit artifact that happens
-# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
-# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
-# to it, H-11 refused every write without the /adr authoring marker, and only
-# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
-# session made a decision it could not record. Its own format doc states H-05's
-# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
-# H-05 is the correct guard: append freely, never rewrite.
-#
-# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
-# directly under .codearbiter/ and this one is nested a level deeper. Both halves
-# of the reclassification are load-bearing: adding it here WITHOUT removing it
-# from the H-11 set below leaves the append blocked, because classify_protected
-# reports every class a path hits and pre-write checks them independently.
-DECISION_LOG_BASENAME = "decision-log.md"
-DECISION_LOG_RE = re.compile(
- r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
-)
-# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
-# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
-# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
-# log cannot silently skip the shell flank — adding the name only to the regex
-# alternation below would sail past that pre-filter and leave the log deletable
-# from the shell. (Caught by test_hook_guards.py, which the comment on that
-# pre-filter predicted verbatim.)
-AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
-AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
-# The path anchor stays scoped to the FLAT logs — those sit directly under
-# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
-# both patterns rather than loosening this one into matching any nesting.
-AUDIT_LOG_RE = re.compile(
- r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
-)
-DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
-DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
-
-# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
-# master switch every hook gates on via arbiter_active(); .markers/ holds the
-# gate-pass tokens (security-gate-passed, migration-gate-passed,
-# adr-authoring-active). Both were writable project state with no Write/Edit
-# guard — the token strings are centralized here beside the audit-log/decisions
-# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
-CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
-MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
-# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
-# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
-# legitimately shell-written (the sanctioned producers are the python
-# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
-# which /adr legitimately `touch`es.
-GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
-
-
-def is_audit_log(rel):
- """True iff `rel` is one of the append-only .codearbiter audit logs
- (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
- arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
- n = norm_path(rel)
- return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
-
-
-def is_tail_append(current, old, new):
- """True iff an Edit's (old_string, new_string) pair is a verifiable,
- TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
- content) — the H-05 guard (reliability-003, #172).
-
- `new.startswith(old)` alone is not sufficient: `old` could be any interior
- line that happens to be a prefix of `new`, which inserts content BETWEEN
- existing lines rather than appending at the end. This requires TWO things:
- `current` must literally END with `old` (old_string is the file's actual
- trailing content, not just some substring elsewhere), and `new` must
- extend `old`. An empty `old` is never a valid append — every string
- "ends with" the empty string, so the tail-anchor check would trivially
- pass and reopen the migration-003 empty-old_string hole this closes.
-
- `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
- that happens to also match the tail is not self-evidently an append — this
- keeps the guard correct on its own terms rather than depending on the Edit
- tool's own (client-side, not re-verified here) uniqueness enforcement for
- a non-replace_all Edit."""
- if not old:
- return False
- if current.count(old) != 1:
- return False
- return current.endswith(old) and new.startswith(old)
-
-
-def is_decisions_path(rel):
- """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
- the H-11 guard set (a non-numbered draft or a nested path still counts).
-
- decisions/decision-log.md is the ONE exception (#528): it is the append-only
- arbitration log, not immutable ADR history, and is governed by H-05 instead.
- The carve-out is exactly one path wide and anchored — `old-decision-log.md`
- and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
- cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
- NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
- n = norm_path(rel)
- if DECISION_LOG_RE.search(n):
- return False
- return bool(DECISIONS_PATH_RE.search(n))
-
-
-def is_context_md(rel):
- """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
- the master switch arbiter_active() reads. Guarded so it can't be flipped to
- `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
- return bool(CONTEXT_MD_RE.search(norm_path(rel)))
-
-
-def is_marker_path(rel):
- """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
- gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
- hand-written marker must not be admitted by the Write/Edit tools."""
- return bool(MARKERS_RE.search(norm_path(rel)))
-
-
-def classify_protected(fpath, root):
- """The set of protected classes a Write/Edit `fpath` targets, resolving
- symlinks (#162). Each classifier runs against BOTH the raw normalized path
- AND the realpath-resolved repo-relative form: a symlink alias whose visible
- path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
- can no longer launder a write past the guard. Centralized so pre-write.py and
- pre-edit.py apply the identical symlink-safe check to every class (H-05,
- H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
-
- Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
- a target outside the repo (which cannot be a `.codearbiter` path), so that
- flank is simply skipped."""
- hits = set()
- for p in (norm_path(fpath), repo_rel(fpath, root)):
- if not p:
- continue
- if is_audit_log(p):
- hits.add("audit")
- if is_decisions_path(p):
- hits.add("decisions")
- if is_context_md(p):
- hits.add("context")
- if is_marker_path(p):
- hits.add("marker")
- return hits
@@ -524,22 +370,6 @@ def project_root(payload=None):
return _ROOT_CACHE[key]
-def repo_rel(fpath, root):
- """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
-
- realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
- (project_root) canonicalizes symlinks and 8.3 short names, but the
- `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
- `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
- same repo via divergent forms. A purely lexical relpath on those forms
- yields a bogus `..`-prefixed path, which silently suppressed every
- path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
- Windows runners while ubuntu passed)."""
- if not fpath:
- return ""
- rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
- rel = rel.replace(os.sep, "/")
- return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/plugins/ca/hooks/_pathnorm.py b/plugins/ca/hooks/_pathnorm.py
index d763b550..c63dc6ae 100644
--- a/plugins/ca/hooks/_pathnorm.py
+++ b/plugins/ca/hooks/_pathnorm.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-# codeArbiter - repo-relative path normalization. The dependency FLOOR of the
+# codeArbiter - repo-relative path primitives. The dependency FLOOR of the
# guard-classifier modules (issue #321).
#
# Two lines of code in its own module on purpose. Every path classifier -
@@ -11,8 +11,34 @@
#
# So it goes BELOW all of them. Nothing here may import from a sibling core
# module; that is what makes it a floor rather than another god module.
+#
+# `repo_rel` joined in slice 2 on the same test: it references no other module
+# symbol, and both the protected-path classifiers and the activation helpers
+# need it. Its docstring is the canonical statement of why a LEXICAL relpath is
+# wrong here - the 8.3 / symlink divergence that also produced #539 and #541.
+
+
+import os
def norm_path(p):
"""Normalize separators so guard regexes match Windows backslash paths."""
return (p or "").replace("\\", "/")
+
+
+def repo_rel(fpath, root):
+ """Repo-relative POSIX path for `fpath`, or "" when it lies outside `root`.
+
+ realpath BOTH sides before relpath: `git rev-parse --show-toplevel`
+ (project_root) canonicalizes symlinks and 8.3 short names, but the
+ `file_path` in a hook payload may not — so on macOS (TMPDIR `/var` ->
+ `/private/var`) and Windows (`RUNNER~1` -> `runneradmin`) the two name the
+ same repo via divergent forms. A purely lexical relpath on those forms
+ yields a bogus `..`-prefixed path, which silently suppressed every
+ path-scoped reminder (#125 CI: H-12/H-15/H-16/H-13 dropped on macOS +
+ Windows runners while ubuntu passed)."""
+ if not fpath:
+ return ""
+ rel = os.path.relpath(os.path.realpath(fpath), os.path.realpath(root))
+ rel = rel.replace(os.sep, "/")
+ return "" if rel == ".." or rel.startswith("../") else rel
diff --git a/plugins/ca/hooks/_protectedlib.py b/plugins/ca/hooks/_protectedlib.py
new file mode 100644
index 00000000..d71d770a
--- /dev/null
+++ b/plugins/ca/hooks/_protectedlib.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# codeArbiter - the protected-path classifiers: which repo paths are append-only
+# audit logs (H-05), ADR decision files (H-11), the activation manifest, and the
+# gate-marker directory.
+#
+# Extracted from _hooklib (issue #321, architecture-002) as slice 2. Measured the
+# same way slice 1 was: the cluster referenced exactly ONE symbol from the rest
+# of _hooklib (repo_rel, which moved to the _pathnorm floor because it
+# references no module symbol at all and the remaining slices need it too), and
+# NOTHING in the rest of _hooklib referenced the cluster. A one-way edge with no
+# back-reference is what makes a slice safe to move without touching a consumer.
+#
+# WHY THESE BELONG TOGETHER: they answer one question - "what class of protected
+# thing is this path?" - and the guards branch on the answer as a set.
+# classify_protected returns EVERY class a path hits, which is load-bearing:
+# #528/#529 showed that adding decision-log.md to the audit set while leaving it
+# in the decisions set still blocked, because pre-write.py checks the classes
+# independently. Splitting these across modules would let one classification
+# move without the others and reopen exactly that.
+#
+# _hooklib re-exports every name below, so no consumer changed and the
+# pre-existing hook suites prove parity without moving.
+
+from __future__ import annotations
+
+import re
+
+from _pathnorm import norm_path, repo_rel
+
+
+# Append-only audit logs (H-05) and ADR-decisions paths (H-11) — centralized
+# here (architecture-004) so the three pre-* hooks import ONE definition instead
+# of re-encoding the regex inline (the exact drift this module exists to
+# prevent: adding sprint-log.md once meant hand-editing every copy). Same home,
+# same rationale, as CRYPTO_RE/SECRET_RE/MIGRATION_DEFAULT_GLOBS.
+#
+# AUDIT_LOG_NAMES is the bare filename alternation; pre-bash.py composes its
+# shell LOG_NAMES from it, and AUDIT_LOG_RE anchors it under .codearbiter/ for
+# the Write/Edit file-path guards. DECISIONS_DIR_RE is the separator-tolerant
+# decisions directory token; pre-bash.py composes its shell DECISIONS from it,
+# and DECISIONS_PATH_RE extends it to a full ADR file path. `[\\/]+` matches the
+# norm_path'd `/` as well as a raw backslash, so both the file-path and shell
+# flanks derive from one source.
+#
+# gate-events.log (observability-001, #186) joins this set: it is the durable,
+# mechanical BLOCK/REMIND/WARN sink block()/remind()/warn() append to below —
+# an append-only audit artifact exactly like the other three, so it gets the
+# SAME H-05 tool-call protection (Write/Edit + shell) for free via this one
+# alternation, with no separate guard to maintain. Note this protects it only
+# from Write/Edit/Bash TOOL CALLS; the hooks' own os-level `open(..., "a")`
+# append (below) is plain file I/O, never a tool call, so H-05 never gates it.
+#
+# AUDIT_LOG_BASENAMES is the single authoritative list of bare filenames — the
+# ONE place a new audit log gets added. pre-bash.py's H-05 shell guard needs
+# these as plain strings too (a cheap `n in cmd` substring pre-filter before
+# running the regexes below), so it imports this tuple directly instead of
+# re-deriving/hand-copying the name set (the exact drift this centralization
+# exists to prevent — a filter that silently skips a future audit log because
+# its literal name was never added to a second, hand-maintained copy).
+# AUDIT_LOG_NAMES is built FROM this tuple (re.escape'd, alternated) — behavior
+# is unchanged from the prior hand-written pattern (same four literal
+# filenames, same (?:...) grouping), only the source of truth moved.
+AUDIT_LOG_FLAT_BASENAMES = ("overrides.log", "triage.log", "gate-events.log", "sprint-log.md")
+# #528: the SMARTS arbitration log is an append-only audit artifact that happens
+# to sit under decisions/ for filing reasons. It is NOT an ADR, and governing it
+# as one was a live deadlock: `decision-variance` Phase 4 is REQUIRED to append
+# to it, H-11 refused every write without the /adr authoring marker, and only
+# decision-lifecycle arms that marker. So a SMARTS arbitration outside an /adr
+# session made a decision it could not record. Its own format doc states H-05's
+# rule verbatim — "strictly append-only … to supersede, append a new entry" — so
+# H-05 is the correct guard: append freely, never rewrite.
+#
+# It is listed separately from AUDIT_LOG_BASENAMES because those are anchored
+# directly under .codearbiter/ and this one is nested a level deeper. Both halves
+# of the reclassification are load-bearing: adding it here WITHOUT removing it
+# from the H-11 set below leaves the append blocked, because classify_protected
+# reports every class a path hits and pre-write checks them independently.
+DECISION_LOG_BASENAME = "decision-log.md"
+DECISION_LOG_RE = re.compile(
+ r"\.codearbiter[\\/]+decisions[\\/]+" + re.escape(DECISION_LOG_BASENAME) + r"$"
+)
+# AUDIT_LOG_BASENAMES stays the SINGLE AUTHORITATIVE BASENAME LIST, and the
+# arbitration log is in it. _bashguardlib's H-05 shell check pre-filters with
+# `any(n in cmd for n in AUDIT_LOG_BASENAMES)` precisely so a newly added audit
+# log cannot silently skip the shell flank — adding the name only to the regex
+# alternation below would sail past that pre-filter and leave the log deletable
+# from the shell. (Caught by test_hook_guards.py, which the comment on that
+# pre-filter predicted verbatim.)
+AUDIT_LOG_BASENAMES = AUDIT_LOG_FLAT_BASENAMES + (DECISION_LOG_BASENAME,)
+AUDIT_LOG_NAMES = "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_BASENAMES) + ")"
+# The path anchor stays scoped to the FLAT logs — those sit directly under
+# .codearbiter/, the arbitration log one level deeper — so is_audit_log() tests
+# both patterns rather than loosening this one into matching any nesting.
+AUDIT_LOG_RE = re.compile(
+ r"\.codearbiter/" + "(?:" + "|".join(re.escape(n) for n in AUDIT_LOG_FLAT_BASENAMES) + ")" + r"$"
+)
+DECISIONS_DIR_RE = r"\.codearbiter[\\/]+decisions"
+DECISIONS_PATH_RE = re.compile(DECISIONS_DIR_RE + r"[\\/]+.+\.md$")
+
+# The activation file (#159) and the gate-marker store (#160). CONTEXT.md is the
+# master switch every hook gates on via arbiter_active(); .markers/ holds the
+# gate-pass tokens (security-gate-passed, migration-gate-passed,
+# adr-authoring-active). Both were writable project state with no Write/Edit
+# guard — the token strings are centralized here beside the audit-log/decisions
+# sets so the pre-* hooks import ONE definition (same anti-drift rationale).
+CONTEXT_MD_RE = re.compile(r"\.codearbiter/CONTEXT\.md$")
+MARKERS_RE = re.compile(r"\.codearbiter/\.markers(?:/|$)")
+# The two load-bearing gate-pass markers a commit gate consumes (H-09b/H-10b,
+# H-14). Their bare filenames feed pre-bash.py's shell flank — these are NEVER
+# legitimately shell-written (the sanctioned producers are the python
+# security-pass.py / migration-pass.py helpers), unlike adr-authoring-active
+# which /adr legitimately `touch`es.
+GATE_MARKER_NAMES = r"(?:security-gate-passed|migration-gate-passed)"
+
+
+def is_audit_log(rel):
+ """True iff `rel` is one of the append-only .codearbiter audit logs
+ (overrides.log, triage.log, sprint-log.md, gate-events.log) or the SMARTS
+ arbitration log decisions/decision-log.md (#528) — the H-05 guard set."""
+ n = norm_path(rel)
+ return bool(AUDIT_LOG_RE.search(n) or DECISION_LOG_RE.search(n))
+
+
+def is_tail_append(current, old, new):
+ """True iff an Edit's (old_string, new_string) pair is a verifiable,
+ TAIL-ANCHORED pure append against `current` (the file's REAL on-disk
+ content) — the H-05 guard (reliability-003, #172).
+
+ `new.startswith(old)` alone is not sufficient: `old` could be any interior
+ line that happens to be a prefix of `new`, which inserts content BETWEEN
+ existing lines rather than appending at the end. This requires TWO things:
+ `current` must literally END with `old` (old_string is the file's actual
+ trailing content, not just some substring elsewhere), and `new` must
+ extend `old`. An empty `old` is never a valid append — every string
+ "ends with" the empty string, so the tail-anchor check would trivially
+ pass and reopen the migration-003 empty-old_string hole this closes.
+
+ `old` must also occur EXACTLY ONCE in `current`: a non-unique old_string
+ that happens to also match the tail is not self-evidently an append — this
+ keeps the guard correct on its own terms rather than depending on the Edit
+ tool's own (client-side, not re-verified here) uniqueness enforcement for
+ a non-replace_all Edit."""
+ if not old:
+ return False
+ if current.count(old) != 1:
+ return False
+ return current.endswith(old) and new.startswith(old)
+
+
+def is_decisions_path(rel):
+ """True iff `rel` is a `.md` ADR anywhere under .codearbiter/decisions/ —
+ the H-11 guard set (a non-numbered draft or a nested path still counts).
+
+ decisions/decision-log.md is the ONE exception (#528): it is the append-only
+ arbitration log, not immutable ADR history, and is governed by H-05 instead.
+ The carve-out is exactly one path wide and anchored — `old-decision-log.md`
+ and a nested `sub/decision-log.md` remain ADRs, so a near-miss filename
+ cannot launder itself out of the marker gate. (`decision-log.md.bak` is in
+ NEITHER set: it does not end in `.md`, so it was never an H-11 path either.)"""
+ n = norm_path(rel)
+ if DECISION_LOG_RE.search(n):
+ return False
+ return bool(DECISIONS_PATH_RE.search(n))
+
+
+def is_context_md(rel):
+ """True iff `rel` is the .codearbiter/CONTEXT.md activation file (#159) —
+ the master switch arbiter_active() reads. Guarded so it can't be flipped to
+ `arbiter: disabled` (or corrupted) to make every enforcement hook dormant."""
+ return bool(CONTEXT_MD_RE.search(norm_path(rel)))
+
+
+def is_marker_path(rel):
+ """True iff `rel` is anywhere under .codearbiter/.markers/ (#160) — the
+ gate-pass token store. Load-bearing markers turn a BLOCK into an allow, so a
+ hand-written marker must not be admitted by the Write/Edit tools."""
+ return bool(MARKERS_RE.search(norm_path(rel)))
+
+
+def classify_protected(fpath, root):
+ """The set of protected classes a Write/Edit `fpath` targets, resolving
+ symlinks (#162). Each classifier runs against BOTH the raw normalized path
+ AND the realpath-resolved repo-relative form: a symlink alias whose visible
+ path lacks `.codearbiter/` still realpaths back inside the repo, so an alias
+ can no longer launder a write past the guard. Centralized so pre-write.py and
+ pre-edit.py apply the identical symlink-safe check to every class (H-05,
+ H-11, #159 CONTEXT.md, #160 markers) instead of re-encoding it twice.
+
+ Classes: "audit", "decisions", "context", "marker". repo_rel() returns "" for
+ a target outside the repo (which cannot be a `.codearbiter` path), so that
+ flank is simply skipped."""
+ hits = set()
+ for p in (norm_path(fpath), repo_rel(fpath, root)):
+ if not p:
+ continue
+ if is_audit_log(p):
+ hits.add("audit")
+ if is_decisions_path(p):
+ hits.add("decisions")
+ if is_context_md(p):
+ hits.add("context")
+ if is_marker_path(p):
+ hits.add("marker")
+ return hits