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.
Files changed (167) hide show
  1. truthstone/__init__.py +7 -0
  2. truthstone/adapters/__init__.py +0 -0
  3. truthstone/adapters/claude_code/__init__.py +1 -0
  4. truthstone/adapters/claude_code/forward.py +308 -0
  5. truthstone/adapters/claude_code/install.py +268 -0
  6. truthstone/adapters/claude_code/plugin.py +290 -0
  7. truthstone/adapters/claude_code/protocol.py +46 -0
  8. truthstone/adapters/claude_code/settings_scan.py +216 -0
  9. truthstone/adapters/claude_code/shim.py +500 -0
  10. truthstone/adapters/claude_code/translate.py +410 -0
  11. truthstone/adapters/codex/__init__.py +5 -0
  12. truthstone/adapters/codex/forward.py +302 -0
  13. truthstone/adapters/codex/forward_native.c +805 -0
  14. truthstone/adapters/codex/hook.py +357 -0
  15. truthstone/adapters/codex/install.py +260 -0
  16. truthstone/adapters/codex/native.py +250 -0
  17. truthstone/adapters/codex/patch.py +495 -0
  18. truthstone/adapters/codex/plugin.py +698 -0
  19. truthstone/adapters/codex/render.py +211 -0
  20. truthstone/adapters/codex/status.py +181 -0
  21. truthstone/adapters/codex/translate.py +453 -0
  22. truthstone/adapters/edits.py +85 -0
  23. truthstone/adapters/mcp.py +90 -0
  24. truthstone/adapters/registry.py +161 -0
  25. truthstone/bench/__init__.py +0 -0
  26. truthstone/bench/codex/__init__.py +24 -0
  27. truthstone/bench/codex/__main__.py +5 -0
  28. truthstone/bench/codex/acceptance.py +1041 -0
  29. truthstone/bench/codex/accounts.py +187 -0
  30. truthstone/bench/codex/agents.py +659 -0
  31. truthstone/bench/codex/appserver.py +215 -0
  32. truthstone/bench/codex/claims.py +77 -0
  33. truthstone/bench/codex/claude.py +881 -0
  34. truthstone/bench/codex/claude_sandbox.py +776 -0
  35. truthstone/bench/codex/cli.py +1424 -0
  36. truthstone/bench/codex/grader.py +763 -0
  37. truthstone/bench/codex/guards.py +980 -0
  38. truthstone/bench/codex/homes.py +220 -0
  39. truthstone/bench/codex/hook_recorder.py +163 -0
  40. truthstone/bench/codex/hook_timer.c +106 -0
  41. truthstone/bench/codex/leakage.py +308 -0
  42. truthstone/bench/codex/limits.py +215 -0
  43. truthstone/bench/codex/naming.py +69 -0
  44. truthstone/bench/codex/orchestrator.py +999 -0
  45. truthstone/bench/codex/plan.py +247 -0
  46. truthstone/bench/codex/premise.py +406 -0
  47. truthstone/bench/codex/regrade.py +196 -0
  48. truthstone/bench/codex/report.py +792 -0
  49. truthstone/bench/codex/scripted.py +119 -0
  50. truthstone/bench/codex/stats.py +208 -0
  51. truthstone/bench/codex/suite.py +806 -0
  52. truthstone/bench/codex/testedits.py +713 -0
  53. truthstone/bench/codex/toolchain.py +96 -0
  54. truthstone/bench/codex/transcript.py +650 -0
  55. truthstone/bench/mutation.py +306 -0
  56. truthstone/bench/replay.py +179 -0
  57. truthstone/bench/runner.py +287 -0
  58. truthstone/bench/tasks.py +301 -0
  59. truthstone/census/__init__.py +16 -0
  60. truthstone/census/report.py +634 -0
  61. truthstone/census/taxonomy.py +363 -0
  62. truthstone/challenge/__init__.py +6 -0
  63. truthstone/challenge/ledger.py +199 -0
  64. truthstone/challenge/renderer.py +129 -0
  65. truthstone/cli/__init__.py +0 -0
  66. truthstone/cli/main.py +1587 -0
  67. truthstone/complexity/__init__.py +5 -0
  68. truthstone/complexity/engine.py +92 -0
  69. truthstone/complexity/rules.py +354 -0
  70. truthstone/complexity/signals.py +313 -0
  71. truthstone/core/__init__.py +0 -0
  72. truthstone/core/config.py +664 -0
  73. truthstone/core/engine.py +1390 -0
  74. truthstone/core/enums.py +284 -0
  75. truthstone/core/events.py +109 -0
  76. truthstone/core/hosts.py +536 -0
  77. truthstone/core/metrics.py +117 -0
  78. truthstone/core/models.py +145 -0
  79. truthstone/core/observe.py +58 -0
  80. truthstone/core/profile.py +307 -0
  81. truthstone/core/rehydrate.py +162 -0
  82. truthstone/core/shellchanges.py +336 -0
  83. truthstone/core/store.py +2309 -0
  84. truthstone/core/taskstate.py +126 -0
  85. truthstone/daemon/__init__.py +0 -0
  86. truthstone/daemon/__main__.py +31 -0
  87. truthstone/daemon/app.py +509 -0
  88. truthstone/daemon/degraded.py +149 -0
  89. truthstone/daemon/lifecycle.py +291 -0
  90. truthstone/daemon/logs.py +237 -0
  91. truthstone/daemon/private_paths.py +139 -0
  92. truthstone/evidence/__init__.py +5 -0
  93. truthstone/evidence/engine.py +86 -0
  94. truthstone/evidence/extractors.py +327 -0
  95. truthstone/evidence/models.py +63 -0
  96. truthstone/evidence/pyanalysis.py +393 -0
  97. truthstone/evidence/resolvers.py +629 -0
  98. truthstone/intent/__init__.py +6 -0
  99. truthstone/intent/dead_switch.py +1124 -0
  100. truthstone/intent/extractor.py +261 -0
  101. truthstone/intent/lexicon.py +321 -0
  102. truthstone/intent/models.py +183 -0
  103. truthstone/intent/premise.py +946 -0
  104. truthstone/intent/premise_names.py +1497 -0
  105. truthstone/intent/premise_switch.py +1901 -0
  106. truthstone/intent/waiver.py +109 -0
  107. truthstone/names.py +180 -0
  108. truthstone/planning/__init__.py +5 -0
  109. truthstone/planning/feasibility.py +75 -0
  110. truthstone/planning/governor.py +400 -0
  111. truthstone/planning/integrity.py +43 -0
  112. truthstone/planning/spec_note.py +489 -0
  113. truthstone/replay.py +519 -0
  114. truthstone/repo/__init__.py +5 -0
  115. truthstone/repo/gitinfo.py +145 -0
  116. truthstone/repo/index.py +726 -0
  117. truthstone/repo/manifests.py +163 -0
  118. truthstone/repo/models.py +193 -0
  119. truthstone/repo/paths.py +131 -0
  120. truthstone/repo/scanner.py +223 -0
  121. truthstone/repo/symbols_python.py +401 -0
  122. truthstone/repo/symbols_ts.py +214 -0
  123. truthstone/util/__init__.py +0 -0
  124. truthstone/util/used_names.py +201 -0
  125. truthstone/validate/__init__.py +6 -0
  126. truthstone/validate/checks.py +1659 -0
  127. truthstone/validate/config_reads.py +506 -0
  128. truthstone/validate/effects.py +621 -0
  129. truthstone/validate/fitting.py +322 -0
  130. truthstone/validate/gaming.py +1365 -0
  131. truthstone/validate/gated_skips.py +682 -0
  132. truthstone/validate/js_module_level.py +826 -0
  133. truthstone/validate/keep_reading.py +242 -0
  134. truthstone/validate/modify.py +435 -0
  135. truthstone/validate/original_tests.py +945 -0
  136. truthstone/validate/premise_unmet.py +318 -0
  137. truthstone/validate/regressions.py +495 -0
  138. truthstone/validate/script_effects.py +310 -0
  139. truthstone/validate/secrets.py +162 -0
  140. truthstone/validate/shell.py +1058 -0
  141. truthstone/validate/signoff.py +296 -0
  142. truthstone/validate/spec_integrity.py +1300 -0
  143. truthstone/validate/testrefs.py +220 -0
  144. truthstone/validate/untracked_state.py +472 -0
  145. truthstone/validate/validator.py +285 -0
  146. truthstone/validate/weakened_tests.py +1512 -0
  147. truthstone/validate/yardstick.py +273 -0
  148. truthstone/verify/__init__.py +5 -0
  149. truthstone/verify/admitted.py +254 -0
  150. truthstone/verify/arbiter.py +1187 -0
  151. truthstone/verify/baseline.py +255 -0
  152. truthstone/verify/claims.py +270 -0
  153. truthstone/verify/commands.py +1286 -0
  154. truthstone/verify/completion_gate.py +764 -0
  155. truthstone/verify/integrity.py +274 -0
  156. truthstone/verify/parsers.py +887 -0
  157. truthstone/verify/runners.py +806 -0
  158. truthstone/verify/spec_bound.py +161 -0
  159. truthstone/verify/spec_conflict.py +906 -0
  160. truthstone/verify/spec_rules.py +561 -0
  161. truthstone/verify/spec_snapshot.py +828 -0
  162. truthstone/verify/static.py +72 -0
  163. truthstone-0.2.0.dist-info/METADATA +377 -0
  164. truthstone-0.2.0.dist-info/RECORD +167 -0
  165. truthstone-0.2.0.dist-info/WHEEL +4 -0
  166. truthstone-0.2.0.dist-info/entry_points.txt +4 -0
  167. 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)