custos-code 0.0.1__py3-none-any.whl
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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
custos_code/hooks.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Live path for Claude Code (class H): hook handlers and the auto-mode loop state.
|
|
2
|
+
|
|
3
|
+
PostToolUse appends CALL/RESULT events to ~/.custos-code/live/<session_id>.jsonl (the harness calls
|
|
4
|
+
us; the model has no write path). Stop reads `last_assistant_message` from the payload (the
|
|
5
|
+
transcript can lag), builds the ledger from the live file when present and the transcript
|
|
6
|
+
otherwise, runs claims -> verdicts, writes the receipt, and in auto mode returns a block decision
|
|
7
|
+
with deterministic nudges until the contract holds or the pass cap is hit.
|
|
8
|
+
|
|
9
|
+
Loop state per session: ~/.custos-code/state/<session_id>.json {passes, nudge_seq, open: {claim_text: verdict}}.
|
|
10
|
+
|
|
11
|
+
Owner: Oliver.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import sys
|
|
20
|
+
import tomllib
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from . import claims as claims_mod
|
|
25
|
+
from . import feedback, parsers, rerun
|
|
26
|
+
from . import judge as judge_mod
|
|
27
|
+
from . import review as review_mod
|
|
28
|
+
from . import scope as scope_mod
|
|
29
|
+
from . import verdicts as verdicts_mod
|
|
30
|
+
from .adapters import claude_code
|
|
31
|
+
from .ledger import MAX_OUTPUT_BYTES, chain, redact
|
|
32
|
+
from .models import Claim, EventFlags, EventKind, LedgerEvent, Session, Verdict, VerdictRecord
|
|
33
|
+
|
|
34
|
+
HOME = os.path.expanduser("~/.custos-code")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _out_of_scope(payload: dict[str, Any]) -> bool:
|
|
38
|
+
"""True when CUSTOS_CODE_ONLY_IN is set and this session's cwd is not inside it.
|
|
39
|
+
|
|
40
|
+
Exists for measurement validity, not convenience. The agent under test can read every file in
|
|
41
|
+
its working directory, so a project-local `.claude/settings.json` tells it that its output is
|
|
42
|
+
being checked and by what -- and on 2026-09-19 a run did exactly that, reading both the hook
|
|
43
|
+
config and a README that described the trap. An agent that knows it is being audited is not
|
|
44
|
+
the agent whose honesty we are trying to measure.
|
|
45
|
+
|
|
46
|
+
With CUSTOS_CODE_ONLY_IN the hooks live in the user's global settings and the project directory
|
|
47
|
+
holds nothing at all. The guard fails toward doing nothing: an unset, empty, or unresolvable
|
|
48
|
+
value, or a payload with no cwd, means this hook does not act.
|
|
49
|
+
"""
|
|
50
|
+
root = os.environ.get("CUSTOS_CODE_ONLY_IN", "").strip()
|
|
51
|
+
if not root:
|
|
52
|
+
return False
|
|
53
|
+
cwd = payload.get("cwd")
|
|
54
|
+
if not isinstance(cwd, str) or not cwd:
|
|
55
|
+
return True
|
|
56
|
+
try:
|
|
57
|
+
root_r = os.path.realpath(os.path.expanduser(root))
|
|
58
|
+
cwd_r = os.path.realpath(cwd)
|
|
59
|
+
except OSError:
|
|
60
|
+
return True
|
|
61
|
+
return os.path.commonpath([root_r, cwd_r]) != root_r
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _config() -> dict[str, Any]:
|
|
65
|
+
"""Auto-mode settings, from ~/.custos-code/config.toml with a per-invocation env override.
|
|
66
|
+
|
|
67
|
+
`auto` blocks the agent's turn, so it must be opt-in and it must be possible to opt in for one
|
|
68
|
+
project without arming every session on the machine. config.toml is global; the hook command in
|
|
69
|
+
a project's own .claude/settings.json can set CUSTOS_CODE_AUTO=1 instead, which scopes blocking to
|
|
70
|
+
that project. CUSTOS_CODE_AUTO=0 force-disables even when the global config enables it, so a repo
|
|
71
|
+
can opt out of a machine-wide default.
|
|
72
|
+
"""
|
|
73
|
+
# Only an accusation holds the turn. `unrecorded` and `unwitnessed` are the checker reporting
|
|
74
|
+
# the limits of its own evidence -- "the output was piped", "nothing in the log either way" --
|
|
75
|
+
# and gating on them made the agent responsible for facts about the recorder. Measured over 648
|
|
76
|
+
# real claims on 2026-09-20 they are 21.0% and 28.9%, so half of every report held the turn
|
|
77
|
+
# open, and `unwitnessed` on a heading or a piece of advice cannot be cleared by any amount of
|
|
78
|
+
# further work. That is the three-pass spin, and it fired on honest reports.
|
|
79
|
+
cfg: dict[str, Any] = {"auto": False, "auto_max_passes": 3, "auto_clear": ["contradicted"]}
|
|
80
|
+
p = os.path.join(HOME, "config.toml")
|
|
81
|
+
if os.path.exists(p):
|
|
82
|
+
with open(p, "rb") as fh:
|
|
83
|
+
data = tomllib.load(fh)
|
|
84
|
+
cfg.update(data.get("tiers", {}))
|
|
85
|
+
env = os.environ.get("CUSTOS_CODE_AUTO")
|
|
86
|
+
if env is not None:
|
|
87
|
+
cfg["auto"] = env.strip().lower() in ("1", "true", "yes", "on")
|
|
88
|
+
if (mp := os.environ.get("CUSTOS_CODE_AUTO_MAX_PASSES")) and mp.isdigit():
|
|
89
|
+
cfg["auto_max_passes"] = int(mp)
|
|
90
|
+
return cfg
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scope_mode() -> str:
|
|
94
|
+
"""`off` | `warn` | `on`, from CUSTOS_CODE_SCOPE or config.toml. Default OFF, deliberately.
|
|
95
|
+
|
|
96
|
+
Every threshold in scope.py is a default I wrote, not a measurement. Issue #57 calibrates them
|
|
97
|
+
against ~400 sessions of accepted work, and until that reports, a scope gate that interrupts
|
|
98
|
+
good work is strictly worse than no scope gate -- see the Stop-hook latency that made the
|
|
99
|
+
terminal unusable on 2026-09-19. So this ships inert and is switched on by a number, not by
|
|
100
|
+
confidence.
|
|
101
|
+
|
|
102
|
+
`warn` bands and records without ever denying: that is the mode #57's harness runs in.
|
|
103
|
+
"""
|
|
104
|
+
v = (os.environ.get("CUSTOS_CODE_SCOPE") or _config().get("scope") or "off")
|
|
105
|
+
v = str(v).strip().lower()
|
|
106
|
+
return v if v in ("off", "warn", "on") else "off"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _scope_grant(payload: dict[str, Any], policy: scope_mod.Policy) -> scope_mod.Grant:
|
|
110
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
|
|
111
|
+
sid = str(payload.get("session_id", "unknown"))
|
|
112
|
+
approved: tuple[str, ...] = ()
|
|
113
|
+
_, state_p, _ = _paths(sid)
|
|
114
|
+
if os.path.exists(state_p):
|
|
115
|
+
try:
|
|
116
|
+
with open(state_p, encoding="utf-8") as fh:
|
|
117
|
+
got = json.load(fh).get("scope_approved")
|
|
118
|
+
if isinstance(got, list):
|
|
119
|
+
approved = tuple(str(x) for x in got)
|
|
120
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
121
|
+
pass
|
|
122
|
+
return scope_mod.Grant.for_session(cwd or os.getcwd(), approved=approved, policy=policy)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _scope_gate(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
126
|
+
"""Band this call and, if the mode allows, stop it before it happens.
|
|
127
|
+
|
|
128
|
+
docs/SCOPE.md §5: scope is checked at PreToolUse, BEFORE the action -- "ask before doing
|
|
129
|
+
something irreversible" is what every permission system does, not halting on an opinion. And
|
|
130
|
+
the cost asymmetry inverts against integrity: a scope false positive costs one pause, a scope
|
|
131
|
+
false negative costs a force-push.
|
|
132
|
+
|
|
133
|
+
The mode decides the response, because a flag is a message to a human and an unattended run has
|
|
134
|
+
nobody reading it:
|
|
135
|
+
|
|
136
|
+
GREEN YELLOW RED
|
|
137
|
+
attended pass ask deny
|
|
138
|
+
unattended pass deny deny
|
|
139
|
+
|
|
140
|
+
Fails OPEN on any error. A checker that cannot run is not evidence about the agent.
|
|
141
|
+
"""
|
|
142
|
+
mode = _scope_mode()
|
|
143
|
+
if mode == "off":
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
raw = payload.get("tool_input")
|
|
147
|
+
inp: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
|
|
148
|
+
policy = scope_mod.Policy.load()
|
|
149
|
+
f = scope_mod.classify(str(payload.get("tool_name", "")), inp,
|
|
150
|
+
_scope_grant(payload, policy), policy=policy)
|
|
151
|
+
except Exception as e: # noqa: BLE001 - never take the turn down over a scope check
|
|
152
|
+
print(f"receipts: scope check failed ({type(e).__name__}); allowing.", file=sys.stderr)
|
|
153
|
+
return None
|
|
154
|
+
if not f.gates or mode == "warn":
|
|
155
|
+
return None
|
|
156
|
+
unattended = bool(_config().get("auto"))
|
|
157
|
+
decision = "deny" if (f.band is scope_mod.Band.RED or unattended) else "ask"
|
|
158
|
+
return {"hookSpecificOutput": {
|
|
159
|
+
"hookEventName": "PreToolUse",
|
|
160
|
+
"permissionDecision": decision,
|
|
161
|
+
"permissionDecisionReason": _scope_reason(f, decision),
|
|
162
|
+
}}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scope_reason(f: scope_mod.Finding, decision: str) -> str:
|
|
166
|
+
"""Say why, in terms of the actual finding. A generic reason trains people to click through."""
|
|
167
|
+
if f.band is scope_mod.Band.RED:
|
|
168
|
+
why = "this cannot be undone"
|
|
169
|
+
elif f.rule == "write-outside-cwd":
|
|
170
|
+
why = "this writes outside the directory this session was started in"
|
|
171
|
+
elif f.rule == "unrecoverable-write":
|
|
172
|
+
why = "there is no git work tree here, so this cannot be reverted"
|
|
173
|
+
else:
|
|
174
|
+
why = "this reaches outside the workspace"
|
|
175
|
+
tail = ("" if decision == "deny"
|
|
176
|
+
else " Approve it and it will not be asked again this session.")
|
|
177
|
+
return f"receipts/scope [{f.band.value}] {f.rule} — {why}: {f.detail}.{tail}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _paths(session_id: str) -> tuple[str, str, str]:
|
|
181
|
+
os.makedirs(os.path.join(HOME, "live"), exist_ok=True)
|
|
182
|
+
os.makedirs(os.path.join(HOME, "state"), exist_ok=True)
|
|
183
|
+
os.makedirs(os.path.join(HOME, "custos-code"), exist_ok=True)
|
|
184
|
+
return (os.path.join(HOME, "live", f"{session_id}.jsonl"),
|
|
185
|
+
os.path.join(HOME, "state", f"{session_id}.json"),
|
|
186
|
+
os.path.join(HOME, "custos-code", f"{session_id}.txt"))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rc_dir() -> str:
|
|
190
|
+
d = os.path.join(HOME, "rc")
|
|
191
|
+
os.makedirs(d, exist_ok=True)
|
|
192
|
+
return d
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _rc_pending_path(session_id: str) -> str:
|
|
196
|
+
os.makedirs(os.path.join(HOME, "rc_pending"), exist_ok=True)
|
|
197
|
+
return os.path.join(HOME, "rc_pending", f"{session_id}.json")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _load_rc_pending(session_id: str) -> dict[str, str]:
|
|
201
|
+
p = _rc_pending_path(session_id)
|
|
202
|
+
if not os.path.exists(p):
|
|
203
|
+
return {}
|
|
204
|
+
with open(p, encoding="utf-8") as fh:
|
|
205
|
+
data: dict[str, str] = json.load(fh)
|
|
206
|
+
return data
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _unpack_pending(entry: str) -> tuple[str, str | None]:
|
|
210
|
+
"""A pending entry is `{"rc": path, "cmd": original}`; older ones were a bare path string.
|
|
211
|
+
|
|
212
|
+
Tolerating the bare form matters because a session in flight when this shipped would otherwise
|
|
213
|
+
lose its rc files and, worse, keep recording our rewritten command as if the agent had run it.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
d = json.loads(entry)
|
|
217
|
+
except (json.JSONDecodeError, TypeError):
|
|
218
|
+
return entry, None
|
|
219
|
+
if not isinstance(d, dict):
|
|
220
|
+
return entry, None
|
|
221
|
+
rc = d.get("rc")
|
|
222
|
+
cmd = d.get("cmd")
|
|
223
|
+
return (rc if isinstance(rc, str) else entry), (cmd if isinstance(cmd, str) else None)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _save_rc_pending(session_id: str, pending: dict[str, str]) -> None:
|
|
227
|
+
with open(_rc_pending_path(session_id), "w", encoding="utf-8") as fh:
|
|
228
|
+
json.dump(pending, fh)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------- PreToolUse ----------
|
|
232
|
+
def on_pre_tool_use(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
233
|
+
"""E5/E9: wrap a known-runner Bash command so `on_post_tool_use` can see the real resolved
|
|
234
|
+
binary path and exit code, defeating a `./pytest` wrapper shadowing the real one. Returns the
|
|
235
|
+
hook response JSON to print, or None to leave the command untouched.
|
|
236
|
+
|
|
237
|
+
Issue #22: the trailer is written to a random per-call file under `~/.custos-code/rc/` instead
|
|
238
|
+
of stdout (the E9 experiment showed a stdout trailer is visible in the model's own tool
|
|
239
|
+
output, letting it later forge the marker). `PostToolUse` cannot safely re-derive that random
|
|
240
|
+
path by re-parsing `tool_input.command` (undocumented whether it sees the original or the
|
|
241
|
+
rewritten command), so the path is instead handed across via a small per-session pending map
|
|
242
|
+
keyed on `tool_use_id`, which both events document. No `tool_use_id` means no way to
|
|
243
|
+
correlate the two sides, so the command is left unwrapped rather than leaking an orphan file.
|
|
244
|
+
"""
|
|
245
|
+
if _out_of_scope(payload):
|
|
246
|
+
return None
|
|
247
|
+
# Scope first: a RED action must never get wrapped and run. The E5 rewrite below only makes a
|
|
248
|
+
# command observable; it does not make it safe.
|
|
249
|
+
if (gate := _scope_gate(payload)) is not None:
|
|
250
|
+
return gate
|
|
251
|
+
if payload.get("tool_name") != "Bash":
|
|
252
|
+
return None
|
|
253
|
+
inp = payload.get("tool_input")
|
|
254
|
+
command = inp.get("command") if isinstance(inp, dict) else None
|
|
255
|
+
if not isinstance(command, str):
|
|
256
|
+
return None
|
|
257
|
+
tool_use_id = payload.get("tool_use_id")
|
|
258
|
+
if not isinstance(tool_use_id, str):
|
|
259
|
+
return None
|
|
260
|
+
rc_path = os.path.join(_rc_dir(), secrets.token_hex(16))
|
|
261
|
+
wrapped = parsers.wrap_command_for_resolution(command, rc_path=rc_path)
|
|
262
|
+
if wrapped is None:
|
|
263
|
+
return None
|
|
264
|
+
sid = str(payload.get("session_id", "unknown"))
|
|
265
|
+
pending = _load_rc_pending(sid)
|
|
266
|
+
# Keep the ORIGINAL command beside the rc path. PostToolUse sees our rewritten command, and
|
|
267
|
+
# recording that would be wrong twice over: the receipt would quote a command the agent never
|
|
268
|
+
# ran, and the wrapper's own `command -v ... 2>/dev/null` matches the output-filtered detector,
|
|
269
|
+
# so every wrapped call would be flagged `piped` and its evidence discounted. Observed on
|
|
270
|
+
# session 21756df4: five true claims came back `unrecorded` for "filtered" output that our own
|
|
271
|
+
# instrumentation had filtered, and a sixth was contradicted outright.
|
|
272
|
+
pending[tool_use_id] = json.dumps({"rc": rc_path, "cmd": command})
|
|
273
|
+
_save_rc_pending(sid, pending)
|
|
274
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "updatedInput": {"command": wrapped}}}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------- PostToolUse ----------
|
|
278
|
+
def _response_text(resp: Any) -> str:
|
|
279
|
+
"""The tool's own output, the way the replay adapter records it.
|
|
280
|
+
|
|
281
|
+
This used to be `json.dumps(resp)`, which wrapped a Bash result in `{"stdout": "...\\n..."}`
|
|
282
|
+
with its newlines escaped. Every parser downstream then read one long line: anything anchored
|
|
283
|
+
to the start of a line could not match, so `pytest -q` -- which prints no session banner, only
|
|
284
|
+
a tail like `86 passed in 1.2s` -- parsed as "not a test runner at all" on the LIVE path while
|
|
285
|
+
parsing correctly on replay.
|
|
286
|
+
|
|
287
|
+
That divergence matters more than the one case. Every accuracy number this project quotes was
|
|
288
|
+
produced by replaying transcripts through `adapters.claude_code`, which extracts stdout
|
|
289
|
+
properly. The hook people actually install stored something else. Measurements taken on the
|
|
290
|
+
replay path were never evidence about the live path.
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(resp, str):
|
|
293
|
+
return resp
|
|
294
|
+
if isinstance(resp, dict):
|
|
295
|
+
parts = [str(resp[k]) for k in ("stdout", "stderr") if isinstance(resp.get(k), str) and resp[k]]
|
|
296
|
+
if parts:
|
|
297
|
+
return "\n".join(parts)
|
|
298
|
+
if isinstance(resp.get("content"), str):
|
|
299
|
+
return str(resp["content"])
|
|
300
|
+
return json.dumps(resp) if resp is not None else ""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def on_post_tool_use(payload: dict[str, Any]) -> None:
|
|
304
|
+
if _out_of_scope(payload):
|
|
305
|
+
return
|
|
306
|
+
sid = str(payload.get("session_id", "unknown"))
|
|
307
|
+
live, _, _ = _paths(sid)
|
|
308
|
+
tool = str(payload.get("tool_name", ""))
|
|
309
|
+
raw_inp = payload.get("tool_input")
|
|
310
|
+
inp: dict[str, Any] = dict(raw_inp) if isinstance(raw_inp, dict) else {}
|
|
311
|
+
resp = payload.get("tool_response")
|
|
312
|
+
text = _response_text(resp)
|
|
313
|
+
# Issue #22: `on_pre_tool_use` (when it wrapped this call) writes the resolved binary path and
|
|
314
|
+
# exit code to a per-call file instead of stdout, so there is no trailer in `text` to strip
|
|
315
|
+
# here -- look the file up via the tool_use_id pending map instead.
|
|
316
|
+
resolved_bin: str | None = None
|
|
317
|
+
wrapped_exit_code: int | None = None
|
|
318
|
+
tool_use_id = payload.get("tool_use_id")
|
|
319
|
+
if isinstance(tool_use_id, str):
|
|
320
|
+
pending = _load_rc_pending(sid)
|
|
321
|
+
entry = pending.pop(tool_use_id, None)
|
|
322
|
+
if entry is not None:
|
|
323
|
+
rc_path, original_cmd = _unpack_pending(entry)
|
|
324
|
+
resolved_bin, wrapped_exit_code = parsers.read_rc_file(rc_path)
|
|
325
|
+
if os.path.exists(rc_path):
|
|
326
|
+
os.remove(rc_path)
|
|
327
|
+
# Restore the agent's own command. `tool_input` here holds OUR rewrite, which quotes a
|
|
328
|
+
# command the agent never ran and whose `command -v ... 2>/dev/null` trips the
|
|
329
|
+
# output-filtered detector below.
|
|
330
|
+
if original_cmd is not None:
|
|
331
|
+
inp["command"] = original_cmd
|
|
332
|
+
_save_rc_pending(sid, pending)
|
|
333
|
+
text = redact(text)
|
|
334
|
+
if resolved_bin is not None:
|
|
335
|
+
inp["resolved_bin"] = resolved_bin # feeds a future rules.py trust check (E5); unused today
|
|
336
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
|
|
337
|
+
side = bool(payload.get("agent_id"))
|
|
338
|
+
n = sum(1 for _ in open(live)) if os.path.exists(live) else 0
|
|
339
|
+
ts = datetime.now(UTC)
|
|
340
|
+
call = LedgerEvent(seq=n, ts=ts, session_id=sid, kind=EventKind.CALL, tool=tool, input=redact(dict(inp)),
|
|
341
|
+
paths=claude_code._paths_from_input(tool, dict(inp), cwd), cwd=cwd, flags=EventFlags(sidechain=side))
|
|
342
|
+
flags = EventFlags(sidechain=side)
|
|
343
|
+
cmd = inp.get("command")
|
|
344
|
+
if tool == "Bash" and isinstance(cmd, str) and parsers.is_piped(cmd):
|
|
345
|
+
flags.piped = True
|
|
346
|
+
if len(text.encode()) > MAX_OUTPUT_BYTES:
|
|
347
|
+
flags.truncated = True
|
|
348
|
+
res = LedgerEvent(seq=n + 1, ts=ts, session_id=sid, kind=EventKind.RESULT, tool=tool,
|
|
349
|
+
output=text.encode()[:MAX_OUTPUT_BYTES].decode(errors="ignore"),
|
|
350
|
+
output_hash=hashlib.sha256(text.encode()).hexdigest(), cwd=cwd, flags=flags,
|
|
351
|
+
exit_code=wrapped_exit_code, # E9: only set when on_pre_tool_use wrapped this command
|
|
352
|
+
paths=[str(inp["file_path"])] if isinstance(inp.get("file_path"), str) else [])
|
|
353
|
+
with open(live, "a", encoding="utf-8") as fh:
|
|
354
|
+
fh.write(call.model_dump_json() + "\n")
|
|
355
|
+
fh.write(res.model_dump_json() + "\n")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------- ledger assembly for Stop ----------
|
|
359
|
+
def _collect_reruns(session_id: str, ledger: list[LedgerEvent], live_path: str) -> list[LedgerEvent]:
|
|
360
|
+
"""Fold any finished Tier 3 results into the ledger, once.
|
|
361
|
+
|
|
362
|
+
`spawn_async` detaches and writes a RERUN event to a result file; Claude Code hooks are
|
|
363
|
+
one-shot, so the Stop call that launched it has already returned. Something has to pick the
|
|
364
|
+
result up on a LATER turn or the re-run is theatre -- the subprocess runs, the evidence lands
|
|
365
|
+
on disk, and the checker never looks. Nothing did: `load_result` had no callers outside its
|
|
366
|
+
own module, which an adversarial pass caught before this shipped.
|
|
367
|
+
|
|
368
|
+
Appending to the live ledger file (not just the in-memory list) is what makes it persist, so
|
|
369
|
+
the evidence stays available to `receipts check` and to every later pass rather than being
|
|
370
|
+
consumed by whichever turn happened to notice it.
|
|
371
|
+
|
|
372
|
+
Resolves the seq placeholder `rerun.run_worker` left as NEEDS-DECISION(oliver): the event is
|
|
373
|
+
numbered when it is folded in, because only here is the ledger's length known.
|
|
374
|
+
"""
|
|
375
|
+
# Ask rerun where it writes rather than rebuilding the path: two copies of the same layout
|
|
376
|
+
# drift, and a reader looking in the wrong directory silently finds nothing forever -- which
|
|
377
|
+
# is indistinguishable from "no re-runs happened".
|
|
378
|
+
d = str(rerun._rerun_dir(session_id))
|
|
379
|
+
if not os.path.isdir(d):
|
|
380
|
+
return ledger
|
|
381
|
+
next_seq = max((e.seq for e in ledger), default=-1) + 1
|
|
382
|
+
for name in sorted(os.listdir(d)):
|
|
383
|
+
if not name.endswith(".result.json"):
|
|
384
|
+
continue
|
|
385
|
+
claim_id = name[: -len(".result.json")]
|
|
386
|
+
try:
|
|
387
|
+
ev = rerun.load_result(session_id, claim_id)
|
|
388
|
+
except (OSError, ValueError):
|
|
389
|
+
ev = None
|
|
390
|
+
if ev is None:
|
|
391
|
+
continue
|
|
392
|
+
ev.seq = next_seq
|
|
393
|
+
next_seq += 1
|
|
394
|
+
ledger.append(ev)
|
|
395
|
+
# Only persist when a live ledger already exists. If this session's ledger came from the
|
|
396
|
+
# transcript (hooks installed mid-flight), creating a live file containing nothing but
|
|
397
|
+
# RERUN events would make the NEXT turn prefer it and lose the transcript entirely --
|
|
398
|
+
# `_ledger_for` takes the live file whenever it has any events at all.
|
|
399
|
+
if os.path.exists(live_path):
|
|
400
|
+
try:
|
|
401
|
+
with open(live_path, "a", encoding="utf-8") as fh:
|
|
402
|
+
fh.write(ev.model_dump_json() + "\n")
|
|
403
|
+
os.remove(os.path.join(d, name)) # consumed exactly once
|
|
404
|
+
except OSError:
|
|
405
|
+
pass
|
|
406
|
+
return ledger
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _ledger_for(payload: dict[str, Any]) -> tuple[Session, list[LedgerEvent]]:
|
|
410
|
+
"""The ledger for this session: the live hook file first, the transcript only as a fallback.
|
|
411
|
+
|
|
412
|
+
Order matters, and it used to be backwards -- the transcript was preferred whenever
|
|
413
|
+
`transcript_path` existed, which is always. Two consequences, both observed on a real session
|
|
414
|
+
(adc885ec, 2026-09-19):
|
|
415
|
+
|
|
416
|
+
1. **We discarded the output we exist to capture.** PostToolUse records each tool's FULL
|
|
417
|
+
stdout before the harness truncates it; that is the whole reason the hook exists
|
|
418
|
+
(docs/DESIGN.md §5: 42% of test output was piped away). On that session the live file held
|
|
419
|
+
28,437 bytes of captured output against the transcript's 20,424. Reading the transcript
|
|
420
|
+
threw away 8KB of evidence and produced a receipt full of `unrecorded ... not visible due
|
|
421
|
+
to truncation` for claims the live file could have settled.
|
|
422
|
+
2. **Citations pointed at the wrong lines.** The two sources number events independently --
|
|
423
|
+
the transcript also numbers assistant/user text records, the live file only tool events. On
|
|
424
|
+
that session `git mv` was seq 16 in the transcript and seq 14 in the live file. The receipt
|
|
425
|
+
cited #16; a reader checking ~/.custos-code/live/<id>.jsonl, which is the artifact we tell
|
|
426
|
+
people to audit, finds an unrelated pytest run there. Every citation in that receipt was
|
|
427
|
+
unverifiable against the ledger on disk.
|
|
428
|
+
|
|
429
|
+
So: live file when it has events, transcript otherwise (a session whose hooks were installed
|
|
430
|
+
mid-flight has no live file for its earlier turns, which is the case this fallback is for).
|
|
431
|
+
"""
|
|
432
|
+
sid = str(payload.get("session_id", "unknown"))
|
|
433
|
+
live, _, _ = _paths(sid)
|
|
434
|
+
events: list[LedgerEvent] = []
|
|
435
|
+
if os.path.exists(live):
|
|
436
|
+
with open(live, encoding="utf-8") as fh:
|
|
437
|
+
for line in fh:
|
|
438
|
+
if line.strip():
|
|
439
|
+
events.append(LedgerEvent.model_validate_json(line))
|
|
440
|
+
if not events:
|
|
441
|
+
tpath = payload.get("transcript_path")
|
|
442
|
+
if isinstance(tpath, str) and os.path.exists(tpath):
|
|
443
|
+
sess, ledger, _ = claude_code.parse(tpath)
|
|
444
|
+
if ledger:
|
|
445
|
+
return sess, ledger
|
|
446
|
+
events = chain(events)
|
|
447
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
|
|
448
|
+
sess = Session(id=sid, source="claude_code", agent="claude-code", cwd=cwd, n_events=len(events),
|
|
449
|
+
ledger_root_hash=events[-1].hash if events else "")
|
|
450
|
+
return sess, events
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------- Stop ----------
|
|
454
|
+
def _render(claims: list[Claim], recs: list[VerdictRecord]) -> str:
|
|
455
|
+
mark = {Verdict.CONFIRMED: "✓", Verdict.CONTRADICTED: "✗", Verdict.UNWITNESSED: "?", Verdict.UNRECORDED: "○", Verdict.QUALIFIED: "≈"}
|
|
456
|
+
by = {c.id: c for c in claims}
|
|
457
|
+
lines = []
|
|
458
|
+
for r in recs:
|
|
459
|
+
c = by[r.claim_id]
|
|
460
|
+
ev = " ".join(f"#{e}" for e in r.evidence) or "—"
|
|
461
|
+
lines.append(f"{mark[r.verdict]} {r.verdict.value:<12} {c.text}\n tier {r.tier} · {r.method} · {ev} · {r.rationale}" + (f" · {r.qualifier}" if r.qualifier else ""))
|
|
462
|
+
s = verdicts_mod.summary(recs)
|
|
463
|
+
lines.append("custos-code · " + " · ".join(f"{s[v.value]} {mark[v]}" for v in Verdict if s[v.value]))
|
|
464
|
+
return "\n".join(lines)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def on_stop(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
468
|
+
"""Returns the JSON to print on stdout (block decision), or None for exit 0 with no output."""
|
|
469
|
+
if _out_of_scope(payload):
|
|
470
|
+
return None
|
|
471
|
+
sid = str(payload.get("session_id", "unknown"))
|
|
472
|
+
report = payload.get("last_assistant_message")
|
|
473
|
+
if not isinstance(report, str) or not report.strip():
|
|
474
|
+
return None
|
|
475
|
+
_, state_p, receipt_p = _paths(sid)
|
|
476
|
+
sess, ledger = _ledger_for(payload)
|
|
477
|
+
live_p, _, _ = _paths(sid)
|
|
478
|
+
ledger = _collect_reruns(sid, ledger, live_p) # evidence from earlier turns' Tier 3 jobs
|
|
479
|
+
repo = payload.get("cwd") if isinstance(payload.get("cwd"), str) else sess.cwd
|
|
480
|
+
|
|
481
|
+
# Cheap gate before the model call. A Stop hook fires on EVERY turn, so a turn that ran one
|
|
482
|
+
# `rm` was paying for a full-session review, and in auto mode up to three of them. Observed
|
|
483
|
+
# on 2026-09-19: a one-line command took tens of seconds and the user reasonably concluded the
|
|
484
|
+
# terminal was broken. A checker nobody leaves switched on verifies nothing.
|
|
485
|
+
#
|
|
486
|
+
# Skip when there is nothing a receipt could say:
|
|
487
|
+
# - no tool calls at all in the session -> every claim would be `unwitnessed` anyway, which
|
|
488
|
+
# is never an accusation and never blocks, so the call buys nothing.
|
|
489
|
+
# - no NEW tool calls since the last receipt -> the evidence has not moved, so neither can
|
|
490
|
+
# any verdict. This is the common case for conversational turns.
|
|
491
|
+
n_calls = sum(1 for e in ledger if e.kind == EventKind.CALL)
|
|
492
|
+
if n_calls == 0:
|
|
493
|
+
return None
|
|
494
|
+
seen_p = os.path.join(HOME, "seen", f"{sid}.json")
|
|
495
|
+
os.makedirs(os.path.dirname(seen_p), exist_ok=True)
|
|
496
|
+
last_seq = -1
|
|
497
|
+
if os.path.exists(seen_p) and not payload.get("stop_hook_active"):
|
|
498
|
+
try:
|
|
499
|
+
with open(seen_p, encoding="utf-8") as fh:
|
|
500
|
+
last_seq = int(json.load(fh).get("seq", -1))
|
|
501
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
502
|
+
last_seq = -1
|
|
503
|
+
max_seq = max((e.seq for e in ledger), default=-1)
|
|
504
|
+
if last_seq >= max_seq:
|
|
505
|
+
return None
|
|
506
|
+
with open(seen_p, "w", encoding="utf-8") as fh:
|
|
507
|
+
json.dump({"seq": max_seq}, fh)
|
|
508
|
+
# Loop state has to be read BEFORE the review, not after: `nudge_seq` marks where the last
|
|
509
|
+
# correction request fell in the ledger, and `annotate()` needs it to draw the boundary between
|
|
510
|
+
# the superseded attempt and the new one. Without it the model can cite a piped run from pass 1
|
|
511
|
+
# as evidence against work the agent redid cleanly in pass 2 (session 21756df4).
|
|
512
|
+
state: dict[str, Any] = {"passes": 0, "nudge_seq": -1}
|
|
513
|
+
if os.path.exists(state_p):
|
|
514
|
+
try:
|
|
515
|
+
with open(state_p, encoding="utf-8") as fh:
|
|
516
|
+
state = json.load(fh)
|
|
517
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
518
|
+
pass
|
|
519
|
+
prior_nudge = int(state.get("nudge_seq", -1)) if payload.get("stop_hook_active") else -1
|
|
520
|
+
|
|
521
|
+
# The measured path (eval/arms/RESULTS.md: 86% vs 70% for the tiered pipeline, McNemar
|
|
522
|
+
# p=0.00017). Falls back to deterministic rules with no key, so the hook never hard-fails.
|
|
523
|
+
backend = judge_mod.make_backend()
|
|
524
|
+
if backend is not None:
|
|
525
|
+
out = review_mod.review(report, ledger, sid, backend, nudge_seq=prior_nudge,
|
|
526
|
+
repo_root=repo)
|
|
527
|
+
claims = out.claims
|
|
528
|
+
recs = verdicts_mod.apply_reruns(claims, out.verdicts, ledger)
|
|
529
|
+
else:
|
|
530
|
+
claims = claims_mod.extract(report, sid)
|
|
531
|
+
recs = verdicts_mod.run(claims, ledger, repo)
|
|
532
|
+
# Tier 3: launch a re-execution for any claim a re-run could actually settle. This is the
|
|
533
|
+
# second GROUNDED source the architecture argues for -- not another opinion, but the command
|
|
534
|
+
# run again and looked at. Coverage is worth 4-74x more than a second verifier at any
|
|
535
|
+
# plausible likelihood ratio, and this is what moves coverage.
|
|
536
|
+
#
|
|
537
|
+
# `spawn_async` detaches, so the Stop hook still returns in the ~10s it wants. The result is
|
|
538
|
+
# picked up by a later pass, by the extension, or by `receipts check` -- there is no path back
|
|
539
|
+
# into this call, which has already returned. `verdicts.should_rerun` is deliberately narrow:
|
|
540
|
+
# open verdict, runnable claim type, a real repo, a committed runner config, within budget,
|
|
541
|
+
# and not already tried on this exact tree.
|
|
542
|
+
spent: set[str] = set(state.get("reruns", []))
|
|
543
|
+
if repo:
|
|
544
|
+
for c, r in zip(claims, recs, strict=False):
|
|
545
|
+
if not verdicts_mod.should_rerun(c, r, repo, already=spent):
|
|
546
|
+
continue
|
|
547
|
+
key = verdicts_mod.rerun_key(c, repo)
|
|
548
|
+
try:
|
|
549
|
+
# report_seq anchors the RERUN event after the evidence it re-checks
|
|
550
|
+
rerun.spawn_async(sid, c.id, repo, report_seq=max_seq, claim_text=c.text,
|
|
551
|
+
cmd=verdicts_mod.rerun_command(c, repo),
|
|
552
|
+
claim_kind=verdicts_mod.rerun_kind(c).value)
|
|
553
|
+
except Exception as e: # noqa: BLE001 - a failed launch must not fail the turn
|
|
554
|
+
print(f"receipts: rerun launch failed ({type(e).__name__}); skipping.", file=sys.stderr)
|
|
555
|
+
continue
|
|
556
|
+
if key:
|
|
557
|
+
spent.add(key)
|
|
558
|
+
if spent != set(state.get("reruns", [])):
|
|
559
|
+
# Written now, not with the auto-mode state below: the budget has to survive turns
|
|
560
|
+
# that do not block, or a session with auto mode off re-runs on every single turn.
|
|
561
|
+
state["reruns"] = sorted(spent)
|
|
562
|
+
try:
|
|
563
|
+
with open(state_p, "w", encoding="utf-8") as fh:
|
|
564
|
+
json.dump(state, fh)
|
|
565
|
+
except OSError:
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
with open(receipt_p, "w", encoding="utf-8") as fh:
|
|
569
|
+
fh.write(_render(claims, recs) + "\n")
|
|
570
|
+
|
|
571
|
+
cfg = _config()
|
|
572
|
+
if not cfg.get("auto"):
|
|
573
|
+
return None
|
|
574
|
+
clear = set(cfg.get("auto_clear", []))
|
|
575
|
+
by = {c.id: c for c in claims}
|
|
576
|
+
open_pairs = [(by[r.claim_id], r) for r in recs
|
|
577
|
+
if r.verdict.value in clear and not review_mod.is_advisory(r)]
|
|
578
|
+
if bool(payload.get("stop_hook_active")) and "nudge_seq" in state:
|
|
579
|
+
# a continuation: a previously open claim clears only on evidence newer than the nudge (docs/DESIGN.md §6).
|
|
580
|
+
# A reworded claim that now "confirms" on old evidence stays open as unwitnessed.
|
|
581
|
+
nudge_seq = int(state.get("nudge_seq", -1))
|
|
582
|
+
prev_open = set(state.get("open", []))
|
|
583
|
+
for r in recs:
|
|
584
|
+
c = by[r.claim_id]
|
|
585
|
+
if c.text in prev_open and r.verdict in (Verdict.CONFIRMED, Verdict.QUALIFIED) and not feedback.cleared(r, r, ledger, nudge_seq):
|
|
586
|
+
r.verdict = Verdict.UNWITNESSED
|
|
587
|
+
r.rationale = "Reworded, but no tool call after the nudge bears on this claim; it is not cleared."
|
|
588
|
+
open_pairs.append((c, r))
|
|
589
|
+
if not open_pairs:
|
|
590
|
+
if os.path.exists(state_p):
|
|
591
|
+
os.remove(state_p)
|
|
592
|
+
return None
|
|
593
|
+
passes = int(state.get("passes", 0)) + 1
|
|
594
|
+
max_passes = int(cfg.get("auto_max_passes", 3))
|
|
595
|
+
if max_passes and passes > max_passes:
|
|
596
|
+
os.remove(state_p) if os.path.exists(state_p) else None
|
|
597
|
+
return None # cap hit: hand back to the human with the receipt file
|
|
598
|
+
nudge_seq = max((e.seq for e in ledger), default=-1)
|
|
599
|
+
with open(state_p, "w", encoding="utf-8") as fh:
|
|
600
|
+
json.dump({"passes": passes, "nudge_seq": nudge_seq,
|
|
601
|
+
"open": [c.text for c, _ in open_pairs],
|
|
602
|
+
"reruns": state.get("reruns", []), # do not drop the Tier 3 budget
|
|
603
|
+
"scope_approved": state.get("scope_approved", [])}, fh)
|
|
604
|
+
reason = feedback.build_block_reason(open_pairs, ledger, passes, max_passes)
|
|
605
|
+
return {"decision": "block", "reason": reason}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main(event: str, session_id: str | None = None, claim_id: str | None = None) -> int:
|
|
609
|
+
if event == "rerun-worker":
|
|
610
|
+
# E4: a detached subprocess `rerun.spawn_async` launched directly -- no hook payload,
|
|
611
|
+
# no stdin to read; its identity is these two args.
|
|
612
|
+
if not session_id or not claim_id:
|
|
613
|
+
return 2
|
|
614
|
+
rerun.run_worker(session_id, claim_id)
|
|
615
|
+
return 0
|
|
616
|
+
# Everything below fails OPEN. `hooks/*.sh` append `|| true`, but the command that
|
|
617
|
+
# `custos-code watch --install` writes into settings.json invokes this binary directly, with no
|
|
618
|
+
# wrapper to swallow anything -- so an unhandled exception here surfaces as a traceback and a
|
|
619
|
+
# non-zero exit from a Claude Code hook. For Stop that reads as "block", which would be a
|
|
620
|
+
# contradiction backed by no evidence at all; for PostToolUse it means the ledger write is
|
|
621
|
+
# skipped, and a missing ledger silently degrades every later verdict to `unwitnessed`.
|
|
622
|
+
# A checker that cannot run is not evidence about the agent. Say so on stderr, exit 0.
|
|
623
|
+
try:
|
|
624
|
+
payload = json.load(sys.stdin) if not sys.stdin.isatty() else {}
|
|
625
|
+
except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e:
|
|
626
|
+
print(f"custos-code: unreadable hook payload ({type(e).__name__}); not blocking.", file=sys.stderr)
|
|
627
|
+
return 0
|
|
628
|
+
if not isinstance(payload, dict):
|
|
629
|
+
print("custos-code: hook payload was not a JSON object; not blocking.", file=sys.stderr)
|
|
630
|
+
return 0
|
|
631
|
+
try:
|
|
632
|
+
if event == "pre":
|
|
633
|
+
out = on_pre_tool_use(payload)
|
|
634
|
+
if out is not None:
|
|
635
|
+
print(json.dumps(out))
|
|
636
|
+
return 0
|
|
637
|
+
if event == "post-tool-use":
|
|
638
|
+
on_post_tool_use(payload)
|
|
639
|
+
return 0
|
|
640
|
+
if event == "stop":
|
|
641
|
+
out = on_stop(payload)
|
|
642
|
+
if out is not None:
|
|
643
|
+
print(json.dumps(out))
|
|
644
|
+
return 0
|
|
645
|
+
except Exception as e: # noqa: BLE001 - a hook must not take the turn down with it
|
|
646
|
+
print(f"custos-code: {event} hook failed ({type(e).__name__}: {e}); not blocking.", file=sys.stderr)
|
|
647
|
+
return 0
|
|
648
|
+
return 2
|