@arbiterforge/ca-pi 0.16.5 → 0.17.0
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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +46 -0
- package/plugins/ca-pi/agents/map-deps.md +26 -26
- package/plugins/ca-pi/agents/map-structure.md +27 -28
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +70 -39
- package/plugins/ca-pi/generated/roles.json +2 -5
- package/plugins/ca-pi/helpers/artifacts/release.json +18 -18
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +16 -10
- package/plugins/ca-pi/hooks/_artifactlib.py +29 -2
- package/plugins/ca-pi/hooks/_contextmergelib.py +160 -0
- package/plugins/ca-pi/hooks/_githooks.py +2 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_tribunalinventorylib.py +653 -0
- package/plugins/ca-pi/hooks/_tribunalrunlib.py +652 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/resolve-context-conflict.py +8 -0
- package/plugins/ca-pi/hooks/tribunal.py +113 -0
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +29 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +192 -99
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +6 -27
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +73 -52
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +69 -13
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +63 -45
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +55 -12
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +51 -13
- package/plugins/ca-pi/routines/tribunal/references/lenses/change-closure.md +65 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +51 -10
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +53 -13
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +52 -12
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +52 -11
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +52 -12
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +53 -13
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +55 -12
- package/plugins/ca-pi/routines/tribunal/references/lenses/semantic-contract.md +62 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +48 -13
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +52 -11
- package/plugins/ca-pi/routines/tribunal/references/report.md +34 -12
- package/plugins/ca-pi/routines/tribunal/references/review-risk.md +78 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +171 -38
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +52 -17
- package/plugins/ca-pi/routines/tribunal/references/triage.md +95 -47
- package/plugins/ca-pi/routines/tribunal/references/verification-quality.md +61 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +29 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +192 -99
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — source-bound Tribunal run records.
|
|
3
|
+
#
|
|
4
|
+
# Stdlib only; no import-time I/O. Git plumbing is read-only, with no shell,
|
|
5
|
+
# external diff, filters, fsmonitor, replacements or lazy fetch. Worktree bytes
|
|
6
|
+
# are read directly, never through Git filters or an escaping link. Fingerprints
|
|
7
|
+
# intentionally compare literal reviewed bytes, including line endings.
|
|
8
|
+
# Explicit evidence_paths name represented file/gitlink inputs, not directories.
|
|
9
|
+
# Ignored or otherwise unrepresented declarations are refused, never omitted.
|
|
10
|
+
# The coordinator owns log writes. Exclusive per-run locks reject concurrent
|
|
11
|
+
# writers; a stranded lock requires explicit operator recovery, never takeover.
|
|
12
|
+
# Existing records are never rewritten. These are cooperative audit records,
|
|
13
|
+
# not cryptographically authenticated attestations (ADR-0010).
|
|
14
|
+
#
|
|
15
|
+
# Public API (all return dictionaries with ok; errors are fixed reason codes):
|
|
16
|
+
# source_fingerprint(root, scope='.', reviewed_untracked=None,
|
|
17
|
+
# target_digest=None, active_run=None, evidence_paths=None) -> dict
|
|
18
|
+
# start_run(root, scope='.', reviewed_untracked=None,
|
|
19
|
+
# target_digest=None, detail=None, evidence_paths=None) -> dict
|
|
20
|
+
# resume_status(root, run_dir, scope=None, target_digest=None, evidence_paths=None) -> dict
|
|
21
|
+
# read_run(root, run_dir) -> dict
|
|
22
|
+
# append_event(root, run_dir, event, **fields) -> dict
|
|
23
|
+
# write_lead(root, run_dir, record) -> dict
|
|
24
|
+
# dispose_lead(root, run_dir, lead_id, disposition, rationale) -> dict
|
|
25
|
+
# read_leads(root, run_dir) -> dict
|
|
26
|
+
# validate_finding(record) -> dict
|
|
27
|
+
# triage_eligibility(finding, triage, verification=None) -> dict
|
|
28
|
+
# append_triage(root, run_dir, finding, triage, verification=None) -> dict
|
|
29
|
+
|
|
30
|
+
from contextlib import contextmanager
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from functools import wraps
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import math
|
|
36
|
+
import os
|
|
37
|
+
from pathlib import Path, PurePosixPath
|
|
38
|
+
import re
|
|
39
|
+
import secrets
|
|
40
|
+
import stat
|
|
41
|
+
import subprocess
|
|
42
|
+
|
|
43
|
+
from _gitexec import git_executable, root_bound_git_env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
REPORTS = ".codearbiter/reports"
|
|
47
|
+
MAX_RECORD_BYTES = 8 * 1024 * 1024
|
|
48
|
+
MAX_GIT_BYTES = 128 * 1024 * 1024
|
|
49
|
+
AUDIT_EVENTS = {"lens-launched", "lens-skipped", "lens-completed", "wave-flushed", "wave-triaged", "report-written"}
|
|
50
|
+
FOLLOWUP_EVENTS = {"issues-filed", "filing-skipped", "telemetry-sent", "telemetry-skipped", "run-completed"}
|
|
51
|
+
TERMINAL_EVENTS = {"run-completed", "run-aborted"}
|
|
52
|
+
DECISIONS = {"keep", "combine", "duplicate", "false-positive", "defer", "accept-risk", "decision-required", "verify-required", "investigate"}
|
|
53
|
+
SEVERITIES = {"critical", "high", "medium", "low"}
|
|
54
|
+
DISPOSITIONS = {"routed", "promoted", "dismissed", "deferred"}
|
|
55
|
+
_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
|
|
56
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
|
|
57
|
+
_OBJECT = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Invalid(ValueError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _public(function):
|
|
65
|
+
@wraps(function)
|
|
66
|
+
def safe(*args, **kwargs):
|
|
67
|
+
try:
|
|
68
|
+
return function(*args, **kwargs)
|
|
69
|
+
except _Invalid as exc:
|
|
70
|
+
return {"ok": False, "error": str(exc)}
|
|
71
|
+
except (OSError, ValueError, TypeError, KeyError, OverflowError, RecursionError, subprocess.SubprocessError):
|
|
72
|
+
return {"ok": False, "error": "unreadable-or-malformed-input"}
|
|
73
|
+
return safe
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _require(condition, reason):
|
|
77
|
+
if not condition:
|
|
78
|
+
raise _Invalid(reason)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _json(value):
|
|
82
|
+
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _digest(value):
|
|
86
|
+
return hashlib.sha256(_json(value)).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _now():
|
|
90
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _text(value):
|
|
94
|
+
return isinstance(value, str) and bool(value.strip())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _name(value):
|
|
98
|
+
return isinstance(value, str) and bool(_NAME.fullmatch(value))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _relative(value):
|
|
102
|
+
_require(isinstance(value, str) and "\x00" not in value
|
|
103
|
+
and (os.name != "nt" or ("\\" not in value and ":" not in value)), "invalid-relative-path")
|
|
104
|
+
path = PurePosixPath(value)
|
|
105
|
+
_require(not path.is_absolute() and ".." not in path.parts and ".git" not in path.parts, "invalid-relative-path")
|
|
106
|
+
return path.as_posix()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _linked(info):
|
|
110
|
+
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & 0x400)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _contained(root, relative, leaf_link=False):
|
|
114
|
+
"""Reject links/reparse points in ancestors before accessing any leaf."""
|
|
115
|
+
relative = _relative(relative)
|
|
116
|
+
current = root
|
|
117
|
+
parts = PurePosixPath(relative).parts
|
|
118
|
+
for index, part in enumerate(parts):
|
|
119
|
+
current = current / part
|
|
120
|
+
try:
|
|
121
|
+
info = current.lstat()
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
continue
|
|
124
|
+
if _linked(info):
|
|
125
|
+
_require(leaf_link and index == len(parts) - 1 and stat.S_ISLNK(info.st_mode), "linked-path-refused")
|
|
126
|
+
return current
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _root(root):
|
|
130
|
+
path = Path(root).resolve(strict=True)
|
|
131
|
+
_require(path.is_dir(), "invalid-repository-root")
|
|
132
|
+
return path
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _git(root, *args, data=None):
|
|
136
|
+
env = {key: value for key, value in root_bound_git_env().items() if not key.startswith("GIT_")}
|
|
137
|
+
env.update(GIT_OPTIONAL_LOCKS="0", GIT_TERMINAL_PROMPT="0", GIT_NO_REPLACE_OBJECTS="1")
|
|
138
|
+
try:
|
|
139
|
+
executable = git_executable()
|
|
140
|
+
except RuntimeError:
|
|
141
|
+
raise _Invalid("git-executable-unavailable") from None
|
|
142
|
+
result = subprocess.run(
|
|
143
|
+
[executable, "--no-pager", "--no-lazy-fetch", "-c", "core.fsmonitor=false",
|
|
144
|
+
"-c", "core.hooksPath=" + os.devnull, "-c", "core.untrackedCache=false", *args],
|
|
145
|
+
cwd=root, env=env, input=data, capture_output=True, shell=False, timeout=30)
|
|
146
|
+
_require(result.returncode == 0, "git-plumbing-unavailable")
|
|
147
|
+
_require(len(result.stdout) <= MAX_GIT_BYTES, "git-output-over-limit")
|
|
148
|
+
return result.stdout
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _git_text(root, *args):
|
|
152
|
+
return _git(root, *args).decode("utf-8", "surrogateescape").strip()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _identity(root):
|
|
156
|
+
actual = Path(_git_text(root, "rev-parse", "--show-toplevel")).resolve(strict=True)
|
|
157
|
+
_require(actual == root, "repository-root-mismatch")
|
|
158
|
+
common = _git_text(root, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
|
159
|
+
return {"root": os.path.normcase(str(root)), "common_dir": os.path.normcase(str(Path(common).resolve(strict=True)))}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _entries(raw, staged):
|
|
163
|
+
entries = []
|
|
164
|
+
for item in raw.split(b"\x00"):
|
|
165
|
+
if not item:
|
|
166
|
+
continue
|
|
167
|
+
metadata, path = item.split(b"\t", 1)
|
|
168
|
+
fields = metadata.decode("ascii").split()
|
|
169
|
+
mode, oid = (fields[0], fields[1]) if staged else (fields[0], fields[2])
|
|
170
|
+
_require(mode in {"100644", "100755", "120000", "160000"} and _OBJECT.fullmatch(oid), "invalid-git-entry")
|
|
171
|
+
stage = int(fields[2]) if staged else 0
|
|
172
|
+
_require(stage in {0, 1, 2, 3}, "invalid-index-stage")
|
|
173
|
+
entries.append({"path": _relative(path.decode("utf-8", "surrogateescape")), "mode": mode, "object": oid, "stage": stage})
|
|
174
|
+
return sorted(entries, key=lambda entry: (entry["path"], entry["stage"]))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _blobs(root, entries):
|
|
178
|
+
objects = sorted({entry["object"] for entry in entries if entry["mode"] != "160000"})
|
|
179
|
+
if not objects:
|
|
180
|
+
return {}
|
|
181
|
+
raw = _git(root, "cat-file", "--batch", data=("\n".join(objects) + "\n").encode("ascii"))
|
|
182
|
+
values, cursor = {}, 0
|
|
183
|
+
for oid in objects:
|
|
184
|
+
end = raw.index(b"\n", cursor)
|
|
185
|
+
returned, kind, length = raw[cursor:end].decode("ascii").split()
|
|
186
|
+
_require(returned == oid and kind == "blob", "invalid-git-blob")
|
|
187
|
+
size = int(length)
|
|
188
|
+
_require(0 <= size <= MAX_GIT_BYTES and end + size + 1 < len(raw), "invalid-git-blob")
|
|
189
|
+
content = raw[end + 1:end + size + 1]
|
|
190
|
+
_require(raw[end + size + 1:end + size + 2] == b"\n", "invalid-git-blob")
|
|
191
|
+
values[oid] = hashlib.sha256(content).hexdigest()
|
|
192
|
+
cursor = end + size + 2
|
|
193
|
+
_require(cursor == len(raw), "invalid-git-blob")
|
|
194
|
+
return values
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _worktree_entry(root, name, indexed, allow_submodules=True):
|
|
198
|
+
path = _contained(root, name, leaf_link=True)
|
|
199
|
+
try:
|
|
200
|
+
info = path.lstat()
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return {"path": name, "mode": "missing", "content": None}
|
|
203
|
+
if indexed and indexed["mode"] == "160000":
|
|
204
|
+
_require(stat.S_ISDIR(info.st_mode), "invalid-submodule-directory")
|
|
205
|
+
if not any(path.iterdir()):
|
|
206
|
+
return {"path": name, "mode": "160000", "content": indexed["object"]}
|
|
207
|
+
# One declared gitlink level is supported, including child dirty and
|
|
208
|
+
# untracked bytes. Nested populated gitlinks require their own audit.
|
|
209
|
+
_require(allow_submodules, "nested-submodule-requires-separate-audit")
|
|
210
|
+
child = _fingerprint(path, ".", None, None, allow_submodules=False)
|
|
211
|
+
return {"path": name, "mode": "160000", "content": child["head"],
|
|
212
|
+
"submodule_digest": child["digest"], "submodule_clean": child["clean"]}
|
|
213
|
+
if stat.S_ISLNK(info.st_mode):
|
|
214
|
+
data, mode = os.fsencode(os.readlink(path)), "120000"
|
|
215
|
+
else:
|
|
216
|
+
_require(stat.S_ISREG(info.st_mode) and not _linked(info), "non-regular-input")
|
|
217
|
+
_require(info.st_size <= MAX_GIT_BYTES, "source-file-over-limit")
|
|
218
|
+
with path.open("rb") as handle:
|
|
219
|
+
data = handle.read(MAX_GIT_BYTES + 1)
|
|
220
|
+
_require(len(data) <= MAX_GIT_BYTES, "source-file-over-limit")
|
|
221
|
+
mode = "100755" if info.st_mode & 0o111 else "100644"
|
|
222
|
+
if os.name == "nt":
|
|
223
|
+
mode = indexed["mode"] if indexed and indexed["mode"] in {"100644", "100755"} else "100644"
|
|
224
|
+
return {"path": name, "mode": mode, "content": hashlib.sha256(data).hexdigest()}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fingerprint(root, scope, reviewed_untracked, target_digest, active_run=None, evidence_paths=None, allow_submodules=True):
|
|
228
|
+
scope = _relative(scope)
|
|
229
|
+
_contained(root, scope, leaf_link=True)
|
|
230
|
+
_require(evidence_paths is None or isinstance(evidence_paths, list), "invalid-evidence-paths")
|
|
231
|
+
evidence_paths = sorted({_relative(path) for path in evidence_paths or []})
|
|
232
|
+
for path in evidence_paths:
|
|
233
|
+
_contained(root, path, leaf_link=True)
|
|
234
|
+
_require(target_digest is None or isinstance(target_digest, str) and _DIGEST.fullmatch(target_digest), "invalid-target-digest")
|
|
235
|
+
identity = _identity(root)
|
|
236
|
+
head = _git_text(root, "rev-parse", "--verify", "HEAD")
|
|
237
|
+
_require(_OBJECT.fullmatch(head), "invalid-head")
|
|
238
|
+
excluded = active_run.relative_to(root).as_posix() if active_run else None
|
|
239
|
+
all_heads = _entries(_git(root, "ls-tree", "-rz", "--full-tree", "HEAD"), False)
|
|
240
|
+
all_indexes = _entries(_git(root, "ls-files", "--stage", "-z"), True)
|
|
241
|
+
gitlinks = {entry["path"] for entry in all_heads + all_indexes if entry["mode"] == "160000"}
|
|
242
|
+
|
|
243
|
+
def selected(name):
|
|
244
|
+
# A scope within a gitlink conservatively binds that whole child.
|
|
245
|
+
included = any(path == "." or name == path or name.startswith(path + "/") or
|
|
246
|
+
name in gitlinks and path.startswith(name + "/") for path in [scope, *evidence_paths])
|
|
247
|
+
return included and not (excluded and (name == excluded or name.startswith(excluded + "/")))
|
|
248
|
+
|
|
249
|
+
head_entries = [entry for entry in all_heads if selected(entry["path"])]
|
|
250
|
+
index_entries = [entry for entry in all_indexes if selected(entry["path"])]
|
|
251
|
+
available = {_relative(path.decode("utf-8", "surrogateescape")) for path in _git(root, "ls-files", "--others", "--exclude-standard", "-z").split(b"\x00") if path}
|
|
252
|
+
available = {name for name in available if selected(name)}
|
|
253
|
+
_require(reviewed_untracked is None or isinstance(reviewed_untracked, list), "invalid-untracked-selection")
|
|
254
|
+
untracked = sorted(available if reviewed_untracked is None else {_relative(name) for name in reviewed_untracked})
|
|
255
|
+
_require(all(name in available for name in untracked), "untracked-input-unavailable")
|
|
256
|
+
represented = {entry["path"] for entry in head_entries + index_entries} | set(untracked)
|
|
257
|
+
_require(all(path in represented for path in evidence_paths), "evidence-input-unavailable")
|
|
258
|
+
blobs = _blobs(root, head_entries + index_entries)
|
|
259
|
+
|
|
260
|
+
def content_entries(entries):
|
|
261
|
+
return [{"path": entry["path"], "mode": entry["mode"], "stage": entry["stage"],
|
|
262
|
+
"content": entry["object"] if entry["mode"] == "160000" else blobs[entry["object"]]} for entry in entries]
|
|
263
|
+
|
|
264
|
+
indexed = {entry["path"]: entry for entry in index_entries}
|
|
265
|
+
tracked = sorted({entry["path"] for entry in head_entries + index_entries})
|
|
266
|
+
worktree = [_worktree_entry(root, name, indexed.get(name), allow_submodules) for name in tracked]
|
|
267
|
+
untracked_content = [_worktree_entry(root, name, None) for name in untracked]
|
|
268
|
+
heads, indexes = content_entries(head_entries), content_entries(index_entries)
|
|
269
|
+
clean_index = heads == indexes
|
|
270
|
+
plain_worktree = [{key: entry[key] for key in ("path", "mode", "content")} for entry in worktree]
|
|
271
|
+
clean_worktree = [dict(entry, stage=0) for entry in plain_worktree] == indexes and all(entry.get("submodule_clean", True) for entry in worktree)
|
|
272
|
+
result = {"schema": "tribunal-source/v1", "repository": identity, "head": head, "scope": scope, "evidence_paths": evidence_paths,
|
|
273
|
+
"clean": clean_index and clean_worktree and not untracked,
|
|
274
|
+
"head_tree_digest": _digest(heads), "index_digest": _digest(indexes), "worktree_digest": _digest(worktree),
|
|
275
|
+
"untracked_digest": _digest(untracked_content), "reviewed_untracked": untracked,
|
|
276
|
+
"untracked_policy": "all" if reviewed_untracked is None else "selected", "target_digest": target_digest}
|
|
277
|
+
result["digest"] = _digest(result)
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _run_path(root, run_dir):
|
|
282
|
+
path = Path(run_dir)
|
|
283
|
+
if not path.is_absolute():
|
|
284
|
+
path = root / path
|
|
285
|
+
parts = path.parts[-3:]
|
|
286
|
+
_require(len(parts) == 3 and "/".join(parts[:2]) == REPORTS and _name(parts[2]), "invalid-run-directory")
|
|
287
|
+
# Normalize only the repository prefix; leave the fixed report suffix
|
|
288
|
+
# unresolved so _contained still rejects its links and reparse points.
|
|
289
|
+
_require(_root(path.parents[2]) == root, "invalid-run-directory")
|
|
290
|
+
path = _contained(root, "/".join(parts))
|
|
291
|
+
_require(path.is_dir(), "run-directory-unavailable")
|
|
292
|
+
return path
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _pairs(pairs):
|
|
296
|
+
result = {}
|
|
297
|
+
for key, value in pairs:
|
|
298
|
+
_require(key not in result, "duplicate-json-key")
|
|
299
|
+
result[key] = value
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse(data):
|
|
304
|
+
def invalid_number(value):
|
|
305
|
+
raise _Invalid("invalid-json-number")
|
|
306
|
+
value = json.loads(data, object_pairs_hook=_pairs, parse_constant=invalid_number)
|
|
307
|
+
_require(isinstance(value, dict), "invalid-record")
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _read_bytes(root, path, optional=False):
|
|
312
|
+
_contained(root, path.relative_to(root).as_posix())
|
|
313
|
+
try:
|
|
314
|
+
with path.open("rb") as handle:
|
|
315
|
+
value = handle.read(MAX_RECORD_BYTES + 1)
|
|
316
|
+
except FileNotFoundError:
|
|
317
|
+
if optional:
|
|
318
|
+
return None
|
|
319
|
+
raise
|
|
320
|
+
_require(len(value) <= MAX_RECORD_BYTES, "record-over-limit")
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _records(root, path):
|
|
325
|
+
value = _read_bytes(root, path, optional=True)
|
|
326
|
+
if not value:
|
|
327
|
+
return []
|
|
328
|
+
_require(value.endswith(b"\n"), "incomplete-log-record")
|
|
329
|
+
return [_parse(line) for line in value.splitlines()]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _binding(root, folder):
|
|
333
|
+
raw = _read_bytes(root, folder / "source.json", optional=True)
|
|
334
|
+
if raw is None:
|
|
335
|
+
return None
|
|
336
|
+
value = _parse(raw)
|
|
337
|
+
_require(value.get("schema") == "tribunal-run/v1" and value.get("run_id") == folder.name, "invalid-run-binding")
|
|
338
|
+
fp = value.get("fingerprint")
|
|
339
|
+
_require(isinstance(fp, dict) and fp.get("schema") == "tribunal-source/v1", "invalid-source-binding")
|
|
340
|
+
_require(fp.get("digest") == _digest({key: entry for key, entry in fp.items() if key != "digest"}), "invalid-source-binding")
|
|
341
|
+
_require(isinstance(fp.get("repository"), dict) and fp["repository"].get("root") == os.path.normcase(str(root)), "repository-root-mismatch")
|
|
342
|
+
_require(isinstance(fp.get("evidence_paths"), list), "invalid-source-binding")
|
|
343
|
+
_require(fp.get("untracked_policy") in {"all", "selected"} and isinstance(fp.get("reviewed_untracked"), list), "invalid-source-binding")
|
|
344
|
+
return value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _cursor(events, fingerprint):
|
|
348
|
+
_require(events and events[0].get("event") == "run-started" and events[0].get("source_digest") == fingerprint["digest"], "unbound-run-log")
|
|
349
|
+
state, filing, telemetry, last_wave = "audit", None, None, 0
|
|
350
|
+
for index, entry in enumerate(events):
|
|
351
|
+
event = entry.get("event")
|
|
352
|
+
_require(entry.get("schema") == "run/v1", "invalid-run-event")
|
|
353
|
+
if index == 0:
|
|
354
|
+
continue
|
|
355
|
+
_require(state != "terminal", "event-after-terminal")
|
|
356
|
+
_require(event in AUDIT_EVENTS | FOLLOWUP_EVENTS | {"run-aborted"}, "invalid-run-event")
|
|
357
|
+
if event in AUDIT_EVENTS:
|
|
358
|
+
_require(state == "audit", "audit-already-reported")
|
|
359
|
+
if event in FOLLOWUP_EVENTS:
|
|
360
|
+
_require(state == "follow-up", "report-required")
|
|
361
|
+
if event == "report-written":
|
|
362
|
+
state = "follow-up"
|
|
363
|
+
elif event in {"issues-filed", "filing-skipped"}:
|
|
364
|
+
_require(filing is None, "filing-already-disposed")
|
|
365
|
+
filing = event
|
|
366
|
+
elif event in {"telemetry-sent", "telemetry-skipped"}:
|
|
367
|
+
_require(telemetry is None, "telemetry-already-disposed")
|
|
368
|
+
telemetry = event
|
|
369
|
+
elif event == "wave-triaged":
|
|
370
|
+
wave = entry.get("wave")
|
|
371
|
+
_require(type(wave) is int and wave > last_wave, "invalid-wave")
|
|
372
|
+
last_wave = wave
|
|
373
|
+
elif event == "run-completed":
|
|
374
|
+
_require(filing is not None and telemetry is not None, "followup-dispositions-required")
|
|
375
|
+
if event in TERMINAL_EVENTS:
|
|
376
|
+
state = "terminal"
|
|
377
|
+
return {"state": state, "filing": filing, "telemetry": telemetry, "last_triaged_wave": last_wave,
|
|
378
|
+
"detail": events[0].get("detail")}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _status(root, folder, scope=None, target_digest=None, evidence_paths=None):
|
|
382
|
+
try:
|
|
383
|
+
binding = _binding(root, folder)
|
|
384
|
+
if binding is None:
|
|
385
|
+
return {"ok": True, "state": "legacy-unbound", "can_resume_audit": False, "can_resume_follow_up": False}
|
|
386
|
+
fp = binding["fingerprint"]
|
|
387
|
+
cursor = _cursor(_records(root, folder / "run.jsonl"), fp)
|
|
388
|
+
except (ValueError, KeyError, TypeError, OSError):
|
|
389
|
+
return {"ok": False, "state": "invalid", "error": "invalid-run-records", "can_resume_audit": False, "can_resume_follow_up": False}
|
|
390
|
+
if cursor["state"] != "terminal":
|
|
391
|
+
try:
|
|
392
|
+
current = _fingerprint(root, fp["scope"] if scope is None else scope,
|
|
393
|
+
None if fp["untracked_policy"] == "all" else fp["reviewed_untracked"],
|
|
394
|
+
fp["target_digest"] if target_digest is None else target_digest, folder,
|
|
395
|
+
fp["evidence_paths"] if evidence_paths is None else evidence_paths)
|
|
396
|
+
unchanged = current == fp
|
|
397
|
+
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
|
398
|
+
unchanged = False
|
|
399
|
+
if not unchanged:
|
|
400
|
+
cursor["state"] = "source-drift"
|
|
401
|
+
return {"ok": True, **cursor, "can_resume_audit": cursor["state"] == "audit",
|
|
402
|
+
"can_resume_follow_up": cursor["state"] == "follow-up"}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _write(root, path, record, append=False):
|
|
406
|
+
_contained(root, path.relative_to(root).as_posix())
|
|
407
|
+
data = _json(record) + b"\n"
|
|
408
|
+
_require(len(data) <= MAX_RECORD_BYTES, "record-over-limit")
|
|
409
|
+
flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_EXCL)
|
|
410
|
+
flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
411
|
+
fd = os.open(path, flags, 0o600)
|
|
412
|
+
with os.fdopen(fd, "ab" if append else "wb") as handle:
|
|
413
|
+
handle.write(data)
|
|
414
|
+
handle.flush()
|
|
415
|
+
os.fsync(handle.fileno())
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@contextmanager
|
|
419
|
+
def _lock(root, folder):
|
|
420
|
+
path = folder / ".write-lock"
|
|
421
|
+
try:
|
|
422
|
+
_write(root, path, {"at": _now()})
|
|
423
|
+
except FileExistsError:
|
|
424
|
+
raise _Invalid("run-busy-recovery-required") from None
|
|
425
|
+
try:
|
|
426
|
+
yield
|
|
427
|
+
finally:
|
|
428
|
+
path.unlink()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _writable(root, folder, followup=False):
|
|
432
|
+
status = _status(root, folder)
|
|
433
|
+
allowed = {"audit", "follow-up"} if followup else {"audit"}
|
|
434
|
+
_require(status.get("ok") and status["state"] in allowed, "run-not-writable")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@_public
|
|
438
|
+
def source_fingerprint(root, scope=".", reviewed_untracked=None, target_digest=None, active_run=None, evidence_paths=None):
|
|
439
|
+
root = _root(root)
|
|
440
|
+
if active_run is not None:
|
|
441
|
+
active_run = _run_path(root, active_run)
|
|
442
|
+
_require(_binding(root, active_run) is not None, "unowned-run-exclusion")
|
|
443
|
+
return {"ok": True, "fingerprint": _fingerprint(root, scope, reviewed_untracked, target_digest, active_run, evidence_paths)}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _new_run_id(scope):
|
|
447
|
+
slug = re.sub(r"[^a-z0-9]+", "-", scope.lower()).strip("-")[:40] or "all"
|
|
448
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + "-" + slug + "-" + secrets.token_hex(4)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@_public
|
|
452
|
+
def start_run(root, scope=".", reviewed_untracked=None, target_digest=None, detail=None, evidence_paths=None):
|
|
453
|
+
root, scope = _root(root), _relative(scope)
|
|
454
|
+
initial = _fingerprint(root, scope, reviewed_untracked, target_digest, evidence_paths=evidence_paths)
|
|
455
|
+
_json(detail)
|
|
456
|
+
reports = _contained(root, REPORTS)
|
|
457
|
+
reports.mkdir(parents=True, exist_ok=True)
|
|
458
|
+
for _ in range(32):
|
|
459
|
+
run_id = _new_run_id(scope)
|
|
460
|
+
_require(_name(run_id), "invalid-run-id")
|
|
461
|
+
folder = reports / run_id
|
|
462
|
+
try:
|
|
463
|
+
folder.mkdir()
|
|
464
|
+
break
|
|
465
|
+
except FileExistsError:
|
|
466
|
+
continue
|
|
467
|
+
else:
|
|
468
|
+
raise _Invalid("run-allocation-exhausted")
|
|
469
|
+
current = _fingerprint(root, scope, reviewed_untracked, target_digest, folder, evidence_paths)
|
|
470
|
+
_require(current == initial, "source-changed-during-start")
|
|
471
|
+
binding = {"schema": "tribunal-run/v1", "run_id": run_id, "created_at": _now(), "fingerprint": current}
|
|
472
|
+
_write(root, folder / "source.json", binding)
|
|
473
|
+
_write(root, folder / "run.jsonl", {"schema": "run/v1", "event": "run-started", "source_digest": current["digest"], "detail": detail, "at": _now()})
|
|
474
|
+
return {"ok": True, "run_id": run_id, "run_dir": str(folder), "fingerprint": current}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@_public
|
|
478
|
+
def resume_status(root, run_dir, scope=None, target_digest=None, evidence_paths=None):
|
|
479
|
+
root = _root(root)
|
|
480
|
+
return _status(root, _run_path(root, run_dir), scope, target_digest, evidence_paths)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@_public
|
|
484
|
+
def read_run(root, run_dir):
|
|
485
|
+
"""Read historical records without migrating, rebinding or rewriting them."""
|
|
486
|
+
root = _root(root)
|
|
487
|
+
folder = _run_path(root, run_dir)
|
|
488
|
+
return {"ok": True, "events": _records(root, folder / "run.jsonl"), "triage": _records(root, folder / "triage.jsonl")}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@_public
|
|
492
|
+
def append_event(root, run_dir, event, **fields):
|
|
493
|
+
root = _root(root)
|
|
494
|
+
folder = _run_path(root, run_dir)
|
|
495
|
+
_require(not ({"schema", "event", "at", "source_digest"} & fields.keys()), "reserved-event-field")
|
|
496
|
+
_require(isinstance(event, str) and event in AUDIT_EVENTS | FOLLOWUP_EVENTS | {"run-aborted"}, "invalid-run-event")
|
|
497
|
+
with _lock(root, folder):
|
|
498
|
+
binding = _binding(root, folder)
|
|
499
|
+
_require(binding is not None, "legacy-unbound")
|
|
500
|
+
if event != "run-aborted":
|
|
501
|
+
_writable(root, folder, followup=True)
|
|
502
|
+
record = {"schema": "run/v1", "event": event, **fields, "at": _now()}
|
|
503
|
+
_cursor(_records(root, folder / "run.jsonl") + [record], binding["fingerprint"])
|
|
504
|
+
_write(root, folder / "run.jsonl", record, append=True)
|
|
505
|
+
return {"ok": True, "record": record}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _locations(value):
|
|
509
|
+
_require(isinstance(value, list) and value, "locations-required")
|
|
510
|
+
for entry in value:
|
|
511
|
+
_require(isinstance(entry, dict) and _text(entry.get("path")) and _text(entry.get("lines")), "invalid-location")
|
|
512
|
+
_relative(entry["path"])
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _lead(record):
|
|
516
|
+
_require(isinstance(record, dict) and record.get("schema") == "lead/v1", "invalid-lead")
|
|
517
|
+
for field in ("id", "source_lens", "target_lens"):
|
|
518
|
+
_require(_name(record.get(field)), "invalid-lead-identity")
|
|
519
|
+
_require(record["id"].startswith(record["source_lens"] + "-"), "invalid-lead-identity")
|
|
520
|
+
_locations(record.get("locations"))
|
|
521
|
+
for field in ("observation", "reason", "created_at"):
|
|
522
|
+
_require(_text(record.get(field)), "invalid-lead-field")
|
|
523
|
+
_require(record.get("disposition") == "open", "invalid-initial-lead-disposition")
|
|
524
|
+
_json(record)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@_public
|
|
528
|
+
def write_lead(root, run_dir, record):
|
|
529
|
+
_lead(record)
|
|
530
|
+
root = _root(root)
|
|
531
|
+
folder = _run_path(root, run_dir)
|
|
532
|
+
with _lock(root, folder):
|
|
533
|
+
_writable(root, folder)
|
|
534
|
+
directory = _contained(root, (folder.relative_to(root) / "leads" / record["source_lens"]).as_posix())
|
|
535
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
536
|
+
_write(root, directory / (record["id"] + ".json"), record)
|
|
537
|
+
return {"ok": True, "record": record}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _read_leads(root, folder):
|
|
541
|
+
directory = _contained(root, (folder.relative_to(root) / "leads").as_posix())
|
|
542
|
+
records = {}
|
|
543
|
+
if directory.exists():
|
|
544
|
+
for lens in sorted(directory.iterdir()):
|
|
545
|
+
_contained(root, lens.relative_to(root).as_posix())
|
|
546
|
+
_require(lens.is_dir() and _name(lens.name), "invalid-lead-directory")
|
|
547
|
+
for path in sorted(lens.iterdir()):
|
|
548
|
+
_require(path.suffix == ".json", "invalid-lead-file")
|
|
549
|
+
record = _parse(_read_bytes(root, path))
|
|
550
|
+
_lead(record)
|
|
551
|
+
_require(record["source_lens"] == lens.name and path.stem == record["id"] and record["id"] not in records, "invalid-lead-identity")
|
|
552
|
+
records[record["id"]] = {**record, "disposition_history": []}
|
|
553
|
+
for entry in _records(root, folder / "lead-dispositions.jsonl"):
|
|
554
|
+
_require(entry.get("schema") == "lead-disposition/v1" and entry.get("id") in records and entry.get("disposition") in DISPOSITIONS and _text(entry.get("rationale")), "invalid-lead-disposition")
|
|
555
|
+
record = records[entry["id"]]
|
|
556
|
+
record["disposition"] = entry["disposition"]
|
|
557
|
+
record["disposition_history"].append(entry)
|
|
558
|
+
return list(records.values())
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@_public
|
|
562
|
+
def read_leads(root, run_dir):
|
|
563
|
+
root = _root(root)
|
|
564
|
+
return {"ok": True, "leads": _read_leads(root, _run_path(root, run_dir))}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@_public
|
|
568
|
+
def dispose_lead(root, run_dir, lead_id, disposition, rationale):
|
|
569
|
+
_require(_name(lead_id) and disposition in DISPOSITIONS and _text(rationale), "invalid-lead-disposition")
|
|
570
|
+
root = _root(root)
|
|
571
|
+
folder = _run_path(root, run_dir)
|
|
572
|
+
with _lock(root, folder):
|
|
573
|
+
_writable(root, folder, followup=True)
|
|
574
|
+
_require(any(record["id"] == lead_id for record in _read_leads(root, folder)), "unknown-lead")
|
|
575
|
+
record = {"schema": "lead-disposition/v1", "id": lead_id, "disposition": disposition, "rationale": rationale, "at": _now()}
|
|
576
|
+
_write(root, folder / "lead-dispositions.jsonl", record, append=True)
|
|
577
|
+
return {"ok": True, "record": record}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _confidence(value):
|
|
581
|
+
return type(value) in {int, float} and math.isfinite(value) and 0 <= value <= 1
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _finding(record):
|
|
585
|
+
_require(isinstance(record, dict) and record.get("schema") == "finding/v1", "invalid-finding")
|
|
586
|
+
_require(_name(record.get("id")) and _name(record.get("lens")), "invalid-finding-identity")
|
|
587
|
+
_require(record.get("severity") in SEVERITIES and _confidence(record.get("confidence")), "invalid-finding-score")
|
|
588
|
+
_locations(record.get("locations"))
|
|
589
|
+
_require(_text(record.get("evidence")) and _text(record.get("recommendation")), "finding-evidence-required")
|
|
590
|
+
_require(record.get("claim_type", "fact") in {"fact", "design-choice"}, "invalid-claim-type")
|
|
591
|
+
if "root_cause_key" in record:
|
|
592
|
+
_require(_text(record["root_cause_key"]), "invalid-root-cause")
|
|
593
|
+
for field in ("corroborates", "related_lenses"):
|
|
594
|
+
if field in record:
|
|
595
|
+
_require(isinstance(record[field], list) and all(_name(value) for value in record[field]), "invalid-corroboration")
|
|
596
|
+
_json(record)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@_public
|
|
600
|
+
def validate_finding(record):
|
|
601
|
+
_finding(record)
|
|
602
|
+
return {"ok": True, "finding": record}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _eligibility(finding, triage, verification):
|
|
606
|
+
_finding(finding)
|
|
607
|
+
_require(isinstance(triage, dict) and triage.get("schema") == "triage/v1" and triage.get("id") == finding["id"], "invalid-triage")
|
|
608
|
+
decision = triage.get("decision")
|
|
609
|
+
_require(decision in DECISIONS and triage.get("final_severity") in SEVERITIES and _confidence(triage.get("final_confidence")), "invalid-triage-decision")
|
|
610
|
+
_require(_text(triage.get("rationale")), "triage-rationale-required")
|
|
611
|
+
_require(decision != "decision-required" or finding.get("claim_type") == "design-choice", "factual-uncertainty-is-not-design-choice")
|
|
612
|
+
serious = finding["severity"] in {"critical", "high"} or triage["final_severity"] in {"critical", "high"}
|
|
613
|
+
required = serious or finding.get("requires_verification") is True
|
|
614
|
+
if serious:
|
|
615
|
+
_require(_text(triage.get("counter_argument")), "serious-counter-argument-required")
|
|
616
|
+
outcome, independent = None, False
|
|
617
|
+
if verification is not None:
|
|
618
|
+
_require(isinstance(verification, dict) and verification.get("schema") == "verification/v1" and verification.get("id") == finding["id"], "invalid-verification")
|
|
619
|
+
outcome = verification.get("outcome")
|
|
620
|
+
_require(outcome in {"confirmed", "narrowed", "refuted", "inconclusive"} and _text(verification.get("evidence")), "invalid-verification-outcome")
|
|
621
|
+
_require(verification.get("verification_independence") in {"independent", "limited"}, "invalid-verification-independence")
|
|
622
|
+
independent = verification["verification_independence"] == "independent"
|
|
623
|
+
verified = independent and outcome in {"confirmed", "narrowed"}
|
|
624
|
+
eligible = decision in {"keep", "combine"} and outcome not in {"refuted", "inconclusive"} and (not required or verified)
|
|
625
|
+
recommended = "false-positive" if outcome == "refuted" else "verify-required" if required and not verified else decision
|
|
626
|
+
root_cause = triage.get("root_cause_key", finding.get("root_cause_key", finding.get("dedup_key", finding["id"])))
|
|
627
|
+
_require(_text(root_cause), "invalid-root-cause")
|
|
628
|
+
_json(triage)
|
|
629
|
+
return {"ok": True, "filing_eligible": eligible, "plan_eligible": eligible,
|
|
630
|
+
"requires_verification": required and not verified, "recommended_decision": recommended,
|
|
631
|
+
"root_cause_key": root_cause, "related_lenses": sorted(set([finding["lens"], *finding.get("related_lenses", [])]))}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@_public
|
|
635
|
+
def triage_eligibility(finding, triage, verification=None):
|
|
636
|
+
return _eligibility(finding, triage, verification)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@_public
|
|
640
|
+
def append_triage(root, run_dir, finding, triage, verification=None):
|
|
641
|
+
eligibility = _eligibility(finding, triage, verification)
|
|
642
|
+
_require(eligibility["plan_eligible"] or triage["decision"] not in {"keep", "combine"}, "triage-requires-verification")
|
|
643
|
+
_require(not triage.get("issue_ref") or eligibility["filing_eligible"], "finding-not-filing-eligible")
|
|
644
|
+
root = _root(root)
|
|
645
|
+
folder = _run_path(root, run_dir)
|
|
646
|
+
record = {**triage, "root_cause_key": eligibility["root_cause_key"], "related_lenses": eligibility["related_lenses"],
|
|
647
|
+
"corroborates": finding.get("corroborates", []), "verification": verification}
|
|
648
|
+
with _lock(root, folder):
|
|
649
|
+
_writable(root, folder, followup=True)
|
|
650
|
+
_records(root, folder / "triage.jsonl")
|
|
651
|
+
_write(root, folder / "triage.jsonl", record, append=True)
|
|
652
|
+
return {"ok": True, "record": record, "eligibility": eligibility}
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.
|
|
316
|
+
adapter_version = "2.25.0"
|
|
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,
|