zikaron 0.1.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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
zikaron/hook/connect.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""The hook's own single-attempt connect-or-start sequence.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §Lifecycle's five-step start-if-absent sequence is normative, and
|
|
4
|
+
`zikaron.service.lifecycle.connect_start_if_absent` already implements it — but that module also
|
|
5
|
+
imports `zikaron.service.context` and `zikaron.service.server` for its unrelated `idle_self_stop`
|
|
6
|
+
function, which pulls `aiosqlite`, `sqlite-vec` and `fastembed`'s own dependency chain into any
|
|
7
|
+
process that imports the module at all, whichever function it actually calls (verified directly:
|
|
8
|
+
`import zikaron.service.lifecycle` adds `aiosqlite`, `numpy` and `sqlite_vec` to `sys.modules`).
|
|
9
|
+
Importing it here would violate this package's own stdlib-only contract before a single request
|
|
10
|
+
is ever sent, so this module re-implements the same five steps rather than importing that one —
|
|
11
|
+
including the `flock`: two racing callers can each observe the socket absent at the same instant,
|
|
12
|
+
and without a lock serializing the vet-and-clear-stale-socket-and-spawn sequence, one caller can
|
|
13
|
+
unlink a socket the *other* has just bound and is already live on, forcing a second, redundant
|
|
14
|
+
service to start against a store the first is already correctly serving.
|
|
15
|
+
|
|
16
|
+
**This reimplementation is narrower than the one it mirrors in exactly one direction, and
|
|
17
|
+
nowhere else**: no *second outer attempt* after this one's own five-step sequence fails.
|
|
18
|
+
`lifecycle.py`'s own sequence is that whole five-step race-safe procedure and runs to
|
|
19
|
+
completion or raises — there is no looping inside it to narrow — but
|
|
20
|
+
`zikaron.mcp.connection.ServiceConnection` wraps a call to it in an outer retry (`_MAX_ATTEMPTS =
|
|
21
|
+
2`) for a long-running process that can afford to try the sequence a second time when an
|
|
22
|
+
already-established connection turns out to be dead. The hook has no equivalent connection to
|
|
23
|
+
have gone stale — every invocation is a fresh, single-shot process — and a user's message is
|
|
24
|
+
waiting on it, so this module runs the five-step sequence exactly once: one connect, one lock, one
|
|
25
|
+
recheck, one possible spawn, one poll to one deadline, then whatever the outcome is. A failure
|
|
26
|
+
here is handed straight to the caller's degraded-mode path rather than retried, which is what
|
|
27
|
+
keeps a slow or wedged service from costing the user two deadlines instead of one.
|
|
28
|
+
|
|
29
|
+
Reuses `zikaron.service.paths` and `zikaron.service.security` directly: both were verified to
|
|
30
|
+
import cleanly with no non-stdlib package added to `sys.modules`, so borrowing their path
|
|
31
|
+
derivation and filesystem-vetting logic does not compromise this package's own contract, and it
|
|
32
|
+
keeps the *safety* rules — never unlink an unvetted socket, never trust a hostile runtime
|
|
33
|
+
directory — identical to the ones `zikaron-service` and `zikaron-mcp` already enforce, rather than
|
|
34
|
+
a third, independently-written copy of the same two checks that could drift from the other two.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import errno
|
|
38
|
+
import fcntl
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import socket
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
from collections.abc import Sequence
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
from zikaron.service import paths, security
|
|
50
|
+
|
|
51
|
+
#: How long this single attempt waits, in total, for a freshly spawned server to answer a genuine
|
|
52
|
+
#: `health()` — not merely accept a connection. `push.py`'s own internal deadline
|
|
53
|
+
#: (`architecture.md`: "enforce an internal deadline of ~2 s") is the budget for the *whole*
|
|
54
|
+
#: `userPromptSubmit` hook, connect-through-print included, so this value must leave room for the
|
|
55
|
+
#: request and response after it. Set well under that: a cold spawn racing this deadline and
|
|
56
|
+
#: losing is exactly the case that must degrade (log to `hook.log`, relay on stdout) rather than
|
|
57
|
+
#: make the user wait for the `timeout_ms` kiro enforces on the whole hook command — 10 s on this
|
|
58
|
+
#: harness, and stated explicitly in the shipped entry (`architecture.md` §"The install contract").
|
|
59
|
+
HEALTH_POLL_DEADLINE_SECONDS = 1.2
|
|
60
|
+
_HEALTH_POLL_INTERVAL_SECONDS = 0.05
|
|
61
|
+
_CONNECT_TIMEOUT_SECONDS = 0.3
|
|
62
|
+
|
|
63
|
+
#: `architecture.md`: "`ECONNREFUSED` on an existing socket file is the signature of a service
|
|
64
|
+
#: that died without cleaning up — unlink and respawn rather than reporting an error," and
|
|
65
|
+
#: `ENOENT` is the plainer case of a socket that was never created. Mirrors
|
|
66
|
+
#: `zikaron.service.lifecycle._ABSENT_SERVER_ERRNOS` exactly, restated here rather than imported
|
|
67
|
+
#: since importing that module at all is what this file exists to avoid.
|
|
68
|
+
_ABSENT_SERVER_ERRNOS = frozenset({errno.ENOENT, errno.ECONNREFUSED})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HookTransportError(Exception):
|
|
72
|
+
"""No server became reachable within the deadline.
|
|
73
|
+
|
|
74
|
+
`push.py` catches this alongside every other failure kind and logs it to `hook.log` plus a
|
|
75
|
+
stdout relay, per `architecture.md`'s "Degraded modes". `StoreIdentityError` is the one
|
|
76
|
+
subclass of this that names a more specific reason — a reachable server that answered, but
|
|
77
|
+
disagreed about which store it was serving — kept as a subclass rather than a sibling
|
|
78
|
+
exception so a caller that only wants "did this single attempt succeed" can still catch this
|
|
79
|
+
one base type, while a caller that wants to log the design's own `-32030 store_identity`
|
|
80
|
+
code specifically can check for the subclass first.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StoreIdentityError(HookTransportError):
|
|
85
|
+
"""A reachable server's `health()` disagreed with this client's own resolved
|
|
86
|
+
`store_db_path`/`store_id` — `architecture.md`'s `-32030 store_identity`, §"Store identity is
|
|
87
|
+
verified, not assumed". Carries the two values the design's own error payload names
|
|
88
|
+
(`{expected, actual}`) so a caller can log them rather than only a formatted message string.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, *, expected: str, actual: str) -> None:
|
|
92
|
+
self.expected = expected
|
|
93
|
+
self.actual = actual
|
|
94
|
+
super().__init__(f"store_identity: expected={expected} actual={actual}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Health:
|
|
99
|
+
"""`health()`'s result, exactly as `push.py`'s identity check needs it."""
|
|
100
|
+
|
|
101
|
+
store_path: str
|
|
102
|
+
store_id: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def connect_once(
|
|
106
|
+
sock_path: Path, *, store_db_path: Path, store_id: str | None, server_command: Sequence[str]
|
|
107
|
+
) -> socket.socket:
|
|
108
|
+
"""One pass through start-if-absent, implementing `architecture.md`'s full five-step
|
|
109
|
+
sequence exactly: connect; on absence, take the `flock`, connect again (another client may
|
|
110
|
+
have won the race while this one reached the lock), and only then vet-and-spawn-and-poll;
|
|
111
|
+
verify identity; return a connected socket.
|
|
112
|
+
|
|
113
|
+
**This is one outer attempt with an internal lock, not two outer attempts.** The design's own
|
|
114
|
+
lock exists to prevent exactly this race: two hooks (or a hook racing `zikaron-mcp`'s own
|
|
115
|
+
start-if-absent) can both observe the socket absent, and without serialization the second one
|
|
116
|
+
can vet-and-unlink the *first* one's now-live socket — the first server's own file, not a
|
|
117
|
+
stale leftover — before spawning a second server
|
|
118
|
+
at the identical path. `architecture.md`'s own five steps are one race-safe *unit*: connect,
|
|
119
|
+
lock, recheck, spawn-if-still-absent, release. Nothing here retries that whole unit a second
|
|
120
|
+
time if it still fails after the lock is released; the "single attempt" property is about not
|
|
121
|
+
looping the five-step unit, not about omitting a step inside it.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A connected, unverified-timeout socket — `push.py` sets its own request timeout before
|
|
125
|
+
using it, since this socket's timeout is still whatever `_CONNECT_TIMEOUT_SECONDS` left it
|
|
126
|
+
at.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
HookTransportError: no server became reachable before the deadline, or a reachable one's
|
|
130
|
+
`health()` disagreed with `store_db_path`/`store_id`.
|
|
131
|
+
OSError: a filesystem or vetting failure distinct from "the server is absent" —
|
|
132
|
+
`zikaron.service.security.ensure_runtime_dir`'s own `ZikaronError` on a hostile runtime
|
|
133
|
+
directory propagates as itself, since this single attempt has no fallback path to
|
|
134
|
+
degrade *through* a runtime directory it does not trust.
|
|
135
|
+
"""
|
|
136
|
+
sock, health = _connect_or_start_under_lock(sock_path, server_command=server_command)
|
|
137
|
+
_verify_identity(health, store_db_path=store_db_path, store_id=store_id, sock=sock)
|
|
138
|
+
return sock
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _connect_or_start_under_lock(
|
|
142
|
+
sock_path: Path, *, server_command: Sequence[str]
|
|
143
|
+
) -> tuple[socket.socket, Health]:
|
|
144
|
+
"""Steps 1 through 5 of `architecture.md`'s start-if-absent sequence, exactly: an unlocked
|
|
145
|
+
connect first (the ordinary warm case never needs the lock at all), then — only on absence —
|
|
146
|
+
the lock, a second connect under it, and the spawn-if-still-absent-and-poll branch, releasing
|
|
147
|
+
the lock on every exit path.
|
|
148
|
+
"""
|
|
149
|
+
security.ensure_runtime_dir(sock_path.parent, uid=security.current_uid())
|
|
150
|
+
sock = _try_connect(sock_path)
|
|
151
|
+
if sock is not None:
|
|
152
|
+
# Already listening (the ordinary warm case): no lock needed at all, since nothing is
|
|
153
|
+
# about to be spawned or unlinked. This connection has never been asked `health()` yet,
|
|
154
|
+
# so one call is genuinely needed here.
|
|
155
|
+
try:
|
|
156
|
+
health = _health(sock)
|
|
157
|
+
except BaseException:
|
|
158
|
+
sock.close()
|
|
159
|
+
raise
|
|
160
|
+
return sock, health
|
|
161
|
+
|
|
162
|
+
lock_path = paths.lock_path(sock_path)
|
|
163
|
+
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o600)
|
|
164
|
+
try:
|
|
165
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
166
|
+
# Step 3: "another client may have won and already started it" — recheck under the lock
|
|
167
|
+
# before touching the socket file at all, so a live socket the other client just bound is
|
|
168
|
+
# never mistaken for a stale one.
|
|
169
|
+
sock = _try_connect(sock_path)
|
|
170
|
+
if sock is not None:
|
|
171
|
+
try:
|
|
172
|
+
health = _health(sock)
|
|
173
|
+
except BaseException:
|
|
174
|
+
sock.close()
|
|
175
|
+
raise
|
|
176
|
+
return sock, health
|
|
177
|
+
_vet_and_clear_stale_socket(sock_path)
|
|
178
|
+
_spawn_detached(server_command)
|
|
179
|
+
# `_poll_until_reachable` already confirmed readiness with its own `health()` call before
|
|
180
|
+
# returning — a **second** call over the identical connection would ask a server that
|
|
181
|
+
# answers one request per connection (the real `zikaron-service`'s own JSON-RPC loop
|
|
182
|
+
# answers many, but nothing in this function's own contract may assume a peer's request
|
|
183
|
+
# count) for a response it has no obligation to send twice. `_poll_until_reachable`
|
|
184
|
+
# therefore hands back the `Health` it already has, not merely the socket.
|
|
185
|
+
return _poll_until_reachable(sock_path)
|
|
186
|
+
finally:
|
|
187
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
188
|
+
os.close(lock_fd)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _verify_identity(
|
|
192
|
+
health: Health, *, store_db_path: Path, store_id: str | None, sock: socket.socket
|
|
193
|
+
) -> None:
|
|
194
|
+
"""`architecture.md`'s handshake: `store_path` always, `store_id` whenever this client has an
|
|
195
|
+
independent one to compare against. `None` covers this client's own permanent case — every
|
|
196
|
+
invocation is a fresh, single-shot process with no independent id to have read, never merely
|
|
197
|
+
"this store does not exist on disk yet" — per §"Store identity is verified, not assumed"'s
|
|
198
|
+
own hook exception.
|
|
199
|
+
"""
|
|
200
|
+
if store_db_path != Path(health.store_path):
|
|
201
|
+
sock.close()
|
|
202
|
+
raise StoreIdentityError(expected=str(store_db_path), actual=health.store_path)
|
|
203
|
+
if store_id is not None and store_id != health.store_id:
|
|
204
|
+
sock.close()
|
|
205
|
+
raise StoreIdentityError(expected=store_id, actual=health.store_id)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _try_connect(sock_path: Path) -> socket.socket | None:
|
|
209
|
+
"""A bare connection attempt: the socket on success, `None` if nobody is listening yet.
|
|
210
|
+
|
|
211
|
+
Any `OSError` *other* than the absent-server signature propagates rather than being folded
|
|
212
|
+
into `None` — an `EACCES` or a permissions problem on the socket file itself is not "nobody is
|
|
213
|
+
listening," and treating it as a reason to spawn a second server here would paper over a real
|
|
214
|
+
failure this single attempt has no business recovering from silently.
|
|
215
|
+
"""
|
|
216
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
217
|
+
sock.settimeout(_CONNECT_TIMEOUT_SECONDS)
|
|
218
|
+
try:
|
|
219
|
+
sock.connect(str(sock_path))
|
|
220
|
+
except OSError as error:
|
|
221
|
+
sock.close()
|
|
222
|
+
if error.errno in _ABSENT_SERVER_ERRNOS:
|
|
223
|
+
return None
|
|
224
|
+
raise
|
|
225
|
+
return sock
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _vet_and_clear_stale_socket(sock_path: Path) -> None:
|
|
229
|
+
"""Unlink `sock_path` if, and only if, it is a real socket this uid owns.
|
|
230
|
+
|
|
231
|
+
`architecture.md`: "never unlink a socket you have not vetted" — identical rule and identical
|
|
232
|
+
helper (`zikaron.service.security.vet_socket_for_unlink`) to the one `zikaron.service.lifecycle`
|
|
233
|
+
already enforces, so a stale-socket decision made here agrees with the one the service and the
|
|
234
|
+
MCP client would have made in this function's place.
|
|
235
|
+
"""
|
|
236
|
+
if security.vet_socket_for_unlink(sock_path, uid=security.current_uid()):
|
|
237
|
+
sock_path.unlink()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _spawn_detached(server_command: Sequence[str]) -> None:
|
|
241
|
+
"""Start the service, detached, and forget it — this single attempt does not wait on the
|
|
242
|
+
child beyond polling its socket, and does not reap it: a short-lived hook process exits long
|
|
243
|
+
before the spawned service's own eventual idle self-stop, so there is no zombie-avoidance
|
|
244
|
+
reason for this process to hold a `Popen` handle the way `zikaron.service.lifecycle`'s own
|
|
245
|
+
reaping thread exists for a *long-running* spawning client. Whatever process this hook's own
|
|
246
|
+
parent (kiro) reaps this child under is outside this function's concern once the fork
|
|
247
|
+
succeeds.
|
|
248
|
+
"""
|
|
249
|
+
subprocess.Popen( # noqa: S603 — `server_command` is caller-supplied, the fixed argv this
|
|
250
|
+
# process's own caller decided to spawn, never built from request data.
|
|
251
|
+
list(server_command),
|
|
252
|
+
start_new_session=True,
|
|
253
|
+
stdin=subprocess.DEVNULL,
|
|
254
|
+
stdout=subprocess.DEVNULL,
|
|
255
|
+
stderr=subprocess.DEVNULL,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _poll_until_reachable(sock_path: Path) -> tuple[socket.socket, Health]:
|
|
260
|
+
"""Poll for a genuine `health()` response, not merely a successful connect, until
|
|
261
|
+
`HEALTH_POLL_DEADLINE_SECONDS` elapses.
|
|
262
|
+
|
|
263
|
+
Mirrors `zikaron.service.lifecycle._poll_until_reachable`'s own reasoning exactly: a freshly
|
|
264
|
+
spawned process can accept a connection before it is able to answer anything, so a poll that
|
|
265
|
+
stopped at `connect()` would hand the caller a socket the service is not yet serving on.
|
|
266
|
+
Answering `health()` is the readiness signal because it is the first thing that requires the
|
|
267
|
+
store to be open.
|
|
268
|
+
|
|
269
|
+
It does **not** mean the model is loaded. The service binds while a deferred load is still
|
|
270
|
+
running, deliberately, so a `health()` answer bounds the wait for the *store* and not for the
|
|
271
|
+
first embedding — the request after it may still block on the model.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The connected socket and the `Health` this function's own poll attempt already read from
|
|
275
|
+
it — the caller must not call `_health` a second time over this identical connection, since
|
|
276
|
+
a peer that answers one request per accepted connection would have nothing left to answer
|
|
277
|
+
with.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
HookTransportError: the deadline elapsed with no genuine `health()` response.
|
|
281
|
+
"""
|
|
282
|
+
deadline = time.monotonic() + HEALTH_POLL_DEADLINE_SECONDS
|
|
283
|
+
while time.monotonic() < deadline:
|
|
284
|
+
sock = _try_connect(sock_path)
|
|
285
|
+
if sock is None:
|
|
286
|
+
time.sleep(_HEALTH_POLL_INTERVAL_SECONDS)
|
|
287
|
+
continue
|
|
288
|
+
try:
|
|
289
|
+
health = _health(sock)
|
|
290
|
+
except (OSError, ConnectionError, KeyError, ValueError, TypeError):
|
|
291
|
+
sock.close()
|
|
292
|
+
time.sleep(_HEALTH_POLL_INTERVAL_SECONDS)
|
|
293
|
+
continue
|
|
294
|
+
else:
|
|
295
|
+
return sock, health
|
|
296
|
+
raise HookTransportError(f"no server became reachable at {sock_path} before the deadline")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _health(sock: socket.socket) -> Health:
|
|
300
|
+
"""Send `health()` and parse its result, requiring a genuine JSON boolean `true` for `ready`
|
|
301
|
+
— mirrors `zikaron.service.lifecycle._health`'s own reasoning for rejecting a merely-truthy
|
|
302
|
+
value rather than coercing one.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
OSError: the socket failed during send or receive.
|
|
306
|
+
ConnectionError: the connection closed before a full line arrived, or the response was
|
|
307
|
+
not the expected shape.
|
|
308
|
+
KeyError: a required field (`store_path`, `store_id`, `ready`) was absent from the result.
|
|
309
|
+
ValueError, TypeError: the response was not valid JSON, or `ready` was present but not the
|
|
310
|
+
JSON boolean `true`.
|
|
311
|
+
"""
|
|
312
|
+
request = {"jsonrpc": "2.0", "id": 1, "method": "health", "params": {}}
|
|
313
|
+
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
|
314
|
+
buffer = b""
|
|
315
|
+
while not buffer.endswith(b"\n"):
|
|
316
|
+
chunk = sock.recv(4096)
|
|
317
|
+
if not chunk:
|
|
318
|
+
raise ConnectionError("connection closed before a full response was read")
|
|
319
|
+
buffer += chunk
|
|
320
|
+
parsed = json.loads(buffer.decode("utf-8"))
|
|
321
|
+
if not isinstance(parsed, dict):
|
|
322
|
+
raise ConnectionError(f"response was not a JSON object: {parsed!r}")
|
|
323
|
+
result = parsed.get("result")
|
|
324
|
+
if not isinstance(result, dict):
|
|
325
|
+
raise ConnectionError(f"health() did not return a result: {parsed!r}")
|
|
326
|
+
ready = result["ready"]
|
|
327
|
+
if ready is not True:
|
|
328
|
+
raise ConnectionError(f"health() reported ready={ready!r}, not the JSON boolean true")
|
|
329
|
+
return Health(store_path=str(result["store_path"]), store_id=str(result["store_id"]))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def default_server_command(sock_path: Path, store_dir: Path) -> list[str]:
|
|
333
|
+
"""The argv `zikaron.service.main` expects — identical shape to
|
|
334
|
+
`zikaron.service.lifecycle.default_server_command`, restated rather than imported for the same
|
|
335
|
+
reason every other reuse in this module is direct rather than through `lifecycle.py`.
|
|
336
|
+
"""
|
|
337
|
+
return [sys.executable, "-m", "zikaron.service.main", str(sock_path), str(store_dir)]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def resolve_sock_path(store_dir: Path) -> Path:
|
|
341
|
+
"""Where this store's socket lives — `zikaron.service.paths`' own derivation, reused
|
|
342
|
+
directly since that module was verified to import with no non-stdlib dependency.
|
|
343
|
+
|
|
344
|
+
**This module deliberately has no function reading `meta.store_id` from an existing store,
|
|
345
|
+
and `connect_once`'s own identity check stays path-only, on every connection this client ever
|
|
346
|
+
makes.** `architecture.md` §"Store identity is verified, not assumed" states this as the
|
|
347
|
+
hook's own permanent, documented exception to the ordinary rule that a later connection has a
|
|
348
|
+
real `store_id` to compare against: every `userPromptSubmit` is a fresh, single-shot process
|
|
349
|
+
with no connection of its own from a previous invocation to have learned anything from, and
|
|
350
|
+
the only way to get a `store_id` to compare against would be to read it directly from
|
|
351
|
+
`memory.db` — precisely the store access §"Degraded modes" forbids unconditionally ("the hook
|
|
352
|
+
is never a reader of the store, under any failure"). Two alternatives were considered and
|
|
353
|
+
rejected on that same rule: reading `meta.store_id` directly with bare `sqlite3` (measured to
|
|
354
|
+
violate the no-store-access invariant this whole package exists to hold), and a small sidecar
|
|
355
|
+
file this client would write and read itself to remember an id across invocations (found to be
|
|
356
|
+
circular — whatever a later call would compare against was itself written by an earlier call
|
|
357
|
+
to the same, possibly-now-stale service, so a service that has gone stale without restarting
|
|
358
|
+
reports the identical value both times and the sidecar never disagrees with itself).
|
|
359
|
+
|
|
360
|
+
**Path-only comparison is not, on its own, a complete identity check** — it cannot by
|
|
361
|
+
construction distinguish a correctly-running service from one still serving a store that was
|
|
362
|
+
deleted and recreated at the identical path, since the path being compared never changed
|
|
363
|
+
either way. That gap is closed on the *service* side instead: `architecture.md` §"Idle
|
|
364
|
+
self-stop" has the service's own 30 s poll also detect the store it has open no longer being
|
|
365
|
+
the one currently on disk at its own path, and stop rather than keep serving it — the only
|
|
366
|
+
side with an independent way to notice, since it can `stat` the path without opening the file
|
|
367
|
+
it already holds. Given that, path-only is the correct, sufficient check for this client:
|
|
368
|
+
the one case it cannot resolve on its own resolves itself within one more poll cycle either
|
|
369
|
+
way, with no fallback read attempted here in the meantime.
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
ZikaronError: `paths.socket_path`'s own `BAD_CONFIG` when the derived path will not fit
|
|
373
|
+
`sun_path`.
|
|
374
|
+
"""
|
|
375
|
+
resolved_store_dir = store_dir.resolve()
|
|
376
|
+
runtime_dir = paths.runtime_dir(
|
|
377
|
+
xdg_runtime_dir=os.environ.get("XDG_RUNTIME_DIR"), uid=os.getuid()
|
|
378
|
+
)
|
|
379
|
+
return paths.socket_path(runtime_dir, resolved_store_dir, platform=sys.platform)
|
zikaron/hook/envelope.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The subagent-suppression check, and this client's `client` envelope.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Subagent sessions — the push hook suppresses itself" is normative for
|
|
4
|
+
`is_subagent_session`; §"The request envelope" for `build_envelope`.
|
|
5
|
+
|
|
6
|
+
**States the envelope's four fields locally rather than importing them, and the reason is
|
|
7
|
+
measured.** This module originally reused `zikaron.service.envelope.ClientEnvelope` and
|
|
8
|
+
`zikaron.core.events.ClientKind` on the argument that both are stdlib-only, so reuse cost nothing —
|
|
9
|
+
verified at the time, and wrong about the cost that matters. Stdlib-only is not the same as cheap:
|
|
10
|
+
measured on this machine, importing `zikaron.core.events` alone costs **28 ms** over a
|
|
11
|
+
socket/json/os/pathlib/subprocess floor and `zikaron.service.envelope` **34 ms** (it pulls in
|
|
12
|
+
`core.events`, `core.errors` and `dataclasses`, whose own `inspect` import is most of it), against
|
|
13
|
+
a whole-process hook budget of ~48 ms without them and ~68 ms with. This client runs **once per
|
|
14
|
+
user message**, so that is a per-turn tax on the exact critical path the design's argument for
|
|
15
|
+
keeping the hook thin is about — and `architecture.md` §Components quotes ~20 ms for this client, a
|
|
16
|
+
figure the reuse quietly tripled. `dataclasses` is avoided for the same reason `logging` is: a
|
|
17
|
+
`NamedTuple` gives the same frozen, typed, four-field value for no measurable import.
|
|
18
|
+
|
|
19
|
+
Two copies of a wire contract is exactly the drift this corpus keeps finding, so the copy is
|
|
20
|
+
**guarded rather than trusted**: `tests/test_hook_envelope.py` asserts this module's field names and
|
|
21
|
+
its `kind` literal against `ClientEnvelope`'s own fields and `ClientKind.HOOK.value`, which is the
|
|
22
|
+
same drift-guard shape `tests/design_tables.py` uses for the design's own tables. The test pays the
|
|
23
|
+
import
|
|
24
|
+
cost; the hook does not.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from typing import NamedTuple
|
|
28
|
+
|
|
29
|
+
#: The wire value for this client's `client.kind`, i.e. `ClientKind.HOOK.value`. A literal here, an
|
|
30
|
+
#: enum there, and a test that they agree — see this module's docstring.
|
|
31
|
+
CLIENT_KIND = "hook"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HookEnvelope(NamedTuple):
|
|
35
|
+
"""This client's `client` envelope: the four fields `architecture.md` §"The request envelope"
|
|
36
|
+
states, in the shape `zikaron.service.envelope.ClientEnvelope` declares them.
|
|
37
|
+
|
|
38
|
+
A `NamedTuple` rather than a frozen dataclass purely for import cost (module docstring); it is
|
|
39
|
+
immutable and typed either way. `as_client_object()` is the one place the wire spelling of these
|
|
40
|
+
fields is written, so `rpc.py` composes a request without restating them.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
session_id: str | None
|
|
44
|
+
kind: str
|
|
45
|
+
pid: int
|
|
46
|
+
op_id: str | None
|
|
47
|
+
|
|
48
|
+
def as_client_object(self) -> dict[str, object]:
|
|
49
|
+
"""The `client` member of an outgoing request's `params`."""
|
|
50
|
+
return {
|
|
51
|
+
"session_id": self.session_id,
|
|
52
|
+
"kind": self.kind,
|
|
53
|
+
"pid": self.pid,
|
|
54
|
+
"op_id": self.op_id,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_subagent_session(*, env_session_id: str | None, payload_session_id: object) -> bool:
|
|
59
|
+
"""Whether this invocation is running inside a subagent session, per
|
|
60
|
+
`architecture.md`'s exact rule: the hook reads the harness's session variable from its
|
|
61
|
+
environment and compares it to its payload's `session_id`. When they **differ**, this is a
|
|
62
|
+
subagent session.
|
|
63
|
+
|
|
64
|
+
**Under Claude Code this comparison is never reached for a genuine subagent**, because
|
|
65
|
+
`UserPromptSubmit` does not fire for subagents at all — the harness closes the door this rule
|
|
66
|
+
was built to guard. The rule stays because kiro still needs it, and because under Claude Code
|
|
67
|
+
a divergence means something else entirely: payload and environment are invariantly equal
|
|
68
|
+
there, so a difference is a misdetected harness rather than a subagent. `tripwire.py` owns
|
|
69
|
+
that reading; this function only states the comparison.
|
|
70
|
+
|
|
71
|
+
Resolving the environment side is `zikaron.harness.detect.session_label`'s job, not this
|
|
72
|
+
module's — the variable's *name* is harness-varying data and belongs in the one table that
|
|
73
|
+
carries such data.
|
|
74
|
+
|
|
75
|
+
A pure comparison, deliberately taking both values as already-extracted arguments rather than
|
|
76
|
+
reading the environment or a payload dict itself — the caller (`push.py`, `spawn_warm.py`)
|
|
77
|
+
owns *when* those reads happen (before any RPC, before any other action, per the same
|
|
78
|
+
section's ordering), and this function only states the comparison, so it can be tested against
|
|
79
|
+
every combination of present/absent/matching/differing without touching `os.environ` or
|
|
80
|
+
constructing a payload.
|
|
81
|
+
|
|
82
|
+
`payload_session_id` is typed `object` rather than `str | None`: the payload is untrusted JSON
|
|
83
|
+
from kiro's own stdin delivery, and a value that is present but not a string (or not present at
|
|
84
|
+
all) is treated as "no payload session id to differ from" — i.e. not a subagent session by this
|
|
85
|
+
check alone — exactly as `architecture.md` states for the *other* absence case: "when
|
|
86
|
+
`KIRO_SESSION_ID` is absent entirely, it proceeds normally." A malformed payload is a separate
|
|
87
|
+
concern the caller's own JSON handling surfaces as an ordinary failure later, not a reason for
|
|
88
|
+
this pure comparison to raise.
|
|
89
|
+
|
|
90
|
+
**When `env_session_id` is `None`, this always returns `False`** — "when... `KIRO_SESSION_ID`
|
|
91
|
+
is absent entirely, it proceeds normally" is unconditional on the payload's own value, so a
|
|
92
|
+
missing environment variable never suppresses a top-level session merely because the payload
|
|
93
|
+
happens to carry some session id.
|
|
94
|
+
"""
|
|
95
|
+
if env_session_id is None:
|
|
96
|
+
return False
|
|
97
|
+
return payload_session_id != env_session_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_envelope(*, session_id: str | None, pid: int) -> HookEnvelope:
|
|
101
|
+
"""This request's `client` envelope: the resolved session label (or `None` for the bootstrap
|
|
102
|
+
form), `kind="hook"`, this process's own pid, and no `op_id` — the service mints one per call
|
|
103
|
+
when a client sends none (`zikaron.service.envelope.resolve`), and a single-request process has
|
|
104
|
+
nothing of its own to correlate across calls the way a long-running MCP connection does.
|
|
105
|
+
"""
|
|
106
|
+
return HookEnvelope(session_id=session_id, kind=CLIENT_KIND, pid=pid, op_id=None)
|
zikaron/hook/failure.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""`hook.log`: exactly one line per failed `userPromptSubmit`, written without `logging`.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Degraded modes": on any failure, the hook must log to `hook.log` — "a **direct
|
|
4
|
+
file write, not `logging`**: `open(path, "a")` and one `.write()` call, then close."
|
|
5
|
+
`coding-standards.md` §6 gives the number the rule rests on: `import logging` alone costs ~15 ms
|
|
6
|
+
on this machine, in the same range as the whole stdlib-thinness argument the hook exists to
|
|
7
|
+
satisfy, and a process that writes one line and exits has no log lifecycle for `logging`'s
|
|
8
|
+
formatters and handlers to manage.
|
|
9
|
+
|
|
10
|
+
`hook.log` is `0600` and this process's alone (`architecture.md` §"Paths"): never `service.log` or
|
|
11
|
+
`warmup.log`, and no other process shares this path, so the append below needs no cross-process
|
|
12
|
+
locking — the one property that would have made `logging.FileHandler` safe to use here in the
|
|
13
|
+
first place, and does not hold for `service.log`/`warmup.log`'s multi-process cases either.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from zikaron.core.store.permissions import ensure_store_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def record_failure(hook_log_path: Path, kind: str, *, code: int | None = None) -> None:
|
|
25
|
+
"""Append one line naming `kind` (and `code`, if this failure carried a wire error code) to
|
|
26
|
+
`hook_log_path`, creating both the file (at mode `0600`) and, if absent, its parent
|
|
27
|
+
directory (at `0700`, symlink-refusing) as needed.
|
|
28
|
+
|
|
29
|
+
**Ensuring the parent directory exists is required, not optional**, because this function is
|
|
30
|
+
the hook's failure path and the most common way to reach it is a service that has never run
|
|
31
|
+
for this project at all — the ordinary first-ever session, where `.zikaron/` does not exist
|
|
32
|
+
yet either. Skipping that step would let `os.open`'s own `ENOENT` on a missing parent be
|
|
33
|
+
swallowed by the same `except OSError` this docstring's next paragraph describes, making the
|
|
34
|
+
very failure this function exists to make durable the one case guaranteed to be lost.
|
|
35
|
+
`ensure_store_dir` is the identical helper `zikaron.core.store.store.Store.create` calls for
|
|
36
|
+
the same directory — reused rather than reimplemented, so the hook's own directory creation
|
|
37
|
+
cannot silently disagree with the service's about what "exists correctly" means (real
|
|
38
|
+
directory, not a symlink, exactly `0700`).
|
|
39
|
+
|
|
40
|
+
Never raises past its own attempt: this is the *last* thing a failure path does, called after
|
|
41
|
+
every other action (the RPC attempt, any socket cleanup) has already failed or been abandoned,
|
|
42
|
+
so a second failure here — the disk full, a permissions problem this process cannot fix — must
|
|
43
|
+
not propagate and turn a silent degraded push into a crashing one. `architecture.md`'s own
|
|
44
|
+
error table has no code for "the hook could not even log its own failure"; there is nothing to
|
|
45
|
+
report it as, and nothing downstream is waiting for this call to succeed. Swallowing the
|
|
46
|
+
failure here is therefore the one deliberate exception to `coding-standards.md` §7's "never
|
|
47
|
+
swallow an exception silently" — the section's own text names it: "except in the hook, where
|
|
48
|
+
the design requires exit 0... even there the failure goes to `hook.log`", and this is the
|
|
49
|
+
function that tries to make that true, on a best-effort basis, when even that attempt does not
|
|
50
|
+
land.
|
|
51
|
+
|
|
52
|
+
The file is opened with `O_CREAT | O_APPEND`, not appended through `Path.open("a")`'s own
|
|
53
|
+
higher-level call, and a **freshly created** file's mode is forced to exactly `0600` with
|
|
54
|
+
`os.fchmod` after opening, rather than trusted to the `mode` argument `os.open` itself
|
|
55
|
+
accepts. `os.open`'s `mode` parameter is **not** umask-independent — POSIX `open(2)` applies
|
|
56
|
+
`mode & ~umask`, so a restrictive process umask (an unusual but legal `0600`, say) can leave a
|
|
57
|
+
newly created file at `0000` even though `0o600` was passed — confirmed directly on this
|
|
58
|
+
machine rather than assumed.
|
|
59
|
+
`os.fchmod` on the already-open descriptor sets the mode outright, independent of umask,
|
|
60
|
+
closing that gap. **Creation is detected with `O_EXCL` first** — attempted before the
|
|
61
|
+
ordinary `O_CREAT | O_APPEND` open — specifically so the `fchmod` step only ever runs on a
|
|
62
|
+
file this exact call created: an *existing* `hook.log` must keep whatever mode it already
|
|
63
|
+
has, per this function's own stated contract below, and `fchmod`-ing it back to `0600` on
|
|
64
|
+
every append would silently re-tighten a mode an operator had deliberately widened.
|
|
65
|
+
"""
|
|
66
|
+
line = _format_line(kind, code=code)
|
|
67
|
+
with contextlib.suppress(Exception):
|
|
68
|
+
# Never narrowed to `OSError` alone: `ensure_store_dir` raises `ZikaronError` — a plain
|
|
69
|
+
# `Exception`, not an `OSError` subclass — for a hostile or symlinked `.zikaron`, and that
|
|
70
|
+
# failure must be swallowed here exactly like any other, per this function's own contract
|
|
71
|
+
# that it is the *last* thing a failure path does and must never itself become the reason
|
|
72
|
+
# a degraded push crashes instead of relaying.
|
|
73
|
+
ensure_store_dir(hook_log_path.parent)
|
|
74
|
+
try:
|
|
75
|
+
fd = os.open(str(hook_log_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
76
|
+
except FileExistsError:
|
|
77
|
+
# The ordinary case after the first failure of a session: append without touching
|
|
78
|
+
# the mode, per this function's own contract — an existing file keeps whatever mode
|
|
79
|
+
# it already has.
|
|
80
|
+
fd = os.open(str(hook_log_path), os.O_APPEND | os.O_WRONLY)
|
|
81
|
+
else:
|
|
82
|
+
# This call won the creation race: force the exact mode now, on the descriptor this
|
|
83
|
+
# process itself just opened, before any content is written to it.
|
|
84
|
+
os.fchmod(fd, 0o600)
|
|
85
|
+
try:
|
|
86
|
+
os.write(fd, line.encode("utf-8"))
|
|
87
|
+
finally:
|
|
88
|
+
os.close(fd)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _format_line(kind: str, *, code: int | None) -> str:
|
|
92
|
+
"""One newline-terminated line: an ISO-8601 UTC timestamp, the failure kind, and the wire
|
|
93
|
+
error code when this failure carried one.
|
|
94
|
+
|
|
95
|
+
Deliberately excludes anything from the prompt or a memory record: `architecture.md`
|
|
96
|
+
§"Filesystem security" states plainly that `hook.log` "log[s] only a fixed failure-kind label
|
|
97
|
+
and an error code, never prompt or memory content, so neither can hold a leaked secret" — a
|
|
98
|
+
guarantee this function keeps by construction, since its only inputs are the failure
|
|
99
|
+
classification and an optional integer, never the prompt text or any RPC response body.
|
|
100
|
+
"""
|
|
101
|
+
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
102
|
+
if code is None:
|
|
103
|
+
return f"{timestamp} {kind}\n"
|
|
104
|
+
return f"{timestamp} {kind} {code}\n"
|
zikaron/hook/limits.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The numbers a shipped hook entry states.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"The install contract" is normative. All are written into every installed hook
|
|
4
|
+
entry rather than inherited from the harness's own defaults, and all live here rather than in
|
|
5
|
+
`zikaron/install/` so there is exactly one place the installer reads them from.
|
|
6
|
+
|
|
7
|
+
**The injection budget is kiro-only**: Claude Code offers no field to raise it. This module is
|
|
8
|
+
deliberately not the general home for "what a harness will accept" — `zikaron.harness.spec` is, and
|
|
9
|
+
the runtime budget check reads it there.
|
|
10
|
+
|
|
11
|
+
**The timeout is one number in seconds, deliberately, and the conversions live at the edges.** Kiro
|
|
12
|
+
states `timeout_ms` in *milliseconds*; kiro's own array format states `timeout` in *seconds*; Claude
|
|
13
|
+
Code states `timeout` in *seconds* (measured, `research/claude-code-installer-probe.md` §2). That is
|
|
14
|
+
three spellings of one budget, and the corpus has already recorded the seconds-versus-milliseconds
|
|
15
|
+
trap between the first two. Keeping the canonical value in the coarser unit means the mistake that
|
|
16
|
+
matters — writing kiro's `10000` into a seconds field, which installs a **10,000-second** budget
|
|
17
|
+
during which a wedged hook blocks every user message with nothing reporting a problem — cannot be
|
|
18
|
+
made by copying a constant, because no constant here carries that number.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Final
|
|
22
|
+
|
|
23
|
+
from zikaron.harness.spec import KIRO
|
|
24
|
+
|
|
25
|
+
#: The hook budget every shipped entry states, in **seconds**. Equal to kiro's own documented
|
|
26
|
+
#: default, and stated anyway: the corpus recorded that default as 30 s for two milestones on the
|
|
27
|
+
#: strength of the public docs, and a budget the hook's ~2 s internal deadline is sized against
|
|
28
|
+
#: should not be a number a harness release can move underneath it. Five times the internal
|
|
29
|
+
#: deadline, so our own failure path — which produces a `hook.log` line *and* a model-facing relay —
|
|
30
|
+
#: always fires before the harness's kill, which produces neither.
|
|
31
|
+
#:
|
|
32
|
+
#: It also has to clear the *tightest* default in the harness table, which is measured and is not
|
|
33
|
+
#: the one a casual reading finds: Claude Code defaults `UserPromptSubmit` to **30 s** against 600 s
|
|
34
|
+
#: for other events. 10 s sits inside that, so stating it never *raises* a budget we would not
|
|
35
|
+
#: otherwise have had.
|
|
36
|
+
HOOK_TIMEOUT_SECONDS: Final = 10
|
|
37
|
+
|
|
38
|
+
#: The same budget in kiro's object-format unit. Derived rather than declared, so the two can never
|
|
39
|
+
#: disagree about how long a hook may run.
|
|
40
|
+
TIMEOUT_MS: Final = HOOK_TIMEOUT_SECONDS * 1_000
|
|
41
|
+
|
|
42
|
+
#: `max_output_size` for a shipped kiro entry, in bytes. **Read from the harness table rather than
|
|
43
|
+
#: declared here**, because the same number is what the runtime oversize check measures an
|
|
44
|
+
#: operator's policy override against: two agreeing declarations of one harness's budget is the
|
|
45
|
+
#: drift shape this corpus has already paid for, and the table is the side that a design-document
|
|
46
|
+
#: test reads.
|
|
47
|
+
#:
|
|
48
|
+
#: Its margin is what makes overrun unreachable rather than merely unlikely, and both real worst
|
|
49
|
+
#: cases are asserted in the suite rather than estimated: the shipped policy text is 6599 bytes, and
|
|
50
|
+
#: a five-row push block is bounded by the `gist` character bound at 6,429 UTF-16 code units — at
|
|
51
|
+
#: most 19,287 bytes of UTF-8 at the 3-bytes-per-unit ceiling. Both figures are stated in the unit
|
|
52
|
+
#: they are measured in, which this module of all places has to get right: a units count wearing a
|
|
53
|
+
#: byte suffix is exactly the conflation the runtime budget check exists to prevent.
|
|
54
|
+
#:
|
|
55
|
+
#: **Exact counts rather than a rounded `kB`, and the policy figure is pinned.** It read `~6.1 kB`
|
|
56
|
+
#: across a change that moved the policy from 6098 to 6230 bytes, and was defensible either way —
|
|
57
|
+
#: 6098 decimal and 6230 binary both round to 6.1 — so no reader could tell a live figure from a
|
|
58
|
+
#: stale one. A prefix that means two things is the same conflation the paragraph above claims to
|
|
59
|
+
#: have avoided, one level down. `architecture.md` carried the fifth staleness of this one number
|
|
60
|
+
#: and is guarded; this was its unguarded second site.
|
|
61
|
+
MAX_OUTPUT_SIZE: Final = KIRO.injection_budget
|