truthstone 0.2.0__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.
- truthstone/__init__.py +7 -0
- truthstone/adapters/__init__.py +0 -0
- truthstone/adapters/claude_code/__init__.py +1 -0
- truthstone/adapters/claude_code/forward.py +308 -0
- truthstone/adapters/claude_code/install.py +268 -0
- truthstone/adapters/claude_code/plugin.py +290 -0
- truthstone/adapters/claude_code/protocol.py +46 -0
- truthstone/adapters/claude_code/settings_scan.py +216 -0
- truthstone/adapters/claude_code/shim.py +500 -0
- truthstone/adapters/claude_code/translate.py +410 -0
- truthstone/adapters/codex/__init__.py +5 -0
- truthstone/adapters/codex/forward.py +302 -0
- truthstone/adapters/codex/forward_native.c +805 -0
- truthstone/adapters/codex/hook.py +357 -0
- truthstone/adapters/codex/install.py +260 -0
- truthstone/adapters/codex/native.py +250 -0
- truthstone/adapters/codex/patch.py +495 -0
- truthstone/adapters/codex/plugin.py +698 -0
- truthstone/adapters/codex/render.py +211 -0
- truthstone/adapters/codex/status.py +181 -0
- truthstone/adapters/codex/translate.py +453 -0
- truthstone/adapters/edits.py +85 -0
- truthstone/adapters/mcp.py +90 -0
- truthstone/adapters/registry.py +161 -0
- truthstone/bench/__init__.py +0 -0
- truthstone/bench/codex/__init__.py +24 -0
- truthstone/bench/codex/__main__.py +5 -0
- truthstone/bench/codex/acceptance.py +1041 -0
- truthstone/bench/codex/accounts.py +187 -0
- truthstone/bench/codex/agents.py +659 -0
- truthstone/bench/codex/appserver.py +215 -0
- truthstone/bench/codex/claims.py +77 -0
- truthstone/bench/codex/claude.py +881 -0
- truthstone/bench/codex/claude_sandbox.py +776 -0
- truthstone/bench/codex/cli.py +1424 -0
- truthstone/bench/codex/grader.py +763 -0
- truthstone/bench/codex/guards.py +980 -0
- truthstone/bench/codex/homes.py +220 -0
- truthstone/bench/codex/hook_recorder.py +163 -0
- truthstone/bench/codex/hook_timer.c +106 -0
- truthstone/bench/codex/leakage.py +308 -0
- truthstone/bench/codex/limits.py +215 -0
- truthstone/bench/codex/naming.py +69 -0
- truthstone/bench/codex/orchestrator.py +999 -0
- truthstone/bench/codex/plan.py +247 -0
- truthstone/bench/codex/premise.py +406 -0
- truthstone/bench/codex/regrade.py +196 -0
- truthstone/bench/codex/report.py +792 -0
- truthstone/bench/codex/scripted.py +119 -0
- truthstone/bench/codex/stats.py +208 -0
- truthstone/bench/codex/suite.py +806 -0
- truthstone/bench/codex/testedits.py +713 -0
- truthstone/bench/codex/toolchain.py +96 -0
- truthstone/bench/codex/transcript.py +650 -0
- truthstone/bench/mutation.py +306 -0
- truthstone/bench/replay.py +179 -0
- truthstone/bench/runner.py +287 -0
- truthstone/bench/tasks.py +301 -0
- truthstone/census/__init__.py +16 -0
- truthstone/census/report.py +634 -0
- truthstone/census/taxonomy.py +363 -0
- truthstone/challenge/__init__.py +6 -0
- truthstone/challenge/ledger.py +199 -0
- truthstone/challenge/renderer.py +129 -0
- truthstone/cli/__init__.py +0 -0
- truthstone/cli/main.py +1587 -0
- truthstone/complexity/__init__.py +5 -0
- truthstone/complexity/engine.py +92 -0
- truthstone/complexity/rules.py +354 -0
- truthstone/complexity/signals.py +313 -0
- truthstone/core/__init__.py +0 -0
- truthstone/core/config.py +664 -0
- truthstone/core/engine.py +1390 -0
- truthstone/core/enums.py +284 -0
- truthstone/core/events.py +109 -0
- truthstone/core/hosts.py +536 -0
- truthstone/core/metrics.py +117 -0
- truthstone/core/models.py +145 -0
- truthstone/core/observe.py +58 -0
- truthstone/core/profile.py +307 -0
- truthstone/core/rehydrate.py +162 -0
- truthstone/core/shellchanges.py +336 -0
- truthstone/core/store.py +2309 -0
- truthstone/core/taskstate.py +126 -0
- truthstone/daemon/__init__.py +0 -0
- truthstone/daemon/__main__.py +31 -0
- truthstone/daemon/app.py +509 -0
- truthstone/daemon/degraded.py +149 -0
- truthstone/daemon/lifecycle.py +291 -0
- truthstone/daemon/logs.py +237 -0
- truthstone/daemon/private_paths.py +139 -0
- truthstone/evidence/__init__.py +5 -0
- truthstone/evidence/engine.py +86 -0
- truthstone/evidence/extractors.py +327 -0
- truthstone/evidence/models.py +63 -0
- truthstone/evidence/pyanalysis.py +393 -0
- truthstone/evidence/resolvers.py +629 -0
- truthstone/intent/__init__.py +6 -0
- truthstone/intent/dead_switch.py +1124 -0
- truthstone/intent/extractor.py +261 -0
- truthstone/intent/lexicon.py +321 -0
- truthstone/intent/models.py +183 -0
- truthstone/intent/premise.py +946 -0
- truthstone/intent/premise_names.py +1497 -0
- truthstone/intent/premise_switch.py +1901 -0
- truthstone/intent/waiver.py +109 -0
- truthstone/names.py +180 -0
- truthstone/planning/__init__.py +5 -0
- truthstone/planning/feasibility.py +75 -0
- truthstone/planning/governor.py +400 -0
- truthstone/planning/integrity.py +43 -0
- truthstone/planning/spec_note.py +489 -0
- truthstone/replay.py +519 -0
- truthstone/repo/__init__.py +5 -0
- truthstone/repo/gitinfo.py +145 -0
- truthstone/repo/index.py +726 -0
- truthstone/repo/manifests.py +163 -0
- truthstone/repo/models.py +193 -0
- truthstone/repo/paths.py +131 -0
- truthstone/repo/scanner.py +223 -0
- truthstone/repo/symbols_python.py +401 -0
- truthstone/repo/symbols_ts.py +214 -0
- truthstone/util/__init__.py +0 -0
- truthstone/util/used_names.py +201 -0
- truthstone/validate/__init__.py +6 -0
- truthstone/validate/checks.py +1659 -0
- truthstone/validate/config_reads.py +506 -0
- truthstone/validate/effects.py +621 -0
- truthstone/validate/fitting.py +322 -0
- truthstone/validate/gaming.py +1365 -0
- truthstone/validate/gated_skips.py +682 -0
- truthstone/validate/js_module_level.py +826 -0
- truthstone/validate/keep_reading.py +242 -0
- truthstone/validate/modify.py +435 -0
- truthstone/validate/original_tests.py +945 -0
- truthstone/validate/premise_unmet.py +318 -0
- truthstone/validate/regressions.py +495 -0
- truthstone/validate/script_effects.py +310 -0
- truthstone/validate/secrets.py +162 -0
- truthstone/validate/shell.py +1058 -0
- truthstone/validate/signoff.py +296 -0
- truthstone/validate/spec_integrity.py +1300 -0
- truthstone/validate/testrefs.py +220 -0
- truthstone/validate/untracked_state.py +472 -0
- truthstone/validate/validator.py +285 -0
- truthstone/validate/weakened_tests.py +1512 -0
- truthstone/validate/yardstick.py +273 -0
- truthstone/verify/__init__.py +5 -0
- truthstone/verify/admitted.py +254 -0
- truthstone/verify/arbiter.py +1187 -0
- truthstone/verify/baseline.py +255 -0
- truthstone/verify/claims.py +270 -0
- truthstone/verify/commands.py +1286 -0
- truthstone/verify/completion_gate.py +764 -0
- truthstone/verify/integrity.py +274 -0
- truthstone/verify/parsers.py +887 -0
- truthstone/verify/runners.py +806 -0
- truthstone/verify/spec_bound.py +161 -0
- truthstone/verify/spec_conflict.py +906 -0
- truthstone/verify/spec_rules.py +561 -0
- truthstone/verify/spec_snapshot.py +828 -0
- truthstone/verify/static.py +72 -0
- truthstone-0.2.0.dist-info/METADATA +377 -0
- truthstone-0.2.0.dist-info/RECORD +167 -0
- truthstone-0.2.0.dist-info/WHEEL +4 -0
- truthstone-0.2.0.dist-info/entry_points.txt +4 -0
- truthstone-0.2.0.dist-info/licenses/LICENSE +201 -0
truthstone/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Truthstone — host-powered reliability and reasoning layer for AI coding agents.
|
|
2
|
+
|
|
3
|
+
Deliberately empty of imports: `truthstone.adapters.claude_code.shim` runs on the hook
|
|
4
|
+
path and must not pay for loading pydantic/fastapi transitively.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Claude Code adapter (SPEC §24.1 — the first and highest-priority integration)."""
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Hot-path forwarder for command hooks: stdin -> Truthstone daemon -> stdout.
|
|
2
|
+
|
|
3
|
+
Why a command hook, and why this file
|
|
4
|
+
-------------------------------------
|
|
5
|
+
Settings-based installs reach the daemon through Claude Code ``http`` hooks, which carry
|
|
6
|
+
the local bearer token in a header. A *plugin* cannot do that. Measured on Claude Code
|
|
7
|
+
2.1.317 (see ``docs/CLAUDE-PLUGIN.md``): a plugin ``http`` hook interpolates header values
|
|
8
|
+
only from variables already in Claude Code's own launch environment — never
|
|
9
|
+
``${CLAUDE_PLUGIN_ROOT}``/``${CLAUDE_PLUGIN_DATA}``, and never anything a ``SessionStart``
|
|
10
|
+
hook writes to ``CLAUDE_ENV_FILE``. A per-user secret therefore cannot reach the header
|
|
11
|
+
without the user exporting it by hand, so the plugin uses ``command`` hooks, and every
|
|
12
|
+
tool call pays for one process. This module keeps that process as cheap as Python allows:
|
|
13
|
+
|
|
14
|
+
* it runs as a **file**, ``python -I -S forward.py``: no ``site`` (no site-packages scan,
|
|
15
|
+
no ``.pth`` processing), no user site, no ``PYTHON*`` variables, and — the security
|
|
16
|
+
half — no working directory on ``sys.path``. Hooks run inside the user's repository, so
|
|
17
|
+
a repository file named ``json.py`` must never be importable here;
|
|
18
|
+
* it imports only builtin C modules: ``_socket`` instead of ``socket`` (whose ``enum``
|
|
19
|
+
import costs ~2 ms) and ``_json`` instead of ``json`` (whose ``re`` import costs
|
|
20
|
+
~7 ms). The scanner below *is* CPython's JSON parser, minus the regex wrappers;
|
|
21
|
+
* it makes **one** request. There is no ``/health`` probe first: a refused connection is
|
|
22
|
+
the probe, and it fails open in microseconds. Liveness checks, revival and the visible
|
|
23
|
+
"unguarded" warning happen once per prompt, in ``plugin.py``, not once per tool call.
|
|
24
|
+
|
|
25
|
+
It must stay standalone: no ``truthstone`` imports (the package is not importable under
|
|
26
|
+
``-S``), standard library only.
|
|
27
|
+
|
|
28
|
+
Two safety properties are enforced here, at the transport boundary, so they hold even if a
|
|
29
|
+
daemon bug — or a foreign process squatting on the daemon's port — produces bad output:
|
|
30
|
+
|
|
31
|
+
* **Truthstone never grants permissions.** A response carrying ``permissionDecision:
|
|
32
|
+
"allow"`` is dropped, which is the same as saying nothing.
|
|
33
|
+
* **Fail open.** Every failure — no handshake, refused connection, timeout, non-200,
|
|
34
|
+
unparsable body — exits 0 with no output, so the agent proceeds as if Truthstone were
|
|
35
|
+
not installed. Exit code 2 (which would *block* the agent) is never used.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import _json
|
|
41
|
+
import _socket
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
|
|
45
|
+
MAX_STDIN_BYTES = 1_000_000
|
|
46
|
+
MAX_RESPONSE_BYTES = 64_000
|
|
47
|
+
DEFAULT_TIMEOUT_S = 4.0
|
|
48
|
+
HOOK_PATH = "/hook/claude-code"
|
|
49
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Ctx:
|
|
53
|
+
"""The attribute bag ``_json.make_scanner`` reads: ``json.decoder``'s defaults.
|
|
54
|
+
|
|
55
|
+
This is a private CPython interface and it does change — 3.15 added ``array_hook`` —
|
|
56
|
+
so construction is guarded below and falls back to the public ``json`` module.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
strict = True
|
|
60
|
+
object_hook = None
|
|
61
|
+
object_pairs_hook = None
|
|
62
|
+
array_hook = None
|
|
63
|
+
parse_float = float
|
|
64
|
+
parse_int = int
|
|
65
|
+
memo: dict = {} # noqa: RUF012 - read by the C scanner, never mutated here
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def parse_constant(name: str) -> float:
|
|
69
|
+
return {"-Infinity": float("-inf"), "Infinity": float("inf"), "NaN": float("nan")}[name]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
_scan = _json.make_scanner(_Ctx())
|
|
74
|
+
except Exception: # noqa: BLE001 - a newer CPython's scanner wants more; use the public API
|
|
75
|
+
_scan = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def loads(text: str) -> object:
|
|
79
|
+
"""Parse one JSON document with the C scanner. Raises ``ValueError`` on bad input."""
|
|
80
|
+
if _scan is None:
|
|
81
|
+
import json # ~7 ms, only on a CPython whose private scanner contract moved
|
|
82
|
+
|
|
83
|
+
return json.loads(text)
|
|
84
|
+
start = len(text) - len(text.lstrip(" \t\n\r"))
|
|
85
|
+
try:
|
|
86
|
+
value, end = _scan(text, start)
|
|
87
|
+
except StopIteration as exc: # the C scanner's "no value here"
|
|
88
|
+
raise ValueError("not JSON") from exc
|
|
89
|
+
if text[end:].strip(" \t\n\r"):
|
|
90
|
+
raise ValueError("trailing data")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Former product names, newest first, as (display, slug, upper): the registry in
|
|
95
|
+
# truthstone/names.py, which this file cannot import. scripts/rename_product.py records a
|
|
96
|
+
# rename in both, and tests/test_names.py keeps them equal.
|
|
97
|
+
# rename-tool: keep-begin former-names
|
|
98
|
+
FORMER: tuple[tuple[str, str, str], ...] = (("AgentGuard", "agentguard", "AGENTGUARD"),)
|
|
99
|
+
# rename-tool: keep-end
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def setting(key: str) -> str:
|
|
103
|
+
"""``TRUTHSTONE_<key>``, else the same setting under a former name; ``""`` if unset.
|
|
104
|
+
|
|
105
|
+
The rule of ``names.env``: the first non-empty value wins, the current name first.
|
|
106
|
+
"""
|
|
107
|
+
for upper in ("TRUTHSTONE", *(former[2] for former in FORMER)):
|
|
108
|
+
value = os.environ.get(upper + "_" + key)
|
|
109
|
+
if value:
|
|
110
|
+
return value
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def truthstone_home() -> str:
|
|
115
|
+
"""The rule of ``names.data_dir``: ``$TRUTHSTONE_HOME``, else ``~/.truthstone``, unless
|
|
116
|
+
only a former name's directory exists."""
|
|
117
|
+
explicit = setting("HOME")
|
|
118
|
+
if explicit:
|
|
119
|
+
return os.path.expanduser(explicit)
|
|
120
|
+
home = os.path.expanduser("~/.truthstone")
|
|
121
|
+
if not os.path.lexists(home):
|
|
122
|
+
for former in FORMER:
|
|
123
|
+
candidate = os.path.expanduser("~/." + former[1])
|
|
124
|
+
if os.path.lexists(candidate):
|
|
125
|
+
return candidate
|
|
126
|
+
return home
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_handshake(home: str) -> dict | None:
|
|
130
|
+
"""``daemon.json``, or ``None``. A symlinked handshake is refused, as in the shim."""
|
|
131
|
+
path = os.path.join(home, "daemon.json")
|
|
132
|
+
try:
|
|
133
|
+
if os.path.islink(path):
|
|
134
|
+
return None
|
|
135
|
+
with open(path, "rb") as fh:
|
|
136
|
+
raw = fh.read(MAX_RESPONSE_BYTES + 1)
|
|
137
|
+
if len(raw) > MAX_RESPONSE_BYTES:
|
|
138
|
+
return None
|
|
139
|
+
data = loads(raw.decode("utf-8"))
|
|
140
|
+
except (OSError, ValueError, UnicodeDecodeError):
|
|
141
|
+
return None
|
|
142
|
+
return data if isinstance(data, dict) else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def loopback_ipv4(host: object) -> str | None:
|
|
146
|
+
"""The daemon binds IPv4 loopback only; refuse to send the token anywhere else."""
|
|
147
|
+
if not isinstance(host, str):
|
|
148
|
+
return None
|
|
149
|
+
host = host.strip()
|
|
150
|
+
if host.lower() == "localhost":
|
|
151
|
+
return "127.0.0.1"
|
|
152
|
+
parts = host.split(".")
|
|
153
|
+
if len(parts) != 4 or not all(p.isdigit() and len(p) <= 3 for p in parts):
|
|
154
|
+
return None
|
|
155
|
+
octets = [int(p) for p in parts]
|
|
156
|
+
if octets[0] != 127 or any(o > 255 for o in octets):
|
|
157
|
+
return None
|
|
158
|
+
return ".".join(str(o) for o in octets)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def endpoint_of(hs: dict) -> tuple[str, int, str] | None:
|
|
162
|
+
"""``(host, port, token)`` from a handshake, or ``None`` if any part is unsafe."""
|
|
163
|
+
host = loopback_ipv4(hs.get("host", "127.0.0.1"))
|
|
164
|
+
port = hs.get("port")
|
|
165
|
+
token = hs.get("token")
|
|
166
|
+
if host is None or type(port) is not int or not 0 < port <= 65535:
|
|
167
|
+
return None
|
|
168
|
+
if not isinstance(token, str) or not token or not token.isascii():
|
|
169
|
+
return None
|
|
170
|
+
if any(c in token for c in "\r\n "):
|
|
171
|
+
return None # header injection
|
|
172
|
+
return host, port, token
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _dechunk(body: bytes) -> bytes:
|
|
176
|
+
out = bytearray()
|
|
177
|
+
while True:
|
|
178
|
+
line, sep, rest = body.partition(b"\r\n")
|
|
179
|
+
if not sep:
|
|
180
|
+
raise ValueError("truncated chunk header")
|
|
181
|
+
size = int(line.split(b";", 1)[0].strip() or b"0", 16)
|
|
182
|
+
if size == 0:
|
|
183
|
+
return bytes(out)
|
|
184
|
+
if len(rest) < size:
|
|
185
|
+
raise ValueError("truncated chunk")
|
|
186
|
+
out += rest[:size]
|
|
187
|
+
body = rest[size + 2 :]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def post(host: str, port: int, token: str, body: bytes, timeout: float) -> bytes:
|
|
191
|
+
"""One HTTP/1.1 POST over a raw socket; the 200 response body, or ``b""``."""
|
|
192
|
+
head = (
|
|
193
|
+
f"POST {HOOK_PATH} HTTP/1.1\r\n"
|
|
194
|
+
f"Host: {host}:{port}\r\n"
|
|
195
|
+
"Content-Type: application/json\r\n"
|
|
196
|
+
f"Authorization: Bearer {token}\r\n"
|
|
197
|
+
f"Content-Length: {len(body)}\r\n"
|
|
198
|
+
"Connection: close\r\n\r\n"
|
|
199
|
+
).encode("ascii")
|
|
200
|
+
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
201
|
+
try:
|
|
202
|
+
sock.settimeout(timeout)
|
|
203
|
+
sock.connect((host, port))
|
|
204
|
+
sock.sendall(head + body)
|
|
205
|
+
chunks: list[bytes] = []
|
|
206
|
+
size = 0
|
|
207
|
+
while True:
|
|
208
|
+
chunk = sock.recv(65536)
|
|
209
|
+
if not chunk:
|
|
210
|
+
break
|
|
211
|
+
size += len(chunk)
|
|
212
|
+
if size > MAX_RESPONSE_BYTES + 16_384:
|
|
213
|
+
return b""
|
|
214
|
+
chunks.append(chunk)
|
|
215
|
+
finally:
|
|
216
|
+
sock.close()
|
|
217
|
+
|
|
218
|
+
raw = b"".join(chunks)
|
|
219
|
+
header, sep, payload = raw.partition(b"\r\n\r\n")
|
|
220
|
+
if not sep:
|
|
221
|
+
return b""
|
|
222
|
+
lines = header.split(b"\r\n")
|
|
223
|
+
status = lines[0].split()
|
|
224
|
+
if len(status) < 2 or status[1] != b"200":
|
|
225
|
+
return b""
|
|
226
|
+
for line in lines[1:]:
|
|
227
|
+
name, _, value = line.partition(b":")
|
|
228
|
+
if name.strip().lower() == b"transfer-encoding" and b"chunked" in value.lower():
|
|
229
|
+
return _dechunk(payload)
|
|
230
|
+
return payload
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def grants_permission(response: dict) -> bool:
|
|
234
|
+
"""Whether a response would approve a tool call on the developer's behalf."""
|
|
235
|
+
specific = response.get("hookSpecificOutput")
|
|
236
|
+
if isinstance(specific, dict):
|
|
237
|
+
if specific.get("permissionDecision") == "allow":
|
|
238
|
+
return True
|
|
239
|
+
decision = specific.get("decision") # PermissionRequest shape
|
|
240
|
+
if isinstance(decision, dict) and decision.get("behavior") == "allow":
|
|
241
|
+
return True
|
|
242
|
+
return response.get("decision") == "approve" # legacy alias of "allow"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def standing_down() -> bool:
|
|
246
|
+
"""Whether the plugin must stay silent because settings hooks already cover this.
|
|
247
|
+
|
|
248
|
+
``plugin.py`` writes these markers at SessionStart when it finds Truthstone's
|
|
249
|
+
settings-based hooks in a settings file Claude Code reads for this project. Both
|
|
250
|
+
installs would otherwise handle every event, and the daemon would count each tool
|
|
251
|
+
call, challenge and completion-gate hold twice.
|
|
252
|
+
"""
|
|
253
|
+
data = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
254
|
+
if not data:
|
|
255
|
+
return False
|
|
256
|
+
marks = os.path.join(data, "standdown")
|
|
257
|
+
if os.path.exists(os.path.join(marks, "user")):
|
|
258
|
+
return True
|
|
259
|
+
project = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
260
|
+
return bool(project) and os.path.exists(os.path.join(marks, project_key(project)))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def project_key(project_dir: str) -> str:
|
|
264
|
+
"""A filename for a project directory, shared by writer (plugin.py) and reader."""
|
|
265
|
+
key = "".join(c if c.isalnum() else "-" for c in project_dir)
|
|
266
|
+
return "project" + key[-200:]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _timeout(argv: list[str]) -> float:
|
|
270
|
+
if "--timeout" in argv:
|
|
271
|
+
try:
|
|
272
|
+
value = float(argv[argv.index("--timeout") + 1])
|
|
273
|
+
except (IndexError, ValueError):
|
|
274
|
+
return DEFAULT_TIMEOUT_S
|
|
275
|
+
if 0 < value <= 60:
|
|
276
|
+
return value
|
|
277
|
+
return DEFAULT_TIMEOUT_S
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run(argv: list[str], stdin: object, stdout: object) -> int:
|
|
281
|
+
"""Forward one hook event. Always returns 0: Truthstone never blocks by crashing."""
|
|
282
|
+
try:
|
|
283
|
+
if setting("DISABLE").strip().lower() in _TRUE:
|
|
284
|
+
return 0
|
|
285
|
+
if standing_down():
|
|
286
|
+
return 0
|
|
287
|
+
body = stdin.read(MAX_STDIN_BYTES + 1)
|
|
288
|
+
if not body or len(body) > MAX_STDIN_BYTES or not body.strip():
|
|
289
|
+
return 0
|
|
290
|
+
hs = read_handshake(truthstone_home())
|
|
291
|
+
target = endpoint_of(hs) if hs else None
|
|
292
|
+
if target is None:
|
|
293
|
+
return 0
|
|
294
|
+
raw = post(*target, body, _timeout(argv))
|
|
295
|
+
if not raw:
|
|
296
|
+
return 0
|
|
297
|
+
response = loads(raw.decode("utf-8"))
|
|
298
|
+
if not isinstance(response, dict) or not response or grants_permission(response):
|
|
299
|
+
return 0
|
|
300
|
+
stdout.write(raw)
|
|
301
|
+
stdout.flush()
|
|
302
|
+
except Exception: # noqa: BLE001 - fail open: any failure means "no decision"
|
|
303
|
+
return 0
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
raise SystemExit(run(sys.argv[1:], sys.stdin.buffer, sys.stdout.buffer))
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Install/uninstall Truthstone's hooks in Claude Code settings (SPEC §26).
|
|
2
|
+
|
|
3
|
+
This is the *settings-based* install, kept as the fallback for setups that cannot use the
|
|
4
|
+
Claude Code plugin (``plugins/truthstone-claude``; see ``docs/CLAUDE-PLUGIN.md``). The two
|
|
5
|
+
must never both handle a session: the plugin stands down wherever these hooks are present
|
|
6
|
+
(``settings_scan``), and ``truthstone install`` / ``doctor`` say so.
|
|
7
|
+
|
|
8
|
+
Transport split, and why
|
|
9
|
+
------------------------
|
|
10
|
+
* ``SessionStart`` is a **command** hook running the shim with ``--ensure-daemon``. It
|
|
11
|
+
fires once per session, so ~50ms of process startup is invisible, and it guarantees the
|
|
12
|
+
daemon is warm before any hot-path hook fires.
|
|
13
|
+
* Everything else is an **http** hook straight to the daemon. Claude Code performs the
|
|
14
|
+
request itself, so per-tool-call cost is a localhost round trip instead of a Python
|
|
15
|
+
interpreter start — the difference between meeting and missing SPEC §8.
|
|
16
|
+
|
|
17
|
+
``PreToolUse``/``PostToolUse`` are matched to tools that can change something: the edit
|
|
18
|
+
tools, both shells, and MCP tools. Read/Grep/Glob would short-circuit to ALLOW anyway, so
|
|
19
|
+
intercepting them would be pure latency for no signal. ``PostToolUseFailure`` is matched
|
|
20
|
+
to the shells: a test run that exits non-zero arrives *only* there, so without it the
|
|
21
|
+
completion gate never sees a failing run (G1).
|
|
22
|
+
|
|
23
|
+
Idempotent: installing twice leaves one copy; uninstalling removes exactly what was added
|
|
24
|
+
and nothing a human put there.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import shlex
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from truthstone.adapters.claude_code import settings_scan
|
|
36
|
+
from truthstone.core.config import Settings, get_or_create_token, write_private_text
|
|
37
|
+
|
|
38
|
+
# Tools that can change something. An anchored regex: unanchored, "Write" would also match
|
|
39
|
+
# TodoWrite, and Claude Code treats any matcher with regex characters as a regex.
|
|
40
|
+
MUTATING_MATCHER = "^(Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|mcp__.*)$"
|
|
41
|
+
# Where a failing command's output arrives (exact-name list, no regex needed).
|
|
42
|
+
FAILURE_MATCHER = "Bash|PowerShell"
|
|
43
|
+
|
|
44
|
+
# Hook timeouts (seconds). Deliberately tight: a wedged daemon must never hold the
|
|
45
|
+
# developer hostage, and the fail-open path is cheap.
|
|
46
|
+
TIMEOUTS = {
|
|
47
|
+
"SessionStart": 20,
|
|
48
|
+
"Health": 12,
|
|
49
|
+
"UserPromptSubmit": 10,
|
|
50
|
+
"PreToolUse": 5,
|
|
51
|
+
"PostToolUse": 5,
|
|
52
|
+
"PostToolUseFailure": 5,
|
|
53
|
+
"Stop": 10,
|
|
54
|
+
"SessionEnd": 3,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Kept for callers and tests that predate the shared module.
|
|
58
|
+
_command_argv = settings_scan.command_argv
|
|
59
|
+
_is_ours_hook = settings_scan.is_ours_hook
|
|
60
|
+
_is_ours = settings_scan.is_ours_group
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def global_settings_path() -> Path:
|
|
64
|
+
"""User settings — under ``CLAUDE_CONFIG_DIR`` when Claude Code is configured to use one."""
|
|
65
|
+
return settings_scan.user_settings_path()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def project_settings_path(workspace: Path | None = None) -> Path:
|
|
69
|
+
# settings.local.json is gitignored by Claude Code convention, which matters here
|
|
70
|
+
# because the config carries a local auth token.
|
|
71
|
+
return (workspace or Path.cwd()) / ".claude" / "settings.local.json"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def settings_path(
|
|
75
|
+
project: bool = False, global_: bool = False, workspace: Path | None = None
|
|
76
|
+
) -> Path:
|
|
77
|
+
"""Resolve an install target, rejecting ambiguous scope flags."""
|
|
78
|
+
if project and global_:
|
|
79
|
+
raise ValueError("choose exactly one of --project or --global")
|
|
80
|
+
return project_settings_path(workspace) if project else global_settings_path()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _http_hook(url: str, token: str, timeout: int) -> dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"type": "http",
|
|
86
|
+
"url": url,
|
|
87
|
+
"headers": {
|
|
88
|
+
"Authorization": f"Bearer {token}",
|
|
89
|
+
# Doubles as the ownership marker for _is_ours() and as a label for any
|
|
90
|
+
# human who opens settings.json and wonders what this entry is.
|
|
91
|
+
settings_scan.OWNERSHIP_HEADER: "1",
|
|
92
|
+
},
|
|
93
|
+
"timeout": timeout,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _command_hook(
|
|
98
|
+
argv: list[str], timeout: int, status_message: str | None = None
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Build a Claude Code command hook from argv.
|
|
101
|
+
|
|
102
|
+
Claude Code's command-hook schema accepts one shell command string, not a separate
|
|
103
|
+
executable plus an ``args`` array. ``shlex.join`` keeps paths and other arguments
|
|
104
|
+
correctly quoted while preserving the exact argv we intend to run.
|
|
105
|
+
"""
|
|
106
|
+
hook: dict[str, Any] = {
|
|
107
|
+
"type": "command",
|
|
108
|
+
"command": shlex.join(argv),
|
|
109
|
+
"timeout": timeout,
|
|
110
|
+
}
|
|
111
|
+
if status_message is not None:
|
|
112
|
+
hook["statusMessage"] = status_message
|
|
113
|
+
return hook
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _shim_argv(py: str, flag: str) -> list[str]:
|
|
117
|
+
# `-P`: never put the working directory on sys.path. Hooks run inside the developer's
|
|
118
|
+
# repository, and `python -m` would otherwise import a repository file named, say,
|
|
119
|
+
# `json.py` in place of the standard library — code execution by opening a folder.
|
|
120
|
+
return [py, "-P", "-m", settings_scan.SHIM_MODULE, flag]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_hook_config(settings: Settings | None = None, python_exe: str | None = None) -> dict[str, Any]:
|
|
124
|
+
settings = settings or Settings.load()
|
|
125
|
+
token = get_or_create_token()
|
|
126
|
+
url = f"{settings.daemon.base_url}/hook/claude-code"
|
|
127
|
+
py = python_exe or sys.executable
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"SessionStart": [
|
|
131
|
+
{
|
|
132
|
+
"matcher": "startup|resume|clear",
|
|
133
|
+
"hooks": [
|
|
134
|
+
_command_hook(
|
|
135
|
+
_shim_argv(py, "--ensure-daemon"),
|
|
136
|
+
TIMEOUTS["SessionStart"],
|
|
137
|
+
"Truthstone: warming repository index",
|
|
138
|
+
)
|
|
139
|
+
],
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"UserPromptSubmit": [
|
|
143
|
+
{
|
|
144
|
+
"hooks": [
|
|
145
|
+
# Health check first: detect a dead daemon, try once to revive it,
|
|
146
|
+
# and tell the developer if it cannot be. Silent fail-open would
|
|
147
|
+
# leave them believing they are guarded when they are not.
|
|
148
|
+
# Once per prompt, not per tool call, so the cost is invisible.
|
|
149
|
+
_command_hook(_shim_argv(py, "--health"), TIMEOUTS["Health"]),
|
|
150
|
+
_http_hook(url, token, TIMEOUTS["UserPromptSubmit"]),
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
"PreToolUse": [
|
|
155
|
+
{"matcher": MUTATING_MATCHER, "hooks": [_http_hook(url, token, TIMEOUTS["PreToolUse"])]}
|
|
156
|
+
],
|
|
157
|
+
"PostToolUse": [
|
|
158
|
+
{"matcher": MUTATING_MATCHER, "hooks": [_http_hook(url, token, TIMEOUTS["PostToolUse"])]}
|
|
159
|
+
],
|
|
160
|
+
"PostToolUseFailure": [
|
|
161
|
+
{
|
|
162
|
+
"matcher": FAILURE_MATCHER,
|
|
163
|
+
"hooks": [_http_hook(url, token, TIMEOUTS["PostToolUseFailure"])],
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
"Stop": [{"hooks": [_http_hook(url, token, TIMEOUTS["Stop"])]}],
|
|
167
|
+
"SessionEnd": [{"hooks": [_http_hook(url, token, TIMEOUTS["SessionEnd"])]}],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _remove_ours_from_group(group: Any) -> Any:
|
|
172
|
+
"""Remove only our hooks, preserving a user's hooks in a mixed group.
|
|
173
|
+
|
|
174
|
+
Claude groups can contain multiple hook entries. Treating the group as the ownership
|
|
175
|
+
unit would delete an unrelated hook whenever a developer placed it beside ours.
|
|
176
|
+
``None`` means the group became empty and may be removed by the caller.
|
|
177
|
+
"""
|
|
178
|
+
if not isinstance(group, dict):
|
|
179
|
+
return group
|
|
180
|
+
hooks = group.get("hooks")
|
|
181
|
+
if not isinstance(hooks, list):
|
|
182
|
+
return group
|
|
183
|
+
remaining = [hook for hook in hooks if not _is_ours_hook(hook)]
|
|
184
|
+
if len(remaining) == len(hooks):
|
|
185
|
+
return group
|
|
186
|
+
if not remaining:
|
|
187
|
+
return None
|
|
188
|
+
out = dict(group)
|
|
189
|
+
out["hooks"] = remaining
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _load(path: Path) -> dict[str, Any]:
|
|
194
|
+
if not path.exists():
|
|
195
|
+
return {}
|
|
196
|
+
try:
|
|
197
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
198
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
199
|
+
raise RuntimeError(f"{path} is not valid JSON; refusing to overwrite it ({exc})") from exc
|
|
200
|
+
return data if isinstance(data, dict) else {}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _save(path: Path, data: dict[str, Any]) -> None:
|
|
204
|
+
# Truthstone's bearer token is embedded in the HTTP hook headers. Both project-local
|
|
205
|
+
# and global Claude settings are therefore credential-bearing files.
|
|
206
|
+
write_private_text(path, json.dumps(data, indent=2) + "\n")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def merge_hooks(existing: dict[str, Any], new_hooks: dict[str, Any]) -> dict[str, Any]:
|
|
210
|
+
"""Add our groups, replacing any previous Truthstone groups, preserving all others."""
|
|
211
|
+
out = dict(existing)
|
|
212
|
+
hooks: dict[str, Any] = dict(out.get("hooks") or {})
|
|
213
|
+
|
|
214
|
+
for event, groups in new_hooks.items():
|
|
215
|
+
current = []
|
|
216
|
+
for group in hooks.get(event) or []:
|
|
217
|
+
cleaned = _remove_ours_from_group(group)
|
|
218
|
+
if cleaned is not None:
|
|
219
|
+
current.append(cleaned)
|
|
220
|
+
hooks[event] = current + list(groups)
|
|
221
|
+
|
|
222
|
+
out["hooks"] = hooks
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def strip_hooks(existing: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
out = dict(existing)
|
|
228
|
+
hooks: dict[str, Any] = dict(out.get("hooks") or {})
|
|
229
|
+
for event in list(hooks):
|
|
230
|
+
remaining = []
|
|
231
|
+
for group in hooks.get(event) or []:
|
|
232
|
+
cleaned = _remove_ours_from_group(group)
|
|
233
|
+
if cleaned is not None:
|
|
234
|
+
remaining.append(cleaned)
|
|
235
|
+
if remaining:
|
|
236
|
+
hooks[event] = remaining
|
|
237
|
+
else:
|
|
238
|
+
hooks.pop(event)
|
|
239
|
+
if hooks:
|
|
240
|
+
out["hooks"] = hooks
|
|
241
|
+
else:
|
|
242
|
+
out.pop("hooks", None)
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def install(
|
|
247
|
+
path: Path, settings: Settings | None = None, dry_run: bool = False, python_exe: str | None = None
|
|
248
|
+
) -> tuple[dict[str, Any], bool]:
|
|
249
|
+
"""Returns (resulting settings, changed)."""
|
|
250
|
+
existing = _load(path)
|
|
251
|
+
merged = merge_hooks(existing, build_hook_config(settings, python_exe))
|
|
252
|
+
changed = merged != existing
|
|
253
|
+
if changed and not dry_run:
|
|
254
|
+
_save(path, merged)
|
|
255
|
+
return merged, changed
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def uninstall(path: Path, dry_run: bool = False) -> tuple[dict[str, Any], bool]:
|
|
259
|
+
existing = _load(path)
|
|
260
|
+
stripped = strip_hooks(existing)
|
|
261
|
+
changed = stripped != existing
|
|
262
|
+
if changed and not dry_run:
|
|
263
|
+
_save(path, stripped)
|
|
264
|
+
return stripped, changed
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def is_installed(path: Path) -> bool:
|
|
268
|
+
return settings_scan.hooks_installed(path)
|