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 Claude Code plugin Codex plugin Pi Feature Forge preview -version 2.10.5 +version 2.10.6 commands skills agents 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