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
3 changes: 3 additions & 0 deletions .codearbiter/gate-events.log
Original file line number Diff line number Diff line change
Expand Up @@ -1119,3 +1119,6 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages
[2026-07-29T00:54:38Z] REMIND [H-12] host=claude hook=post-write-edit.py | .codearbiter/tech-stack.md is governed by ADR-0001-hybrid-adr-living-docs-governance (Adopt a hybrid ADR + living-docs governance model). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-29T01:20:32Z] REMIND [H-12] host=claude hook=post-write-edit.py | .github/workflows/ci.yml is governed by ADR-0014-pi-host-authentication-and-fail-closed-tool-boundary (Treat Pi host authentication as opaque runtime state and fail closed on unknown tools). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-29T01:20:32Z] REMIND [H-15] host=claude hook=post-write-edit.py | CI/CD workflow changed. Dispatch security-reviewer before merging (it reviews workflow/secrets/permissions exposure). Advisory — not a commit block.
[2026-07-29T01:42:33Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/tools/farm.ts is governed by ADR-0002-plan-json-trusted-operator-shell-input (Treat plan.json gate commands and FARM_MUTATION_CMD as trusted operator-authored shell input). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-29T01:42:49Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/tools/farm.ts is governed by ADR-0002-plan-json-trusted-operator-shell-input (Treat plan.json gate commands and FARM_MUTATION_CMD as trusted operator-authored shell input). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
[2026-07-29T01:45:27Z] REMIND [H-12] host=claude hook=post-write-edit.py | plugins/ca/tools/farm.ts is governed by ADR-0002-plan-json-trusted-operator-shell-input (Treat plan.json gate commands and FARM_MUTATION_CMD as trusted operator-authored shell input). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently.
26 changes: 26 additions & 0 deletions .github/scripts/test_ci_impact.py
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,32 @@ def test_the_coverage_union_actually_merges_more_than_one_host(self):
self.assertIn("--merge-reports", merge, "the union job stopped merging")
self.assertIn("coverage-blob-ca-*", merge, "the merge job no longer collects the host blobs")

def test_the_coverage_union_artifact_path_is_not_a_hidden_directory(self):
"""The union silently merged NOTHING for every run after it shipped.

`actions/upload-artifact` defaults to `include-hidden-files: false`, so
vitest's default `.vitest-reports/` uploaded zero files and the merge job
reported `blobs merged: 0` while still passing — an advisory job that
looks healthy and measures nothing is worse than an absent one.

Asserted structurally rather than by the directory's name: any hidden
upload path is acceptable ONLY with the flag that makes it work.
"""
ci = CI_WORKFLOW.read_text(encoding="utf-8")
jobs = workflow_jobs(ci)
for job_id in ("coverage-union", "coverage-union-merge"):
body = jobs[job_id]
for match in re.finditer(r"path:\s*(\S+)", body):
target = match.group(1)
hidden = any(part.startswith(".") and part not in (".", "..")
for part in target.replace("\\", "/").split("/"))
if hidden:
self.assertIn(
"include-hidden-files: true", body,
f"{job_id} uploads the hidden path {target} without "
"include-hidden-files, so it uploads nothing",
)

def test_the_coverage_union_is_advisory_not_a_required_check(self):
"""DECISION-0031 weighed Available and made this explicitly non-blocking.

Expand Down
17 changes: 11 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -367,16 +367,21 @@ jobs:
# reporter: the first run of this job failed on Windows and the log
# showed only "exit code 1" with no summary, no failing test name and no
# assertion. A cell that cannot say what broke is not worth having.
run: npx vitest run --coverage --reporter=blob --reporter=default --outputFile.blob=.vitest-reports/${{ matrix.os }}.json
run: npx vitest run --coverage --reporter=blob --reporter=default --outputFile.blob=coverage-blobs/${{ matrix.os }}.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# NOT vitest's default `.vitest-reports/`: upload-artifact defaults to
# `include-hidden-files: false`, so a dot-directory uploads NOTHING and
# the merge job silently reported `blobs merged: 0` for every run since
# this job shipped. A non-hidden directory needs no flag to be correct.
#
# always(): a FAILING host's blob is the one most worth having. Without
# this the upload is skipped exactly when the run needs diagnosing, and
# the merge job silently reports a one-host figure as though the other
# host had nothing to add.
if: always()
with:
name: coverage-blob-ca-${{ matrix.os }}
path: plugins/ca/tools/.vitest-reports/
path: plugins/ca/tools/coverage-blobs/
retention-days: 5

coverage-union-merge:
Expand All @@ -403,17 +408,17 @@ jobs:
with:
pattern: coverage-blob-ca-*
merge-multiple: true
path: plugins/ca/tools/.vitest-reports/
path: plugins/ca/tools/coverage-blobs/
- name: Merge and report the union
# A missing blob (a cancelled or flaked host cell) degrades to "report
# what we have and name what is missing" rather than failing a lane
# nothing gates on - the same thing maturity-coverage.md tells a human
# reading a partial local report.
run: |
echo "### Coverage union - plugins/ca/tools" >> "$GITHUB_STEP_SUMMARY"
blobs=$(ls .vitest-reports/*.json 2>/dev/null | wc -l)
blobs=$(ls coverage-blobs/*.json 2>/dev/null | wc -l)
echo "blobs merged: $blobs" | tee -a "$GITHUB_STEP_SUMMARY"
ls .vitest-reports/ 2>/dev/null | sed 's/^/ - /' >> "$GITHUB_STEP_SUMMARY" || true
ls coverage-blobs/ 2>/dev/null | sed 's/^/ - /' >> "$GITHUB_STEP_SUMMARY" || true
if [ "$blobs" -eq 0 ]; then
echo "no host produced a blob - nothing to merge (advisory)" | tee -a "$GITHUB_STEP_SUMMARY"
exit 0
Expand All @@ -422,7 +427,7 @@ jobs:
echo "PARTIAL: only $blobs of 2 hosts reported - this is NOT the union figure" | tee -a "$GITHUB_STEP_SUMMARY"
fi
echo '```' >> "$GITHUB_STEP_SUMMARY"
npx vitest --merge-reports .vitest-reports --coverage --coverage.reporter=text-summary 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" || true
npx vitest --merge-reports coverage-blobs --coverage --coverage.reporter=text-summary 2>&1 | tee -a "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"

tools:
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ predate the plugin rewrite and are grouped by date.

## [Unreleased]

## [2.10.3] — 2026-07-28

### Fixed

- **The farm worktree containment check canonicalizes both paths before
comparing them (#539).** It was lexical, and `path.resolve` neither expands a
Windows 8.3 short name nor follows a link — so two spellings of one directory
compared as different paths and a worktree root genuinely inside the repo was
refused, with a message telling the operator to point it inside the repo where
it already was. The only escape was `FARM_ALLOW_EXTERNAL_WORKTREE_ROOT=1`,
which disables the guard outright to work around a false positive in it.

Surfaced by #521's coverage-union job the first time this tree ran on Windows
CI: 15 failures, all downstream of one refusal, because the runner's tmpdir is
`C:\Users\RUNNER~1\...` while git reports `C:/Users/runneradmin/...`. It does
not reproduce on a developer box whose profile path is already 8.3-clean.

The change **tightens** the guard: resolving links means a junction inside the
repo whose target is outside is now refused, where the lexical check accepted
it. That was never exploitable — `fs.rm` removes the link rather than
recursing through it, measured — but accepting it was still wrong. A missing
root is normal and is canonicalized by walking up to the deepest existing
ancestor; any realpath failure that is not absence refuses, per #163.

## [2.10.2] — 2026-07-28

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Every intent routes through a gated skill or reviewer agent. Nothing commits unt
<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.10.2" src="https://img.shields.io/badge/version-2.10.2-2b7489">
<img alt="version 2.10.3" src="https://img.shields.io/badge/version-2.10.3-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
2 changes: 1 addition & 1 deletion plugins/ca/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.2",
"version": "2.10.3",
"author": { "name": "arbiterForge" },
"license": "AGPL-3.0-only",
"homepage": "https://github.com/arbiterForge/codeArbiter",
Expand Down
25 changes: 24 additions & 1 deletion plugins/ca/tools/farm.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { readFile as readFile2, writeFile, appendFile, mkdir as mkdir2, rm, stat, rename, open as open2 } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path3 from "node:path";
import { fileURLToPath } from "node:url";

Expand Down Expand Up @@ -679,9 +680,30 @@ function repoTopLevel() {
}
return path3.resolve(process.cwd());
}
function canonicalize(target, label, realpath2 = realpathSync.native) {
const resolved = path3.resolve(target);
const missing = [];
let cursor = resolved;
for (; ; ) {
try {
const real = realpath2(cursor);
return missing.length ? path3.join(real, ...missing.slice().reverse()) : real;
} catch (error) {
const code = error.code;
if (code !== "ENOENT" && code !== "ENOTDIR")
throw new Error(
`${label} '${resolved}' could not be canonicalized (${code ?? "unknown error"}) while checking worktree containment, so it is refused (#163 fail-closed, #539).`
);
const parent = path3.dirname(cursor);
if (parent === cursor) return resolved;
missing.push(path3.basename(cursor));
cursor = parent;
}
}
}
function validateWorktreeRoot(rawRoot, repo, external) {
const root = path3.resolve(rawRoot);
if (!external && !isInside(path3.resolve(repo), root))
if (!external && !isInside(canonicalize(repo, "the repository root"), canonicalize(rawRoot, "FARM_WORKTREE_ROOT")))
throw new Error(
`FARM_WORKTREE_ROOT resolves to '${root}', outside the repository root '${path3.resolve(repo)}'. farm recursively deletes task worktrees under this root, so an out-of-repo root is refused (#163). Point it inside the repo, or set FARM_ALLOW_EXTERNAL_WORKTREE_ROOT=1 to override.`
);
Expand Down Expand Up @@ -2194,6 +2216,7 @@ export {
atomicWriteFile,
buildChatBody,
buildPrompt,
canonicalize,
captureInScope,
checkDrift,
cleanupFailures,
Expand Down
57 changes: 54 additions & 3 deletions plugins/ca/tools/farm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import { readFile, writeFile, appendFile, mkdir, rm, stat, rename, open } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// v2.rev.0020 god-module split (architecture-003): the process/shell layer, the
Expand Down Expand Up @@ -263,12 +264,62 @@ function repoTopLevel(): string {
return path.resolve(process.cwd());
}

// Pure, side-effect-free root validation (directly unit-testable): the resolved
// worktree root must live inside `repo` unless `external` opts out. Throws with a
// #539 — canonicalize before comparing. `path.resolve` normalizes separators and
// `.`/`..`, but it does NOT expand a Windows 8.3 short name or follow a symlink,
// so two spellings of ONE directory compare as different paths and containment
// refuses a root that is genuinely inside the repo. GitHub's Windows runner hands
// out `C:\Users\RUNNER~1\...` from tmpdir() while `git rev-parse --show-toplevel`
// returns `C:/Users/runneradmin/...`; that mismatch failed 15 tests the first time
// this tree ran on Windows CI, and the message told the operator to point the root
// inside the repo, where it already was.
//
// This TIGHTENS the guard rather than loosening it: resolving links means a
// junction inside the repo whose target is outside is now refused, where the
// lexical check accepted it. (That was not exploitable - `fs.rm` removes the link
// rather than recursing through it, measured - but accepting it was still wrong.)
//
// A missing path is NORMAL here: the root is created later. So canonicalize the
// deepest EXISTING ancestor and re-attach the rest. Any realpath failure that is
// not "does not exist" is a genuine failure and refuses, per #163's fail-closed
// stance - never a silent fall-back to the lexical compare this replaces.
// `realpath` is injectable so the fail-closed branch is reachable from a test:
// an EACCES or ELOOP cannot be induced portably, and a guard whose refusal path
// is never executed is the #163 stance asserted rather than proven.
export function canonicalize(
target: string,
label: string,
realpath: (p: string) => string = realpathSync.native,
): string {
const resolved = path.resolve(target);
const missing: string[] = [];
let cursor = resolved;
for (;;) {
try {
const real = realpath(cursor);
return missing.length ? path.join(real, ...missing.slice().reverse()) : real;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "ENOTDIR")
throw new Error(
`${label} '${resolved}' could not be canonicalized (${code ?? "unknown error"}) while ` +
`checking worktree containment, so it is refused (#163 fail-closed, #539).`,
);
const parent = path.dirname(cursor);
// Root of the volume reached and nothing on the path exists: fall back to
// the lexical form. Nothing was resolved, so nothing was weakened.
if (parent === cursor) return resolved;
missing.push(path.basename(cursor));
cursor = parent;
}
}
}

// Root validation (directly unit-testable): the CANONICAL worktree root must live
// inside the canonical `repo` unless `external` opts out. Throws with a
// remediating message otherwise; returns the resolved absolute root.
export function validateWorktreeRoot(rawRoot: string, repo: string, external: boolean): string {
const root = path.resolve(rawRoot);
if (!external && !isInside(path.resolve(repo), root))
if (!external && !isInside(canonicalize(repo, "the repository root"), canonicalize(rawRoot, "FARM_WORKTREE_ROOT")))
throw new Error(
`FARM_WORKTREE_ROOT resolves to '${root}', outside the repository root '${path.resolve(repo)}'. ` +
`farm recursively deletes task worktrees under this root, so an out-of-repo root is ` +
Expand Down
Loading
Loading