@arbiterforge/ca-pi 0.10.10 → 0.10.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +13 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +2 -2
- package/plugins/ca-pi/extensions/codearbiter.js +8 -8
- package/plugins/ca-pi/hooks/_appendonlyconflictlib.py +461 -0
- package/plugins/ca-pi/hooks/_hooklib.py +85 -9
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_modelib.py +12 -3
- package/plugins/ca-pi/hooks/_overrideappendlib.py +154 -0
- package/plugins/ca-pi/hooks/append-override.py +8 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/resolve-append-only-conflict.py +8 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +1 -1
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-override/SKILL.md +40 -6
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ project context. You decide. codeArbiter enforces.
|
|
|
10
10
|
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude_Code-plugin-d97757">
|
|
11
11
|
<img alt="Codex plugin" src="https://img.shields.io/badge/OpenAI_Codex-plugin-10a37f">
|
|
12
12
|
<img alt="Pi Feature Forge preview" src="https://img.shields.io/badge/ca--pi-Feature_Forge_preview-d97757">
|
|
13
|
-
<img alt="version 2.17.
|
|
13
|
+
<img alt="version 2.17.10" src="https://img.shields.io/badge/version-2.17.10-2b7489">
|
|
14
14
|
<img alt="core lanes" src="https://img.shields.io/badge/core_lanes-18-555">
|
|
15
15
|
<img alt="skills" src="https://img.shields.io/badge/skills-23-555">
|
|
16
16
|
<img alt="agents" src="https://img.shields.io/badge/agents-19-555">
|
|
@@ -117,7 +117,7 @@ Approve the normal plugin trust prompt, open the target repository, and continue
|
|
|
117
117
|
|
|
118
118
|
### Codex CLI
|
|
119
119
|
|
|
120
|
-
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.
|
|
120
|
+
The public GitHub-slug flow is **available now**. The repository currently ships `ca-codex 0.9.10`;
|
|
121
121
|
the dated end-to-end public-install record discovered `ca-codex 0.2.4` from release `v2.8.13`.
|
|
122
122
|
Current packaging and shared-core parity are continuously verified, while that dated live-install
|
|
123
123
|
record stays labeled rather than being silently promoted to evidence for a newer adapter:
|
|
@@ -161,7 +161,7 @@ pi config
|
|
|
161
161
|
Replace `<version>` with the numeric suffix from the chosen tag while retaining the full
|
|
162
162
|
`ca-pi-v...` tag in the install source.
|
|
163
163
|
|
|
164
|
-
Pi 0.
|
|
164
|
+
Pi 0.84.1 is the supported host for this release line. Generated aliases use `/ca-*`;
|
|
165
165
|
`/skill:ca-*` is the host-native fallback. Every `ca-pi-v*` tag is also published to npm as
|
|
166
166
|
`npm:@arbiterforge/ca-pi` with provenance (ADR-0029); the pinned Git tag remains the reproducible
|
|
167
167
|
install. The [Pi runbook](./docs/pi-parity-testing.md) covers isolated install, trust, verification,
|
package/package.json
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to `ca-pi` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.10.12] - 2026-09-09
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Bind the reviewed Pi 0.84.1 npm configuration and lock bytes across Windows and POSIX checkouts, require CI to execute the integrity-checked install step, and reject the retired Pi 0.80.5 host explicitly.
|
|
12
|
+
- Correct singleton Pi support wording and reconcile authenticated publication receipts for the already-published 0.10.11 release line.
|
|
13
|
+
|
|
14
|
+
## [0.10.11] - 2026-09-08
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Resolve genuine append-only audit-log conflicts through a fail-closed helper that preserves both branch histories and records the immediate H-05 authorization, with shared audit-path locks and a locked mandatory-override writer preventing concurrent row loss.
|
|
19
|
+
|
|
7
20
|
## [0.10.10] - 2026-09-07
|
|
8
21
|
|
|
9
22
|
### Fixed
|
|
@@ -629,7 +629,7 @@ function resolvePythonCommand(platform = process.platform, probe = systemPythonP
|
|
|
629
629
|
}
|
|
630
630
|
|
|
631
631
|
// src/compatibility.ts
|
|
632
|
-
var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.
|
|
632
|
+
var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.84.1"]);
|
|
633
633
|
var MINIMUM_NODE = [22, 19, 0];
|
|
634
634
|
var SEMVER_PREFIX = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/u;
|
|
635
635
|
function atLeast(version, minimum) {
|
|
@@ -644,7 +644,7 @@ function atLeast(version, minimum) {
|
|
|
644
644
|
}
|
|
645
645
|
function compatibilityDirection(input) {
|
|
646
646
|
if (!SUPPORTED_PI_VERSIONS.has(input.piVersion)) {
|
|
647
|
-
return "codeArbiter requires Pi 0.
|
|
647
|
+
return "codeArbiter requires Pi 0.84.1; install a supported Pi version and run /ca-doctor.";
|
|
648
648
|
}
|
|
649
649
|
if (!atLeast(input.nodeVersion, MINIMUM_NODE)) {
|
|
650
650
|
return "codeArbiter requires Node >=22.19.0 for Pi; upgrade Node and run /ca-doctor.";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
var define_CODEARBITER_PI_PERMISSION_POLICY_SURFACES_default = { "ca-plan": "planning-write", codearbiter_background_bash: "background-launch" };
|
|
3
3
|
|
|
4
4
|
// <define:__CODEARBITER_PI_SKILL_EXPANSION_FINGERPRINTS__>
|
|
5
|
-
var define_CODEARBITER_PI_SKILL_EXPANSION_FINGERPRINTS_default = { "0.
|
|
5
|
+
var define_CODEARBITER_PI_SKILL_EXPANSION_FINGERPRINTS_default = { "0.84.1": "12632f365440b07d5183cff871d889b796a3c711b6b49df20f95d9bc198d6c51" };
|
|
6
6
|
|
|
7
7
|
// <define:__CODEARBITER_PI_TOOL_CLASSES__>
|
|
8
8
|
var define_CODEARBITER_PI_TOOL_CLASSES_default = { bash: "EXEC", codearbiter_background_bash: "EXEC", codearbiter_dispatch: "EXEC", codearbiter_farm_preview: "EXEC", write: "WRITE", edit: "EDIT", read: "READ" };
|
|
@@ -16,7 +16,7 @@ import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL2 } from
|
|
|
16
16
|
import { types as utilTypes9 } from "node:util";
|
|
17
17
|
|
|
18
18
|
// src/compatibility.ts
|
|
19
|
-
var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.
|
|
19
|
+
var SUPPORTED_PI_VERSIONS = /* @__PURE__ */ new Set(["0.84.1"]);
|
|
20
20
|
var MINIMUM_NODE = [22, 19, 0];
|
|
21
21
|
var SEMVER_PREFIX = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/u;
|
|
22
22
|
function atLeast(version, minimum) {
|
|
@@ -31,7 +31,7 @@ function atLeast(version, minimum) {
|
|
|
31
31
|
}
|
|
32
32
|
function compatibilityDirection(input) {
|
|
33
33
|
if (!SUPPORTED_PI_VERSIONS.has(input.piVersion)) {
|
|
34
|
-
return "codeArbiter requires Pi 0.
|
|
34
|
+
return "codeArbiter requires Pi 0.84.1; install a supported Pi version and run /ca-doctor.";
|
|
35
35
|
}
|
|
36
36
|
if (!atLeast(input.nodeVersion, MINIMUM_NODE)) {
|
|
37
37
|
return "codeArbiter requires Node >=22.19.0 for Pi; upgrade Node and run /ca-doctor.";
|
|
@@ -6881,10 +6881,10 @@ async function collectPiDoctorInput(dependencies) {
|
|
|
6881
6881
|
var REMEDIATION = {
|
|
6882
6882
|
package: "Reinstall ca-pi from the approved pinned Git tag, then restart Pi.",
|
|
6883
6883
|
trust: "Run /trust in Pi, inspect the project, grant trust only if you accept it, then start a new session.",
|
|
6884
|
-
version: "Upgrade Pi to 0.
|
|
6884
|
+
version: "Upgrade Pi to 0.84.1 and Node to >=22.19.0, then restart Pi.",
|
|
6885
6885
|
python: "Upgrade or install Python 3, then run /ca-doctor again.",
|
|
6886
6886
|
core: "Reinstall ca-pi to restore the generated shared core, then run /ca-doctor again.",
|
|
6887
|
-
commands: "Remove conflicting command owners or run Pi 0.
|
|
6887
|
+
commands: "Remove conflicting command owners or run Pi 0.84.1, then restart Pi and run /ca-doctor.",
|
|
6888
6888
|
bridge: "Reinstall ca-pi and Python 3, then run /ca-doctor again.",
|
|
6889
6889
|
child: "Reinstall ca-pi if the hardened child artifact is missing or tampered, then run /ca-doctor again.",
|
|
6890
6890
|
"ambient-marker": "Remove CODEARBITER_SUBAGENT from the parent environment and restart Pi.",
|
|
@@ -6913,7 +6913,7 @@ function diagnosePi(input) {
|
|
|
6913
6913
|
const packageHealthy = input.package.declared && input.package.name === "@arbiterforge/ca-pi" && existsSync(input.package.root) && existsSync(input.package.extensionPath) && samePath2(input.package.extensionPath, expectedExtension) && canonicallyInside(input.package.extensionPath, input.package.root);
|
|
6914
6914
|
const trustHealthy = input.trust.inspected && (!input.trust.required || input.trust.projectTrusted);
|
|
6915
6915
|
const waitingForTrust = input.trust.required && !input.trust.projectTrusted;
|
|
6916
|
-
const versionHealthy =
|
|
6916
|
+
const versionHealthy = input.runtime.piVersion === "0.84.1" && atLeast(input.runtime.nodeVersion, [22, 19, 0]);
|
|
6917
6917
|
const piBelowMinimum = !atLeast(input.runtime.piVersion, [0, 80, 5]);
|
|
6918
6918
|
const supportedExpansion = input.commands.expansionVerifiedVersions.includes(input.runtime.piVersion);
|
|
6919
6919
|
const expectedDoctorSkill = resolve9(input.package.root, "skills", "ca-doctor", "SKILL.md");
|
|
@@ -7034,7 +7034,7 @@ function diagnosePi(input) {
|
|
|
7034
7034
|
{
|
|
7035
7035
|
id: "active-dispatch",
|
|
7036
7036
|
state: "degraded",
|
|
7037
|
-
message: "Supported Pi 0.
|
|
7037
|
+
message: "Supported Pi 0.84.1 public extension APIs cannot submit this deterministic self-test through the active dispatcher; the wrapper self-test does not exercise active dispatch.",
|
|
7038
7038
|
remediation: REMEDIATION["active-dispatch"]
|
|
7039
7039
|
}
|
|
7040
7040
|
];
|
|
@@ -10588,7 +10588,7 @@ async function codeArbiterPi(pi) {
|
|
|
10588
10588
|
activeTools: pi.getActiveTools(),
|
|
10589
10589
|
allTools: pi.getAllTools(),
|
|
10590
10590
|
expansionFingerprints,
|
|
10591
|
-
childFingerprint: "
|
|
10591
|
+
childFingerprint: "22e5231b36a0af4a4202ced48a20e7dc9de435b6c6af985c6007bff96c5e04d1"
|
|
10592
10592
|
});
|
|
10593
10593
|
const wrapperSelfTest = await runPiWrapperSelfTest({
|
|
10594
10594
|
enabled: enabledForDoctor,
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter - fail-closed resolution of genuine H-05 append-only conflicts.
|
|
3
|
+
#
|
|
4
|
+
# The public entry point is resolve-append-only-conflict.py. This module keeps
|
|
5
|
+
# Git inspection, byte validation, deterministic union construction, atomic
|
|
6
|
+
# replacement, and explicit staging testable and host-neutral.
|
|
7
|
+
#
|
|
8
|
+
# Public API:
|
|
9
|
+
# resolve(target, reason, cwd=None) -> str resolve and return repo-relative path
|
|
10
|
+
# main(argv=None) -> int CLI contract
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
from _hooklib import acquire_lock, audit_lock_key, release_lock
|
|
24
|
+
from _gitexec import git_executable
|
|
25
|
+
from _protectedlib import classify_protected
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
BOM = b"\xef\xbb\xbf"
|
|
29
|
+
MAX_REASON = 500
|
|
30
|
+
SUPPORTED_AUDIT_PATHS = frozenset(
|
|
31
|
+
{".codearbiter/overrides.log", ".codearbiter/gate-events.log"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResolutionError(RuntimeError):
|
|
36
|
+
"""A surfaced, non-mutating refusal of the conflict-resolution contract."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConcurrentAppendError(ResolutionError):
|
|
40
|
+
"""A displaced original inode changed and was restored before refusal."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, path, restored, observed=None):
|
|
43
|
+
super().__init__(f"concurrent append detected while replacing '{Path(path).name}'")
|
|
44
|
+
self.path = Path(path)
|
|
45
|
+
self.restored = restored
|
|
46
|
+
self.observed = observed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _git(cwd, *args, input_bytes=None, check=True):
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
[git_executable(), *args],
|
|
53
|
+
cwd=cwd,
|
|
54
|
+
input=input_bytes,
|
|
55
|
+
stdout=subprocess.PIPE,
|
|
56
|
+
stderr=subprocess.PIPE,
|
|
57
|
+
check=False,
|
|
58
|
+
)
|
|
59
|
+
except OSError as error:
|
|
60
|
+
raise ResolutionError(f"git unavailable: {error}") from error
|
|
61
|
+
if check and result.returncode != 0:
|
|
62
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
63
|
+
raise ResolutionError(f"git {' '.join(args)} failed: {detail or 'unknown error'}")
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _repository_root(cwd):
|
|
68
|
+
result = _git(cwd, "rev-parse", "--show-toplevel")
|
|
69
|
+
try:
|
|
70
|
+
raw = result.stdout.decode("utf-8", errors="strict").strip()
|
|
71
|
+
except UnicodeDecodeError as error:
|
|
72
|
+
raise ResolutionError("git returned an invalid UTF-8 repository root") from error
|
|
73
|
+
if not raw:
|
|
74
|
+
raise ResolutionError("git returned an empty repository root")
|
|
75
|
+
return Path(raw).resolve()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _relative_target(root, target):
|
|
79
|
+
candidate = Path(target)
|
|
80
|
+
if candidate.is_absolute():
|
|
81
|
+
raise ResolutionError("target must be a repository-relative supported append-only path")
|
|
82
|
+
normalized = Path(os.path.normpath(str(candidate)))
|
|
83
|
+
if normalized == Path(".") or ".." in normalized.parts:
|
|
84
|
+
raise ResolutionError("target must be a contained supported append-only path")
|
|
85
|
+
path = root / normalized
|
|
86
|
+
try:
|
|
87
|
+
resolved_root = os.path.realpath(root)
|
|
88
|
+
resolved_path = os.path.realpath(path)
|
|
89
|
+
common = os.path.commonpath((resolved_root, resolved_path))
|
|
90
|
+
except ValueError as error:
|
|
91
|
+
raise ResolutionError("target escapes the repository root") from error
|
|
92
|
+
if os.path.normcase(common) != os.path.normcase(resolved_root):
|
|
93
|
+
raise ResolutionError("target escapes the repository root")
|
|
94
|
+
relative = normalized.as_posix()
|
|
95
|
+
if relative not in SUPPORTED_AUDIT_PATHS or "audit" not in classify_protected(
|
|
96
|
+
str(path), str(root)
|
|
97
|
+
):
|
|
98
|
+
raise ResolutionError(f"'{relative}' is not a supported append-only H-05 path")
|
|
99
|
+
try:
|
|
100
|
+
mode = path.lstat().st_mode
|
|
101
|
+
except OSError as error:
|
|
102
|
+
raise ResolutionError(f"cannot inspect '{relative}': {error}") from error
|
|
103
|
+
if not stat.S_ISREG(mode) or path.is_symlink():
|
|
104
|
+
raise ResolutionError(f"'{relative}' is not a regular non-link file")
|
|
105
|
+
return relative, path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_bytes(label, raw):
|
|
109
|
+
if BOM in raw:
|
|
110
|
+
raise ResolutionError(f"{label} contains a UTF-8 BOM")
|
|
111
|
+
if b"\x00" in raw:
|
|
112
|
+
raise ResolutionError(f"{label} contains a NUL byte")
|
|
113
|
+
if b"\r" in raw:
|
|
114
|
+
raise ResolutionError(f"{label} is not canonical LF text")
|
|
115
|
+
try:
|
|
116
|
+
raw.decode("utf-8", errors="strict")
|
|
117
|
+
except UnicodeDecodeError as error:
|
|
118
|
+
raise ResolutionError(f"{label} is not valid UTF-8") from error
|
|
119
|
+
if raw and not raw.endswith(b"\n"):
|
|
120
|
+
raise ResolutionError(f"{label} does not end with LF")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _marker_run(line, marker):
|
|
124
|
+
if not line.endswith(b"\n"):
|
|
125
|
+
return 0
|
|
126
|
+
body = line[:-1]
|
|
127
|
+
size = 0
|
|
128
|
+
while size < len(body) and body[size : size + 1] == marker:
|
|
129
|
+
size += 1
|
|
130
|
+
if size < 7 or (len(body) > size and body[size : size + 1] != b" "):
|
|
131
|
+
return 0
|
|
132
|
+
return size
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_conflicted_worktree(raw, base, ours_suffix, theirs_suffix):
|
|
136
|
+
"""Prove the worktree is only Git's rendering of the three index stages."""
|
|
137
|
+
# Git for Windows may render conflict markers and checked-out blob lines as
|
|
138
|
+
# CRLF even though every index blob is canonical LF. Normalize only complete
|
|
139
|
+
# CRLF pairs for comparison; lone CR remains invalid and the original bytes
|
|
140
|
+
# stay untouched for rollback.
|
|
141
|
+
normalized = raw.replace(b"\r\n", b"\n")
|
|
142
|
+
_validate_bytes("conflicted worktree", normalized)
|
|
143
|
+
if not normalized.startswith(base):
|
|
144
|
+
raise ResolutionError("conflicted worktree does not preserve the merge-base prefix")
|
|
145
|
+
lines = normalized[len(base) :].splitlines(keepends=True)
|
|
146
|
+
starts = [(index, _marker_run(line, b"<")) for index, line in enumerate(lines)]
|
|
147
|
+
starts = [(index, size) for index, size in starts if size]
|
|
148
|
+
if len(starts) != 1:
|
|
149
|
+
raise ResolutionError("conflicted worktree is not one recognized Git conflict rendering")
|
|
150
|
+
start, marker_size = starts[0]
|
|
151
|
+
separator_line = (b"=" * marker_size) + b"\n"
|
|
152
|
+
separators = [index for index, line in enumerate(lines) if line == separator_line]
|
|
153
|
+
ends = [
|
|
154
|
+
index
|
|
155
|
+
for index, line in enumerate(lines)
|
|
156
|
+
if _marker_run(line, b">") == marker_size
|
|
157
|
+
]
|
|
158
|
+
base_markers = [
|
|
159
|
+
index
|
|
160
|
+
for index, line in enumerate(lines)
|
|
161
|
+
if _marker_run(line, b"|") == marker_size
|
|
162
|
+
]
|
|
163
|
+
if len(separators) != 1 or len(ends) != 1 or len(base_markers) > 1:
|
|
164
|
+
raise ResolutionError("conflicted worktree has ambiguous Git conflict markers")
|
|
165
|
+
separator = separators[0]
|
|
166
|
+
end = ends[0]
|
|
167
|
+
base_marker = base_markers[0] if base_markers else None
|
|
168
|
+
ours_end = base_marker if base_marker is not None else separator
|
|
169
|
+
if not (start < ours_end <= separator < end):
|
|
170
|
+
raise ResolutionError("conflicted worktree has out-of-order Git conflict markers")
|
|
171
|
+
if base_marker is not None and b"".join(lines[base_marker + 1 : separator]):
|
|
172
|
+
raise ResolutionError("conflicted worktree has unexpected merge-base conflict bytes")
|
|
173
|
+
|
|
174
|
+
common_prefix = b"".join(lines[:start])
|
|
175
|
+
ours_body = b"".join(lines[start + 1 : ours_end])
|
|
176
|
+
theirs_body = b"".join(lines[separator + 1 : end])
|
|
177
|
+
common_suffix = b"".join(lines[end + 1 :])
|
|
178
|
+
if ours_suffix != common_prefix + ours_body + common_suffix:
|
|
179
|
+
raise ResolutionError("conflicted worktree differs from the current-branch index stage")
|
|
180
|
+
if theirs_suffix != common_prefix + theirs_body + common_suffix:
|
|
181
|
+
raise ResolutionError("conflicted worktree differs from the incoming-branch index stage")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _unmerged(root, relative, *, allow_resolved=False):
|
|
185
|
+
result = _git(root, "ls-files", "-u", "-z", "--", relative)
|
|
186
|
+
records = []
|
|
187
|
+
for item in result.stdout.split(b"\0"):
|
|
188
|
+
if not item:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
metadata, encoded_path = item.split(b"\t", 1)
|
|
192
|
+
mode, oid, stage = metadata.decode("ascii").split(" ")
|
|
193
|
+
path = encoded_path.decode("utf-8", errors="strict")
|
|
194
|
+
records.append((mode, oid, int(stage), path))
|
|
195
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
196
|
+
raise ResolutionError("malformed unmerged index record") from error
|
|
197
|
+
if allow_resolved and not records:
|
|
198
|
+
return []
|
|
199
|
+
if len(records) != 3 or [record[2] for record in records] != [1, 2, 3]:
|
|
200
|
+
raise ResolutionError("target is not in a complete three-stage conflict")
|
|
201
|
+
if any(record[3] != relative for record in records):
|
|
202
|
+
raise ResolutionError("unmerged index path does not match the requested target")
|
|
203
|
+
if any(record[0] != "100644" for record in records):
|
|
204
|
+
raise ResolutionError("append-only conflict stages must all be regular 100644 blobs")
|
|
205
|
+
return records
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _tracked_index(root, relative):
|
|
209
|
+
result = _git(root, "ls-files", "--stage", "-z", "--", relative)
|
|
210
|
+
records = []
|
|
211
|
+
for item in result.stdout.split(b"\0"):
|
|
212
|
+
if not item:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
metadata, encoded_path = item.split(b"\t", 1)
|
|
216
|
+
mode, oid, stage = metadata.decode("ascii").split(" ")
|
|
217
|
+
path = encoded_path.decode("utf-8", errors="strict")
|
|
218
|
+
records.append((mode, oid, int(stage), path))
|
|
219
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
220
|
+
raise ResolutionError("malformed tracked index record") from error
|
|
221
|
+
return records
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _blob(root, oid, label):
|
|
225
|
+
result = _git(root, "cat-file", "blob", oid)
|
|
226
|
+
raw = result.stdout
|
|
227
|
+
_validate_bytes(label, raw)
|
|
228
|
+
return raw
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _authorization(root, reason):
|
|
232
|
+
reason = reason.strip()
|
|
233
|
+
if not reason:
|
|
234
|
+
raise ResolutionError("--reason must contain a non-empty reason")
|
|
235
|
+
if len(reason) > MAX_REASON or "|" in reason or "\n" in reason or "\r" in reason:
|
|
236
|
+
raise ResolutionError("--reason must be at most 500 characters with no pipes or newlines")
|
|
237
|
+
result = _git(root, "config", "--get", "user.email", check=False)
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
raise ResolutionError("git config user.email is required for the attributed override")
|
|
240
|
+
try:
|
|
241
|
+
identity = result.stdout.decode("utf-8", errors="strict").strip()
|
|
242
|
+
except UnicodeDecodeError as error:
|
|
243
|
+
raise ResolutionError("git config user.email is not valid UTF-8") from error
|
|
244
|
+
if not identity or len(identity) > 254 or "|" in identity or "\n" in identity or "\r" in identity:
|
|
245
|
+
raise ResolutionError("git config user.email is not a valid override identity")
|
|
246
|
+
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
247
|
+
return f"[{timestamp}] | BY: {identity} | GATE: H-05 | REASON: {reason}\n".encode("utf-8")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _append_parts(root, records):
|
|
251
|
+
base = _blob(root, records[0][1], "merge base")
|
|
252
|
+
ours = _blob(root, records[1][1], "current branch")
|
|
253
|
+
theirs = _blob(root, records[2][1], "incoming branch")
|
|
254
|
+
if not ours.startswith(base):
|
|
255
|
+
raise ResolutionError("current branch is not append-only relative to the merge base")
|
|
256
|
+
if not theirs.startswith(base):
|
|
257
|
+
raise ResolutionError("incoming branch is not append-only relative to the merge base")
|
|
258
|
+
ours_suffix = ours[len(base) :]
|
|
259
|
+
theirs_suffix = theirs[len(base) :]
|
|
260
|
+
if not ours_suffix or not theirs_suffix:
|
|
261
|
+
raise ResolutionError("both conflict sides must append non-empty history")
|
|
262
|
+
return base, ours_suffix, theirs_suffix
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _build_union(root, records):
|
|
266
|
+
base, ours_suffix, theirs_suffix = _append_parts(root, records)
|
|
267
|
+
merged = base + ours_suffix + theirs_suffix
|
|
268
|
+
_validate_bytes("resolved output", merged)
|
|
269
|
+
return merged
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _sync_parent(path):
|
|
273
|
+
if os.name == "nt":
|
|
274
|
+
return False
|
|
275
|
+
flags = os.O_RDONLY
|
|
276
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
277
|
+
flags |= os.O_DIRECTORY
|
|
278
|
+
parent_fd = os.open(path.parent, flags)
|
|
279
|
+
try:
|
|
280
|
+
os.fsync(parent_fd)
|
|
281
|
+
finally:
|
|
282
|
+
os.close(parent_fd)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _write_bytes_atomic(path, raw, *, expected=None):
|
|
287
|
+
original_mode = stat.S_IMODE(path.stat().st_mode)
|
|
288
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
|
|
289
|
+
witness = None
|
|
290
|
+
try:
|
|
291
|
+
with os.fdopen(fd, "wb") as handle:
|
|
292
|
+
handle.write(raw)
|
|
293
|
+
handle.flush()
|
|
294
|
+
os.fsync(handle.fileno())
|
|
295
|
+
os.chmod(temporary, original_mode)
|
|
296
|
+
if expected is not None:
|
|
297
|
+
witness_fd, witness = tempfile.mkstemp(
|
|
298
|
+
dir=path.parent, prefix=path.name + ".", suffix=".witness"
|
|
299
|
+
)
|
|
300
|
+
os.close(witness_fd)
|
|
301
|
+
os.remove(witness)
|
|
302
|
+
os.link(path, witness)
|
|
303
|
+
observed = Path(witness).read_bytes()
|
|
304
|
+
if observed != expected:
|
|
305
|
+
raise ConcurrentAppendError(path, restored=True, observed=observed)
|
|
306
|
+
os.replace(temporary, path)
|
|
307
|
+
temporary = None
|
|
308
|
+
_sync_parent(path)
|
|
309
|
+
if witness is not None:
|
|
310
|
+
displaced = Path(witness).read_bytes()
|
|
311
|
+
if displaced != expected:
|
|
312
|
+
restored = False
|
|
313
|
+
try:
|
|
314
|
+
os.replace(witness, path)
|
|
315
|
+
witness = None
|
|
316
|
+
_sync_parent(path)
|
|
317
|
+
restored = True
|
|
318
|
+
finally:
|
|
319
|
+
raise ConcurrentAppendError(path, restored=restored, observed=displaced)
|
|
320
|
+
os.remove(witness)
|
|
321
|
+
witness = None
|
|
322
|
+
except Exception:
|
|
323
|
+
if temporary is not None:
|
|
324
|
+
try:
|
|
325
|
+
os.remove(temporary)
|
|
326
|
+
except OSError:
|
|
327
|
+
pass
|
|
328
|
+
if witness is not None:
|
|
329
|
+
try:
|
|
330
|
+
os.remove(witness)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _restore_index(root, relative, records):
|
|
337
|
+
removed = _git(root, "update-index", "--force-remove", "--", relative, check=False)
|
|
338
|
+
if removed.returncode != 0:
|
|
339
|
+
detail = removed.stderr.decode("utf-8", errors="replace").strip()
|
|
340
|
+
raise ResolutionError(f"could not clear staged resolution during rollback: {detail}")
|
|
341
|
+
index_info = b"".join(
|
|
342
|
+
f"{mode} {oid} {stage}\t{path}\n".encode("utf-8")
|
|
343
|
+
for mode, oid, stage, path in records
|
|
344
|
+
)
|
|
345
|
+
_git(root, "update-index", "--index-info", input_bytes=index_info)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def resolve(target, reason, cwd=None):
|
|
349
|
+
root = _repository_root(cwd or os.getcwd())
|
|
350
|
+
relative, path = _relative_target(root, target)
|
|
351
|
+
records = _unmerged(root, relative)
|
|
352
|
+
authorization = _authorization(root, reason)
|
|
353
|
+
base, ours_suffix, theirs_suffix = _append_parts(root, records)
|
|
354
|
+
proposed = base + ours_suffix + theirs_suffix
|
|
355
|
+
_validate_bytes("resolved output", proposed)
|
|
356
|
+
changes = {relative: proposed}
|
|
357
|
+
paths = {relative: path}
|
|
358
|
+
originals = {relative: path.read_bytes()}
|
|
359
|
+
_validate_conflicted_worktree(originals[relative], base, ours_suffix, theirs_suffix)
|
|
360
|
+
index_records = {relative: records}
|
|
361
|
+
override_relative = ".codearbiter/overrides.log"
|
|
362
|
+
if relative == override_relative:
|
|
363
|
+
changes[relative] += authorization
|
|
364
|
+
else:
|
|
365
|
+
override_relative, override_path = _relative_target(root, override_relative)
|
|
366
|
+
if _unmerged(root, override_relative, allow_resolved=True):
|
|
367
|
+
raise ResolutionError("the override audit sink is itself unmerged")
|
|
368
|
+
for diff_args in (("diff", "--quiet"), ("diff", "--cached", "--quiet")):
|
|
369
|
+
clean = _git(root, *diff_args, "--", override_relative, check=False)
|
|
370
|
+
if clean.returncode != 0:
|
|
371
|
+
raise ResolutionError("the override audit sink must be clean before resolution")
|
|
372
|
+
override_raw = override_path.read_bytes()
|
|
373
|
+
_validate_bytes("override audit sink", override_raw)
|
|
374
|
+
changes[override_relative] = override_raw + authorization
|
|
375
|
+
paths[override_relative] = override_path
|
|
376
|
+
originals[override_relative] = override_raw
|
|
377
|
+
index_records[override_relative] = _tracked_index(root, override_relative)
|
|
378
|
+
try:
|
|
379
|
+
common_raw = _git(root, "rev-parse", "--git-common-dir").stdout.decode(
|
|
380
|
+
"utf-8", errors="strict"
|
|
381
|
+
).strip()
|
|
382
|
+
except UnicodeDecodeError as error:
|
|
383
|
+
raise ResolutionError("git returned an invalid UTF-8 common directory") from error
|
|
384
|
+
if not common_raw:
|
|
385
|
+
raise ResolutionError("git returned an empty common directory")
|
|
386
|
+
common = Path(common_raw)
|
|
387
|
+
if not common.is_absolute():
|
|
388
|
+
common = root / common
|
|
389
|
+
lock = acquire_lock(str(common.resolve() / "codearbiter-append-only-conflict"))
|
|
390
|
+
if lock is None:
|
|
391
|
+
raise ResolutionError("another append-only conflict resolution holds the repository lock")
|
|
392
|
+
path_locks = []
|
|
393
|
+
try:
|
|
394
|
+
for changed_relative in sorted(changes):
|
|
395
|
+
path_lock = acquire_lock(audit_lock_key(root, paths[changed_relative]))
|
|
396
|
+
if path_lock is None:
|
|
397
|
+
raise ResolutionError(
|
|
398
|
+
f"another writer holds the audit path lock for '{changed_relative}'"
|
|
399
|
+
)
|
|
400
|
+
path_locks.append(path_lock)
|
|
401
|
+
if _unmerged(root, relative) != records or path.read_bytes() != originals[relative]:
|
|
402
|
+
raise ResolutionError("conflict state changed during validation; retry from fresh evidence")
|
|
403
|
+
try:
|
|
404
|
+
for changed_relative, raw in changes.items():
|
|
405
|
+
if paths[changed_relative].read_bytes() != originals[changed_relative]:
|
|
406
|
+
raise ResolutionError("protected state changed during validation; retry")
|
|
407
|
+
_write_bytes_atomic(
|
|
408
|
+
paths[changed_relative], raw, expected=originals[changed_relative]
|
|
409
|
+
)
|
|
410
|
+
staged = _git(root, "add", "--", *changes.keys(), check=False)
|
|
411
|
+
if staged.returncode != 0:
|
|
412
|
+
detail = staged.stderr.decode("utf-8", errors="replace").strip()
|
|
413
|
+
raise ResolutionError(f"git add failed: {detail or 'unknown error'}")
|
|
414
|
+
if _unmerged(root, relative, allow_resolved=True):
|
|
415
|
+
raise ResolutionError("git add did not clear the three-stage conflict")
|
|
416
|
+
for changed_relative, raw in changes.items():
|
|
417
|
+
index_blob = _git(root, "show", f":0:{changed_relative}").stdout
|
|
418
|
+
if index_blob != raw:
|
|
419
|
+
raise ResolutionError(
|
|
420
|
+
f"staged blob does not match validated output for {changed_relative}"
|
|
421
|
+
)
|
|
422
|
+
except Exception as error:
|
|
423
|
+
# The replacement is atomic; if staging or verification fails, put
|
|
424
|
+
# the exact conflict-marker worktree bytes and three index stages
|
|
425
|
+
# back. This also covers a successful `git add` followed by a
|
|
426
|
+
# failed staged-blob verification.
|
|
427
|
+
for changed_relative, raw in originals.items():
|
|
428
|
+
if isinstance(error, ConcurrentAppendError) and paths[changed_relative] == error.path:
|
|
429
|
+
if error.restored:
|
|
430
|
+
continue
|
|
431
|
+
if error.observed is not None:
|
|
432
|
+
raw = error.observed
|
|
433
|
+
_write_bytes_atomic(paths[changed_relative], raw)
|
|
434
|
+
for changed_relative, prior_records in index_records.items():
|
|
435
|
+
_restore_index(root, changed_relative, prior_records)
|
|
436
|
+
raise
|
|
437
|
+
finally:
|
|
438
|
+
for path_lock in reversed(path_locks):
|
|
439
|
+
release_lock(path_lock)
|
|
440
|
+
release_lock(lock)
|
|
441
|
+
return relative
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def main(argv=None):
|
|
445
|
+
parser = argparse.ArgumentParser(
|
|
446
|
+
description="Resolve one genuine H-05 append-only Git conflict without weakening the guard."
|
|
447
|
+
)
|
|
448
|
+
parser.add_argument("target", help="repository-relative protected append-only path")
|
|
449
|
+
parser.add_argument("--reason", required=True, help="specific immediate H-05 override reason")
|
|
450
|
+
args = parser.parse_args(argv)
|
|
451
|
+
try:
|
|
452
|
+
relative = resolve(args.target, args.reason)
|
|
453
|
+
except (OSError, ResolutionError) as error:
|
|
454
|
+
print(f"append-only conflict resolution refused: {error}", file=sys.stderr)
|
|
455
|
+
return 1
|
|
456
|
+
print(f"resolved and staged append-only conflict: {relative}")
|
|
457
|
+
return 0
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
raise SystemExit(main())
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
# non-blocking + bounded LOCK_WAIT retry spin,
|
|
97
97
|
# fail-soft None on contention/timeout/OSError
|
|
98
98
|
# release_lock(handle) -> None release + close; None handle is a no-op
|
|
99
|
+
# audit_lock_key(root, path) -> str trusted Git-owned key for an audit path
|
|
99
100
|
# block(tag, msg) -> None BLOCK tool call: print to stderr and exit 2
|
|
100
101
|
# remind(tag, msg) -> None non-blocking nudge to stderr
|
|
101
102
|
# warn(msg) -> None loud degradation breadcrumb to stderr
|
|
@@ -105,6 +106,7 @@
|
|
|
105
106
|
|
|
106
107
|
import datetime
|
|
107
108
|
import errno
|
|
109
|
+
import hashlib
|
|
108
110
|
import json
|
|
109
111
|
import os
|
|
110
112
|
import re
|
|
@@ -216,8 +218,10 @@ from _sensitivelib import ( # noqa: F401
|
|
|
216
218
|
)
|
|
217
219
|
|
|
218
220
|
|
|
219
|
-
# Serialize same-process
|
|
220
|
-
|
|
221
|
+
# Serialize same-process writers before taking the bounded cross-process lock.
|
|
222
|
+
# POSIX flock and Windows byte-range locks are process-scoped, so sibling
|
|
223
|
+
# threads can otherwise burn the shared wait budget contending with each other.
|
|
224
|
+
_GATE_EVENTS_PROCESS_LOCK = threading.Lock()
|
|
221
225
|
_WINDOWS_LOCK_TIMEOUT_SECONDS = 5.0
|
|
222
226
|
_WINDOWS_LOCK_RETRY_SECONDS = 0.01
|
|
223
227
|
_WINDOWS_CRT_EDEADLK = 36
|
|
@@ -409,6 +413,63 @@ def release_lock(handle):
|
|
|
409
413
|
pass
|
|
410
414
|
|
|
411
415
|
|
|
416
|
+
def audit_lock_key(root, path):
|
|
417
|
+
"""Return a deterministic audit lock key beneath Git-owned metadata.
|
|
418
|
+
|
|
419
|
+
Audit paths and their siblings are repository-controlled, so an adjacent
|
|
420
|
+
lock can be a checked-out symlink that redirects acquire_lock's seed byte
|
|
421
|
+
outside the checkout. Linked worktrees resolve to their common Git
|
|
422
|
+
directory and therefore share one trusted key per repository-relative path.
|
|
423
|
+
"""
|
|
424
|
+
root = os.path.abspath(os.fspath(root))
|
|
425
|
+
path = os.path.abspath(os.fspath(path))
|
|
426
|
+
try:
|
|
427
|
+
if os.path.normcase(os.path.commonpath((root, path))) != os.path.normcase(root):
|
|
428
|
+
raise ValueError
|
|
429
|
+
except (OSError, ValueError) as error:
|
|
430
|
+
raise OSError("audit lock target is outside the repository root") from error
|
|
431
|
+
|
|
432
|
+
dotgit = os.path.join(root, ".git")
|
|
433
|
+
if os.path.isdir(dotgit):
|
|
434
|
+
common = dotgit
|
|
435
|
+
elif os.path.isfile(dotgit):
|
|
436
|
+
try:
|
|
437
|
+
with open(dotgit, encoding="utf-8") as handle:
|
|
438
|
+
pointer = handle.read(4097)
|
|
439
|
+
except OSError as error:
|
|
440
|
+
raise OSError("cannot read linked-worktree Git metadata") from error
|
|
441
|
+
if len(pointer) > 4096 or not pointer.startswith("gitdir:"):
|
|
442
|
+
raise OSError("linked-worktree Git metadata is malformed")
|
|
443
|
+
gitdir = pointer[7:].strip()
|
|
444
|
+
if not gitdir:
|
|
445
|
+
raise OSError("linked-worktree Git directory is empty")
|
|
446
|
+
if not os.path.isabs(gitdir):
|
|
447
|
+
gitdir = os.path.join(root, gitdir)
|
|
448
|
+
commondir_file = os.path.join(gitdir, "commondir")
|
|
449
|
+
if os.path.isfile(commondir_file):
|
|
450
|
+
try:
|
|
451
|
+
with open(commondir_file, encoding="utf-8") as handle:
|
|
452
|
+
relative_common = handle.read(4097).strip()
|
|
453
|
+
except OSError as error:
|
|
454
|
+
raise OSError("cannot read Git common-directory metadata") from error
|
|
455
|
+
if not relative_common or len(relative_common) > 4096:
|
|
456
|
+
raise OSError("Git common-directory metadata is malformed")
|
|
457
|
+
common = relative_common if os.path.isabs(relative_common) else os.path.join(
|
|
458
|
+
gitdir, relative_common
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
common = gitdir
|
|
462
|
+
else:
|
|
463
|
+
raise OSError("repository Git metadata is unavailable for audit locking")
|
|
464
|
+
|
|
465
|
+
common = os.path.realpath(common)
|
|
466
|
+
if not os.path.isdir(common):
|
|
467
|
+
raise OSError("Git common directory is unavailable for audit locking")
|
|
468
|
+
relative = os.path.relpath(path, root).replace("\\", "/")
|
|
469
|
+
digest = hashlib.sha256(relative.encode("utf-8")).hexdigest()
|
|
470
|
+
return os.path.join(common, "codearbiter-audit-locks", digest)
|
|
471
|
+
|
|
472
|
+
|
|
412
473
|
# The H-11 authoring-marker freshness window (issue #567) — the single
|
|
413
474
|
# declaration every `marker_fresh(marker, minutes)` call site resolves by
|
|
414
475
|
# import. Previously five independent `30` literals (pre-write.py,
|
|
@@ -469,17 +530,27 @@ def _log_gate_event(kind, tag, msg):
|
|
|
469
530
|
host = "unknown"
|
|
470
531
|
tag_part = f"[{tag}] " if tag else ""
|
|
471
532
|
line = f"[{ts}] {kind} {tag_part}host={host} hook={hook} | {msg}\n"
|
|
533
|
+
log_path = os.path.join(cad, "gate-events.log")
|
|
472
534
|
flags = os.O_APPEND | os.O_CREAT | os.O_WRONLY
|
|
473
535
|
if hasattr(os, "O_BINARY"):
|
|
474
536
|
flags |= os.O_BINARY
|
|
475
537
|
process_lock_acquired = False
|
|
476
|
-
|
|
477
|
-
|
|
538
|
+
try:
|
|
539
|
+
_GATE_EVENTS_PROCESS_LOCK.acquire()
|
|
478
540
|
process_lock_acquired = True
|
|
541
|
+
audit_lock = acquire_lock(audit_lock_key(root, log_path))
|
|
542
|
+
except Exception:
|
|
543
|
+
if process_lock_acquired:
|
|
544
|
+
_GATE_EVENTS_PROCESS_LOCK.release()
|
|
545
|
+
raise
|
|
546
|
+
if audit_lock is None:
|
|
547
|
+
if process_lock_acquired:
|
|
548
|
+
_GATE_EVENTS_PROCESS_LOCK.release()
|
|
549
|
+
return
|
|
479
550
|
fd = None
|
|
480
551
|
os_lock_acquired = False
|
|
481
552
|
try:
|
|
482
|
-
fd = os.open(
|
|
553
|
+
fd = os.open(log_path, flags, 0o600)
|
|
483
554
|
if os.name == "nt":
|
|
484
555
|
import msvcrt
|
|
485
556
|
os.lseek(fd, 0, os.SEEK_SET)
|
|
@@ -504,10 +575,15 @@ def _log_gate_event(kind, tag, msg):
|
|
|
504
575
|
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
505
576
|
except Exception: # noqa: BLE001 — outer sink remains fail-open
|
|
506
577
|
pass
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
578
|
+
try:
|
|
579
|
+
if fd is not None:
|
|
580
|
+
os.close(fd)
|
|
581
|
+
finally:
|
|
582
|
+
try:
|
|
583
|
+
release_lock(audit_lock)
|
|
584
|
+
finally:
|
|
585
|
+
if process_lock_acquired:
|
|
586
|
+
_GATE_EVENTS_PROCESS_LOCK.release()
|
|
511
587
|
except Exception: # noqa: BLE001 — fail-open: the sink must never affect the gate
|
|
512
588
|
pass
|
|
513
589
|
|
|
@@ -11,7 +11,7 @@ import hostapi # noqa: E402
|
|
|
11
11
|
class PiHost(hostapi.Host):
|
|
12
12
|
name = "pi"
|
|
13
13
|
adapter_name = "@arbiterforge/ca-pi"
|
|
14
|
-
adapter_version = "0.10.
|
|
14
|
+
adapter_version = "0.10.12"
|
|
15
15
|
update_target = "ca-pi"
|
|
16
16
|
update_tag_prefix = "ca-pi-v"
|
|
17
17
|
update_command = "pi update npm:@arbiterforge/ca-pi"
|
|
@@ -25,7 +25,7 @@ import os
|
|
|
25
25
|
import re
|
|
26
26
|
|
|
27
27
|
from _activationlib import marker_root
|
|
28
|
-
from _hooklib import write_text_atomic
|
|
28
|
+
from _hooklib import acquire_lock, audit_lock_key, release_lock, write_text_atomic
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
# ---------------------------------------------------------------------------
|
|
@@ -645,12 +645,21 @@ def _overrides_has_line(root, line):
|
|
|
645
645
|
|
|
646
646
|
def _append_override_line(root, line):
|
|
647
647
|
"""Append one audit line to overrides.log. True on a confirmed write."""
|
|
648
|
+
lock = None
|
|
648
649
|
try:
|
|
649
|
-
|
|
650
|
+
path = _overrides_log_path(root)
|
|
651
|
+
lock = acquire_lock(audit_lock_key(root, path))
|
|
652
|
+
if lock is None:
|
|
653
|
+
return False
|
|
654
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
650
655
|
f.write(line)
|
|
656
|
+
f.flush()
|
|
657
|
+
os.fsync(f.fileno())
|
|
651
658
|
return True
|
|
652
|
-
except
|
|
659
|
+
except Exception: # noqa: BLE001 — mode audit settlement remains fail-soft
|
|
653
660
|
return False
|
|
661
|
+
finally:
|
|
662
|
+
release_lock(lock)
|
|
654
663
|
|
|
655
664
|
|
|
656
665
|
def _dev_dropped_close_note(count, host_name=None):
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed, cooperatively locked append for mandatory override records."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from _gitexec import git_executable
|
|
16
|
+
from _hooklib import acquire_lock, audit_lock_key, release_lock
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAX_FIELD = 500
|
|
20
|
+
GATE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OverrideAppendError(RuntimeError):
|
|
24
|
+
"""The mandatory audit row could not be safely and durably appended."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _git(cwd, *args):
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
[git_executable(), *args], cwd=cwd, stdout=subprocess.PIPE,
|
|
31
|
+
stderr=subprocess.PIPE, check=False,
|
|
32
|
+
)
|
|
33
|
+
except OSError as error:
|
|
34
|
+
raise OverrideAppendError(f"git unavailable: {error}") from error
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
37
|
+
raise OverrideAppendError(f"git {' '.join(args)} failed: {detail or 'unknown error'}")
|
|
38
|
+
return result.stdout
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _root(cwd):
|
|
42
|
+
try:
|
|
43
|
+
raw = _git(cwd, "rev-parse", "--show-toplevel").decode("utf-8", errors="strict").strip()
|
|
44
|
+
except UnicodeDecodeError as error:
|
|
45
|
+
raise OverrideAppendError("git returned an invalid UTF-8 repository root") from error
|
|
46
|
+
if not raw:
|
|
47
|
+
raise OverrideAppendError("git returned an empty repository root")
|
|
48
|
+
return Path(raw).resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _field(label, value):
|
|
52
|
+
if not value or len(value) > MAX_FIELD or any(char in value for char in "|\r\n"):
|
|
53
|
+
raise OverrideAppendError(
|
|
54
|
+
f"{label} must be non-empty, at most {MAX_FIELD} characters, and contain no pipe or newline"
|
|
55
|
+
)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _identity(root):
|
|
60
|
+
try:
|
|
61
|
+
value = _git(root, "config", "user.email").decode("utf-8", errors="strict").strip()
|
|
62
|
+
except UnicodeDecodeError as error:
|
|
63
|
+
raise OverrideAppendError("git config user.email is not valid UTF-8") from error
|
|
64
|
+
_field("git config user.email", value)
|
|
65
|
+
if len(value) > 254 or any(char.isspace() for char in value):
|
|
66
|
+
raise OverrideAppendError("git config user.email is not a valid override identity")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _validate_file_type(path):
|
|
71
|
+
try:
|
|
72
|
+
mode = path.lstat().st_mode
|
|
73
|
+
except OSError as error:
|
|
74
|
+
raise OverrideAppendError(f"cannot inspect overrides.log: {error}") from error
|
|
75
|
+
if not stat.S_ISREG(mode) or path.is_symlink():
|
|
76
|
+
raise OverrideAppendError("overrides.log is not a regular non-link file")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_existing(path, raw):
|
|
80
|
+
_validate_file_type(path)
|
|
81
|
+
if raw.startswith(b"\xef\xbb\xbf") or b"\x00" in raw or b"\r" in raw:
|
|
82
|
+
raise OverrideAppendError("overrides.log is not canonical UTF-8 LF text")
|
|
83
|
+
try:
|
|
84
|
+
raw.decode("utf-8", errors="strict")
|
|
85
|
+
except UnicodeDecodeError as error:
|
|
86
|
+
raise OverrideAppendError("overrides.log is not valid UTF-8") from error
|
|
87
|
+
if raw and not raw.endswith(b"\n"):
|
|
88
|
+
raise OverrideAppendError("overrides.log does not end with LF")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def append_override(*, gate=None, security_finding=None, reason, cwd=None):
|
|
92
|
+
root = _root(cwd or os.getcwd())
|
|
93
|
+
path = root / ".codearbiter" / "overrides.log"
|
|
94
|
+
_validate_file_type(path)
|
|
95
|
+
before = path.read_bytes()
|
|
96
|
+
_validate_existing(path, before)
|
|
97
|
+
identity = _identity(root)
|
|
98
|
+
reason = _field("reason", reason)
|
|
99
|
+
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
100
|
+
if gate is not None:
|
|
101
|
+
if not GATE_RE.fullmatch(gate):
|
|
102
|
+
raise OverrideAppendError("gate must be a compact gate identifier")
|
|
103
|
+
body = f"GATE: {gate}"
|
|
104
|
+
else:
|
|
105
|
+
body = f"SECURITY-OVERRIDE | FINDING: {_field('security finding', security_finding)}"
|
|
106
|
+
line = f"[{timestamp}] | BY: {identity} | {body} | REASON: {reason}\n".encode("utf-8")
|
|
107
|
+
|
|
108
|
+
lock = acquire_lock(audit_lock_key(root, path))
|
|
109
|
+
if lock is None:
|
|
110
|
+
raise OverrideAppendError("another writer holds the overrides.log audit path lock")
|
|
111
|
+
fd = None
|
|
112
|
+
try:
|
|
113
|
+
if path.read_bytes() != before:
|
|
114
|
+
raise OverrideAppendError("overrides.log changed during validation; retry")
|
|
115
|
+
flags = os.O_APPEND | os.O_WRONLY
|
|
116
|
+
if hasattr(os, "O_BINARY"):
|
|
117
|
+
flags |= os.O_BINARY
|
|
118
|
+
fd = os.open(path, flags)
|
|
119
|
+
offset = 0
|
|
120
|
+
while offset < len(line):
|
|
121
|
+
written = os.write(fd, line[offset:])
|
|
122
|
+
if written <= 0:
|
|
123
|
+
raise OverrideAppendError("override audit append made no progress")
|
|
124
|
+
offset += written
|
|
125
|
+
os.fsync(fd)
|
|
126
|
+
except OSError as error:
|
|
127
|
+
raise OverrideAppendError(f"override audit append failed: {error}") from error
|
|
128
|
+
finally:
|
|
129
|
+
try:
|
|
130
|
+
if fd is not None:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
finally:
|
|
133
|
+
release_lock(lock)
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv=None):
|
|
138
|
+
parser = argparse.ArgumentParser(description="Append one mandatory override audit row safely.")
|
|
139
|
+
kind = parser.add_mutually_exclusive_group(required=True)
|
|
140
|
+
kind.add_argument("--gate", help="gate identifier for an ordinary override")
|
|
141
|
+
kind.add_argument("--security-finding", help="specific acknowledged security finding")
|
|
142
|
+
parser.add_argument("--reason", required=True, help="specific justification")
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
try:
|
|
145
|
+
path = append_override(
|
|
146
|
+
gate=args.gate,
|
|
147
|
+
security_finding=args.security_finding,
|
|
148
|
+
reason=args.reason,
|
|
149
|
+
)
|
|
150
|
+
except (OSError, OverrideAppendError) as error:
|
|
151
|
+
print(f"override audit append refused: {error}", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
print(f"appended mandatory override audit row: {path}")
|
|
154
|
+
return 0
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.17.
|
|
316
|
+
adapter_version = "2.17.10"
|
|
317
317
|
|
|
318
318
|
# Update-notifier descriptor. Each independently versioned host overrides
|
|
319
319
|
# these three values in its per-plugin _host.py. Keeping the target,
|
|
@@ -74,7 +74,7 @@ file maps those actions to Pi's extension API.
|
|
|
74
74
|
operator-launched Pi CLI, imported module, package root, and reported version.
|
|
75
75
|
It does not prove publisher authenticity. Verify the source separately with
|
|
76
76
|
`pi list` and `pi config`.
|
|
77
|
-
-
|
|
77
|
+
- The supported promotion target is exact Pi 0.84.1. The package is
|
|
78
78
|
published as `npm:@arbiterforge/ca-pi` on every `ca-pi-v*` tag (ADR-0029);
|
|
79
79
|
the pinned Git tag remains the reproducible install. A Pi-native embedded
|
|
80
80
|
farm worker is a future spike, not an installed dependency.
|
|
@@ -21,7 +21,7 @@ remediation.
|
|
|
21
21
|
Pi bash wrapper. The exact shared-core `[H-03]` block is healthy and cannot stage files; execution
|
|
22
22
|
or a different block is unhealthy. This self-test does not traverse Pi's active dispatcher. Do not
|
|
23
23
|
rerun or respell it.
|
|
24
|
-
4. Its `active-dispatch` row remains degraded because supported Pi 0.
|
|
24
|
+
4. Its `active-dispatch` row remains degraded because supported Pi 0.84.1 public extension
|
|
25
25
|
APIs cannot submit that deterministic call through the active dispatcher. PI-AC-28 remains blocked
|
|
26
26
|
until supported-version real-host promotion/CI evidence closes the gap.
|
|
27
27
|
|
|
@@ -15,14 +15,40 @@ logged, always visible, never silent. Single identity, single confirm.
|
|
|
15
15
|
vague reason ("just skip it") and ask for a specific one.
|
|
16
16
|
2. Detect the operator identity from `git config user.email` only. If it is unset, ask the user once
|
|
17
17
|
to state their identity for the log. (No platform ladder, no second confirmation.)
|
|
18
|
-
3.
|
|
18
|
+
3. For an ordinary override, use the private locked audit helper from the repository root:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
python3 "<plugin-root>/hooks/append-override.py" --gate "<gate bypassed>" --reason "<specific reason>"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It appends one line to `<project-root>/.codearbiter/overrides.log`:
|
|
19
25
|
|
|
20
26
|
```
|
|
21
27
|
[ISO-8601 timestamp] | BY: <email> | GATE: <gate bypassed> | REASON: <reason>
|
|
22
28
|
```
|
|
23
29
|
|
|
24
|
-
The log is append-only — never edited or deleted, committed as a permanent audit artifact.
|
|
25
|
-
|
|
30
|
+
The log is append-only — never edited or deleted, committed as a permanent audit artifact. The
|
|
31
|
+
helper fails closed if another official writer or conflict resolver owns the audit-path lock; the
|
|
32
|
+
overridden action must not proceed unless the row lands. A guard-permitted raw append remains a
|
|
33
|
+
cooperative operator escape hatch, but it must not run concurrently with conflict resolution.
|
|
34
|
+
4. A genuine three-stage Git conflict on an H-05 append-only path uses the dedicated helper instead
|
|
35
|
+
of the ordinary step 3 append. Run it once from the conflicted repository, with the specific
|
|
36
|
+
repo-relative path and the same reason:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
python3 "<plugin-root>/hooks/resolve-append-only-conflict.py" ".codearbiter/overrides.log" --reason "<specific reason>"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The helper derives the same `git config user.email` identity, validates the merge-base and both
|
|
43
|
+
append suffixes, writes the attributed H-05 record, and explicitly stages only the validated
|
|
44
|
+
deterministic union. It refuses a missing or malformed three-stage conflict, any edit to prior
|
|
45
|
+
bytes, unsupported paths, invalid encoding, dirty audit sink, or replay after resolution. This is
|
|
46
|
+
scoped to the immediate action only; it creates no marker, standing exception, or general H-05
|
|
47
|
+
bypass. It takes canonical per-audit-path locks and supports only `overrides.log` and the
|
|
48
|
+
repository-owned `gate-events.log` sink; official writers share those locks. Conflict resolution
|
|
49
|
+
is an exclusive cooperative operation: do not start a raw append before or during it. Never
|
|
50
|
+
hand-resolve the protected log with Write, patch, checkout, or restore.
|
|
51
|
+
5. Proceed with the overridden action. Note in the response that the override is logged.
|
|
26
52
|
|
|
27
53
|
## Security ceiling — heavier path for security-critical stops
|
|
28
54
|
|
|
@@ -40,8 +66,14 @@ Heavier path (all required, in order):
|
|
|
40
66
|
2. **Explicit per-finding acknowledgement** — the user must acknowledge *that specific finding* in
|
|
41
67
|
their own words (a bare "yes"/"go ahead"/"I trust you" is declined — this mirrors `decision-variance`).
|
|
42
68
|
Detect identity from `git config user.email`; if unset, ask once.
|
|
43
|
-
3. **Heavier log entry** —
|
|
44
|
-
|
|
69
|
+
3. **Heavier log entry** — use the same locked helper so the mandatory row cannot race conflict
|
|
70
|
+
resolution:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
python3 "<plugin-root>/hooks/append-override.py" --security-finding "<specific finding>" --reason "<reason>"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It appends a line tagged `SECURITY-OVERRIDE` that records the specific finding, not just the gate name:
|
|
45
77
|
|
|
46
78
|
```
|
|
47
79
|
[ISO-8601] | BY: <email> | SECURITY-OVERRIDE | FINDING: <specific finding> | REASON: <reason>
|
|
@@ -62,7 +94,9 @@ Under `/ca-sprint`, a security-critical override is a hard-gate STOP: it surface
|
|
|
62
94
|
MUST write the log line before proceeding — it is not optional. MUST capture an operator identity —
|
|
63
95
|
"codeArbiter" or "automated" are not valid. MUST include a justification. The override is scoped to
|
|
64
96
|
the immediate action only; it creates no standing exception. MUST NOT edit or delete an existing
|
|
65
|
-
`overrides.log` entry.
|
|
97
|
+
`overrides.log` entry. For an H-05 three-stage conflict, the dedicated helper's validated union and
|
|
98
|
+
audit write are the single immediate action; do not pre-edit the conflicted file. MUST route a
|
|
99
|
+
security-critical / crypto-secret / irreversible stop through the
|
|
66
100
|
**Security ceiling** path — never the single-confirm flow — and MUST NOT auto-decide such an override
|
|
67
101
|
under `/ca-sprint`.
|
|
68
102
|
|