Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ predate the plugin rewrite and are grouped by date.

## [Unreleased]

## [2.11.10] — 2026-08-05

### Fixed

- Linked-worktree sessions could record a security/migration gate pass that
the H-09b/H-10b/H-14 commit guards would never see: `security-pass.py` and
`migration-pass.py`, run bare via a Bash tool call (no `CLAUDE_PROJECT_DIR`
in that shell), wrote their marker under the worktree's own (gitignored)
`.codearbiter/.markers/`, while the guards read it from the main checkout.
A new `marker_root()` seam (`hostapi.Host`) gives both sides the same
main-checkout answer without moving the diff/migration SCAN root, which
must stay bound to the tree actually being committed (#604).
- `test_colorlib.py`'s palette-compatibility tests read the real project's
`.codearbiter/` state when rendered via subprocess with no explicit `cwd`,
so a maintainer's own accumulated audit-log rows could make a required
custom-palette color intermittently disappear from the assertion window.
Isolated to a synthetic, in-fixture `.codearbiter/` (or none at all), plus
a new test that appends adversarial override/gate-event/task rows and
proves the palette-completeness check still holds (#552).
- Ten `test_git_hooks.py`/`test_repo_resolution.py` tests failed whenever the
suite itself ran from inside a linked git worktree (subagents do this
routinely): `_githooks`'s own `__file__` resolved to an ephemeral path,
which the existing `is_ephemeral_path` safety check (#441/ADR-0014)
correctly refused to register into a fixture's shared drop-in dir. Fixtures
now resolve `_githooks`'s enforcer path from a durable temp-dir copy of the
hooks payload (`durable_plugin_copy`, the #442 fix's existing pattern).

## [2.11.9] — 2026-08-05

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
<img alt="version 2.11.9" src="https://img.shields.io/badge/version-2.11.9-2b7489">
<img alt="version 2.11.10" src="https://img.shields.io/badge/version-2.11.10-2b7489">
<img alt="commands" src="https://img.shields.io/badge/commands-40-555">
<img alt="skills" src="https://img.shields.io/badge/skills-23-555">
<img alt="agents" src="https://img.shields.io/badge/agents-28-555">
Expand Down Expand Up @@ -119,7 +119,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue

### Codex CLI

The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.8`;
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.4.9`;
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
Current packaging and shared-core parity are continuously verified, while that dated live-install
record stays labeled rather than being silently promoted to evidence for a newer adapter:
Expand Down
13 changes: 13 additions & 0 deletions core/pysrc/_activationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ def _reset_root_cache():
still-valid (env, cwd) cache entry) call this between scenarios."""
_ROOT_CACHE.clear()


def marker_root(payload=None):
"""The root `.codearbiter/.markers/` gate passes (security-pass.py,
migration-pass.py, and the H-09b/H-10b/H-14 guards) are written to and
read from (#604) — see `hostapi.Host.marker_root`'s docstring for why
this is NOT the same thing as `project_root()` in a linked worktree.

Not memoized like `project_root()` above: called at most once or twice
per hook process (the marker checks, or a single `security-pass.py` /
`migration-pass.py` run), so the extra git spawn a linked-worktree
escalation occasionally costs is not worth a second cache to avoid."""
return get_host().marker_root(payload)

ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)

def frontmatter_enabled_text(text):
Expand Down
22 changes: 20 additions & 2 deletions core/pysrc/_bashguardlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
from _gitexec import git_executable
import _gitlib # reused for its spawn-free, worktree-aware (.git-as-a-FILE /
# gitdir: pointer) project_root() climb (#223)
from hostapi import git_worktree_main_root # noqa: #604 marker-root escalation
import _protectedstatelib # H-22's shell flank (T-08, #564) — imported as a
# module (not `from ... import REGISTRY`) so
# _STATE_WRITE_RES below is built from a live
Expand Down Expand Up @@ -1456,6 +1457,23 @@ def _check_h22_state(cmd, root):
f"this policy. Use the sanctioned helper.")


def _marker_root(root):
"""`root`, escalated to the MAIN checkout when `root` itself names a
LINKED git worktree's own checkout (#604) — see
`hostapi.git_worktree_main_root`'s docstring.

`root` (this file's own `project_root()`-derived parameter, D-2) already
names the main checkout in the common case: the harness sets
`CLAUDE_PROJECT_DIR` once at session start, before a session's cwd ever
moves into a linked worktree, so this is a no-op for the reported bug's
own scenario. It matters only when THIS hook process ALSO ran without
`CLAUDE_PROJECT_DIR` set (uncommon for a registered hook subprocess, but
possible) — without this, that edge case would have the guard read from
the worktree while `security-pass.py`'s `marker_root()` (hostapi.py)
writes to the main checkout, reopening the exact split this closes."""
return git_worktree_main_root(root) or root


def _check_h09b_h10b_crypto_secret(commit, add, cwd, root):
"""H-09b / H-10b: BLOCK a commit that introduces crypto/secret changes without
a recorded security-gate pass. The crypto-compliance / secret-handling skills
Expand Down Expand Up @@ -1494,7 +1512,7 @@ def _check_h09b_h10b_crypto_secret(commit, add, cwd, root):
kind = "crypto/TLS" if touches_crypto else "secret"
tag = "H-09b" if touches_crypto else "H-10b"
skill = "crypto-compliance" if touches_crypto else "secret-handling"
marker = os.path.join(root, ".codearbiter", ".markers", "security-gate-passed")
marker = os.path.join(_marker_root(root), ".codearbiter", ".markers", "security-gate-passed")
if not marker_fresh(marker, 30):
block(tag, f"This commit introduces {kind} changes, but no security-gate pass is "
f"recorded (.codearbiter/.markers/security-gate-passed). Run the "
Expand Down Expand Up @@ -1556,7 +1574,7 @@ def _check_h14_migration(commit, add, cwd, root):
staged |= extra
migs = sorted(p for p in staged if is_migration_path(p, root))
if migs:
marker = os.path.join(root, ".codearbiter", ".markers", "migration-gate-passed")
marker = os.path.join(_marker_root(root), ".codearbiter", ".markers", "migration-gate-passed")
try:
with open(marker, encoding="utf-8") as f:
approved = set(f.read().split())
Expand Down
5 changes: 5 additions & 0 deletions core/pysrc/_hooklib.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
# project_root(payload=None) -> str CLAUDE_PROJECT_DIR, else git repo root, else cwd
# (memoized per process, keyed on the
# inputs that could change it — #260)
# marker_root(payload=None) -> str project_root(payload), escalated to the MAIN
# checkout when that names a LINKED worktree's
# own checkout — the root gate MARKERS
# (.codearbiter/.markers/) live under (#604)
# repo_rel(fpath, root) -> str repo-relative POSIX path, or "" if outside root
# line_digest(line) -> str sha256 hex of one diff line (H-09b/H-10b gate)
# content_digest(text) -> str sha256 hex of a whole file's content (H-14 gate)
Expand Down Expand Up @@ -122,6 +126,7 @@
frontmatter_enabled,
frontmatter_enabled_text,
get_host,
marker_root,
project_root,
reset_host,
set_host,
Expand Down
85 changes: 84 additions & 1 deletion core/pysrc/hostapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,63 @@ def git_toplevel(cwd=None):
return None


def git_worktree_main_root(root):
"""When `root` (an already-resolved project root — a `project_root()`
answer, NOT necessarily a fresh `git_toplevel` call) is itself the
checkout of a LINKED git worktree, the MAIN checkout's root that owns the
shared `.git` directory — else `None`.

#604: `security-pass.py`/`migration-pass.py` and the H-09b/H-10b/H-14
guards were found resolving DIFFERENT roots for GATE MARKERS in a
linked-worktree session — a hook subprocess trusts a (possibly stale)
`CLAUDE_PROJECT_DIR` naming the MAIN checkout, while `security-pass.py`
run bare via Bash (no `CLAUDE_PROJECT_DIR` in that shell) fell through to
`git_toplevel()`, which names the WORKTREE's own checkout — so a gate
pass recorded by one was never seen by the other. `.codearbiter/
.markers/` is gitignored, so a linked worktree's own checkout never has
one freshly — the git-hook guard's diff/branch resolution already
carries this exact split (`_bashguardlib._effective_exec_root`'s
docstring, D-2: gate markers stay pinned to the main checkout regardless
of the command's effective exec root).

Deliberately NOT wired into `Host.project_root()` itself: `project_root()`
also backs `security-pass.py`'s DIFF SCAN (`candidate_lines()`), which
must stay worktree-local — escalating the general project root to "main"
would bind digests to the wrong (unrelated, possibly dirty) tree and
silently drop coverage for the diff actually being committed, the exact
trap the issue this closes warns against. Callers that specifically need
the gate-MARKER root call this as a targeted escalation on top of an
already-resolved `project_root()` answer instead — e.g.
`git_worktree_main_root(root) or root` — so every OTHER project_root()
consumer (diff scans, `arbiter_active`, …) is entirely unaffected, and
the two callers agree on marker location without `git_toplevel`'s own
`git rev-parse` mechanism being replaced anywhere (deliberate: symlink/
8.3 canonicalization, #125).

Distinguishes a linked worktree from a submodule — both have a `.git`
FILE, but only a worktree's `gitdir:` pointer names a path under
`.git/worktrees/<name>`; a submodule's names `.git/modules/<name>`, which
is not a "main root" to climb to and must fall through untouched (mirrors
`_durabilitylib._gitfile_points_at_worktree`'s same distinction)."""
git_meta = os.path.join(root, ".git")
if not os.path.isfile(git_meta):
return None
try:
with open(git_meta, encoding="utf-8", errors="replace") as f:
pointer = f.read().strip()
except OSError:
return None
if not pointer.startswith("gitdir: "):
return None
gitdir = pointer[len("gitdir: "):].strip().replace("\\", "/")
marker = "/.git/worktrees/"
idx = gitdir.find(marker)
if idx == -1:
return None # not a linked worktree (e.g. a submodule) — nothing to climb to
main_git_dir = gitdir[:idx + len("/.git")]
return os.path.dirname(main_git_dir) or None


class Host:
"""One host's answers to the host-coupled questions the hooks ask.

Expand Down Expand Up @@ -123,7 +180,11 @@ def project_root(self, payload=None):
var.
3. `git rev-parse --show-toplevel` from the process cwd.
4. the process cwd.
"""

Deliberately climbs no further than the WORKTREE's own toplevel in a
linked-worktree session — a caller wanting the gate-MARKER root (which
must agree on the MAIN checkout, #604) calls `marker_root()` instead;
see its docstring for why the two must not be conflated."""
env_root = os.environ.get("CLAUDE_PROJECT_DIR")
if env_root and os.path.isdir(env_root):
return env_root
Expand All @@ -136,6 +197,28 @@ def project_root(self, payload=None):
return top
return os.getcwd()

def marker_root(self, payload=None):
"""The root `.codearbiter/.markers/` gate passes (security-pass.py,
migration-pass.py, and the H-09b/H-10b/H-14 guards) are written to
and read from (#604).

Identical to `project_root(payload)` in every case except one: when
that resolves to a LINKED git worktree's own checkout, this escalates
to the MAIN checkout that owns the shared `.git` directory instead
(`git_worktree_main_root`) — `.codearbiter/.markers/` is gitignored,
so a linked worktree's own checkout never has one freshly, and the
git-hook guard already anchors every marker READ at the main
checkout regardless of a command's real exec root (D-2,
`_bashguardlib._effective_exec_root`'s docstring). This gives every
marker WRITER that same answer even when it runs without
`CLAUDE_PROJECT_DIR` set — `security-pass.py` invoked bare via a Bash
tool call, rather than as a registered hook subprocess that inherits
it from the harness — closing the loop without touching
`project_root()` itself, which other callers (diff scans in
particular) need to stay worktree-local."""
root = self.project_root(payload)
return git_worktree_main_root(root) or root

def plugin_root(self):
"""The plugin payload root: CLAUDE_PLUGIN_ROOT when set, else derived
from this file's own location (<root>/hooks/hostapi.py -> <root>) —
Expand Down
14 changes: 10 additions & 4 deletions core/pysrc/migration-pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _hooklib import ( # noqa: E402
content_digest, is_migration_path, project_root, set_host, utf8_stdio,
warn, write_text_atomic,
content_digest, is_migration_path, marker_root, project_root, set_host,
utf8_stdio, warn, write_text_atomic,
)

MAX_FILE_BYTES = 1_000_000 # a blob bigger than this is not a reviewable migration
Expand Down Expand Up @@ -86,7 +86,13 @@ def main():
text = read_text(root, rel)
if text is not None:
digests.add(content_digest(text))
marker_dir = os.path.join(root, ".codearbiter", ".markers")
# #604: same split as security-pass.py — candidate_paths(root)/read_text(root, …)
# above must stay bound to wherever this process is actually running (a
# linked worktree's own tree), but the MARKER write goes through
# marker_root(), which agrees with the H-14 guard's main-checkout-anchored
# read (D-2) even when this process has no CLAUDE_PROJECT_DIR set.
write_root = marker_root()
marker_dir = os.path.join(write_root, ".codearbiter", ".markers")
os.makedirs(marker_dir, exist_ok=True)
marker = os.path.join(marker_dir, "migration-gate-passed")
digests = sorted(digests)
Expand All @@ -95,7 +101,7 @@ def main():
# spurious gate re-run.
write_text_atomic(marker, "\n".join(digests) + ("\n" if digests else ""))
print(f"migration-gate pass recorded: {len(digests)} migration file(s) "
f"bound to {os.path.relpath(marker, root)}")
f"bound to {os.path.relpath(marker, write_root)}")


def run(host, argv=None):
Expand Down
18 changes: 14 additions & 4 deletions core/pysrc/security-pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
import _entrylib # noqa: E402 — shared run() dispatch (jscpd dedup)
from _hooklib import ( # noqa: E402
CRYPTO_RE, SECRET_RE, SECURITY_DIFF_GIT_ARGS, is_sensitive_scan_exempt,
line_digest, project_root, sensitive_scan_added_lines, set_host,
utf8_stdio, warn, write_text_atomic,
line_digest, marker_root, project_root, sensitive_scan_added_lines,
set_host, utf8_stdio, warn, write_text_atomic,
)

MAX_UNTRACKED_BYTES = 1_000_000 # an untracked blob bigger than this is not reviewable prose
Expand Down Expand Up @@ -97,7 +97,17 @@ def main():
sys.exit(1)
sensitive = [ln for ln in candidate_lines(root)
if CRYPTO_RE.search(ln) or SECRET_RE.search(ln)]
marker_dir = os.path.join(root, ".codearbiter", ".markers")
# #604: the MARKER root is deliberately NOT `root` above. `root` (plain
# project_root()) must stay wherever this process is actually running —
# candidate_lines(root) just scanned exactly that tree's diff, and binding
# digests to a DIFFERENT tree would review lines nobody staged. But in a
# linked git worktree, `root` names the worktree's own (gitignored,
# never-checked-out) `.codearbiter/.markers/`, while the H-09b/H-10b
# guard reads the marker from the MAIN checkout (D-2) — marker_root()
# gives the write the same main-checkout answer the guard's read already
# has, without moving the scan.
write_root = marker_root()
marker_dir = os.path.join(write_root, ".codearbiter", ".markers")
os.makedirs(marker_dir, exist_ok=True)
marker = os.path.join(marker_dir, "security-gate-passed")
digests = sorted({line_digest(ln) for ln in sensitive})
Expand All @@ -106,7 +116,7 @@ def main():
# spurious gate re-run.
write_text_atomic(marker, "\n".join(digests) + ("\n" if digests else ""))
print(f"security-gate pass recorded: {len(digests)} sensitive line(s) "
f"bound to {os.path.relpath(marker, root)}")
f"bound to {os.path.relpath(marker, write_root)}")


def run(host, argv=None):
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
"version": "0.2.8",
"version": "0.2.9",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/ca-codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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.4.8",
"version": "0.4.9",
"author": {
"name": "arbiterForge"
},
Expand Down
13 changes: 13 additions & 0 deletions plugins/ca-codex/hooks/_activationlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ def _reset_root_cache():
still-valid (env, cwd) cache entry) call this between scenarios."""
_ROOT_CACHE.clear()


def marker_root(payload=None):
"""The root `.codearbiter/.markers/` gate passes (security-pass.py,
migration-pass.py, and the H-09b/H-10b/H-14 guards) are written to and
read from (#604) — see `hostapi.Host.marker_root`'s docstring for why
this is NOT the same thing as `project_root()` in a linked worktree.

Not memoized like `project_root()` above: called at most once or twice
per hook process (the marker checks, or a single `security-pass.py` /
`migration-pass.py` run), so the extra git spawn a linked-worktree
escalation occasionally costs is not worth a second cache to avoid."""
return get_host().marker_root(payload)

ARBITER_RE = re.compile(r"^\s*arbiter:\s*enabled\s*$", re.I)

def frontmatter_enabled_text(text):
Expand Down
Loading
Loading