@veda-hq/kavach 0.0.0-stage → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +675 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code PreToolUse adapter for the shared deterministic shell/path boundary.
|
|
3
|
+
|
|
4
|
+
The boundary module (``boundary.py``, beside this file) supplies parsing
|
|
5
|
+
only; this adapter owns Claude event semantics and requires this session's own
|
|
6
|
+
Claude-managed checkout. The consumer's settings (``config.py``, named by the
|
|
7
|
+
environment variable ``KAVACH_CONFIG``) are read once per event here and
|
|
8
|
+
handed down; a settings file that cannot be used refuses every event.
|
|
9
|
+
|
|
10
|
+
One thing a Claude session mutates that is not repository state: its own
|
|
11
|
+
agent-memory directory. Claude Code keeps that outside every checkout, so the
|
|
12
|
+
worktree boundary below has nothing to protect there and would otherwise make
|
|
13
|
+
the memory permanently unwritable — a session is told to record findings and
|
|
14
|
+
then blocked with a message about worktrees. ``claude_memory_root`` derives
|
|
15
|
+
that single directory from the checkout this session is already in and allows
|
|
16
|
+
writes confined to it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from types import ModuleType
|
|
29
|
+
from typing import Any, NoReturn
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
HERE = Path(__file__).resolve().parent
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Claude Code lets a hook's exit 1 through, so an error in this guard or in the
|
|
36
|
+
# boundary it loads would wave every mutation on. Any failure refuses instead,
|
|
37
|
+
# naming the error. Not caught here: an interpreter that cannot start or is
|
|
38
|
+
# killed, and a syntax error in this file, which the guard's behaviour suite
|
|
39
|
+
# (`tests/test_worktree_guard.py`) exists to catch before it ships.
|
|
40
|
+
def refuse(reason: str) -> NoReturn:
|
|
41
|
+
print(f"BLOCKED: {reason}", file=sys.stderr)
|
|
42
|
+
raise SystemExit(2)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_exit(error: SystemExit, *codes: int) -> bool:
|
|
46
|
+
"""Whether ``error`` exits with one of ``codes``, as an int: Python exits 1
|
|
47
|
+
for ``SystemExit(2.0)`` although ``2.0 == 2``."""
|
|
48
|
+
|
|
49
|
+
return type(error.code) is int and error.code in codes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def failure(error: BaseException) -> str:
|
|
53
|
+
if isinstance(error, SystemExit):
|
|
54
|
+
return f"exit {error.code!r} instead of a verdict"
|
|
55
|
+
return f"{type(error).__name__}: {error}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_module(name: str, path: Path, *, register: bool = False) -> ModuleType:
|
|
59
|
+
"""A module of this package, by file; a module that fails to load refuses.
|
|
60
|
+
|
|
61
|
+
Loaded by path rather than imported, so the guard runs as a plain script
|
|
62
|
+
from wherever the package is installed. With ``register`` the module is
|
|
63
|
+
entered in ``sys.modules`` under ``name`` while it runs, which a dataclass
|
|
64
|
+
module needs; a load that fails puts back whatever was there.
|
|
65
|
+
|
|
66
|
+
Loading has no verdict to give, so any exit but a refusal (2) refuses too:
|
|
67
|
+
an exit 0 here would skip the check, an exit 1 would wave the call on.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
previous = sys.modules.get(name)
|
|
71
|
+
|
|
72
|
+
def forget() -> None:
|
|
73
|
+
if not register:
|
|
74
|
+
return
|
|
75
|
+
if previous is None:
|
|
76
|
+
sys.modules.pop(name, None)
|
|
77
|
+
else:
|
|
78
|
+
sys.modules[name] = previous
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
82
|
+
if spec is None or spec.loader is None:
|
|
83
|
+
raise ImportError(f"no loader for {path}")
|
|
84
|
+
module = importlib.util.module_from_spec(spec)
|
|
85
|
+
if register:
|
|
86
|
+
sys.modules[name] = module
|
|
87
|
+
spec.loader.exec_module(module)
|
|
88
|
+
except SystemExit as error:
|
|
89
|
+
forget()
|
|
90
|
+
if is_exit(error, 2):
|
|
91
|
+
raise
|
|
92
|
+
refuse(
|
|
93
|
+
f"the ownership guard could not load {path.name} ({failure(error)}), "
|
|
94
|
+
"so it cannot judge this call"
|
|
95
|
+
)
|
|
96
|
+
except BaseException as error:
|
|
97
|
+
forget()
|
|
98
|
+
refuse(
|
|
99
|
+
f"the ownership guard could not load {path.name} ({failure(error)}), "
|
|
100
|
+
"so it cannot judge this call"
|
|
101
|
+
)
|
|
102
|
+
return module
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_boundary(path: Path) -> ModuleType:
|
|
106
|
+
"""The shared shell/path boundary; a module that fails to load refuses."""
|
|
107
|
+
|
|
108
|
+
return load_module("kavach_worktree_boundary", path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_config(path: Path) -> ModuleType:
|
|
112
|
+
"""The settings loader; a module that fails to load refuses."""
|
|
113
|
+
|
|
114
|
+
return load_module("kavach_worktree_config", path, register=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
boundary = load_boundary(HERE / "boundary.py")
|
|
118
|
+
config = load_config(HERE / "config.py")
|
|
119
|
+
refusal_log = load_module("kavach_worktree_refusal_log", HERE / "refusal_log.py")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def settings() -> Any:
|
|
123
|
+
"""The consumer's settings (``config.Config``); unusable settings refuse.
|
|
124
|
+
|
|
125
|
+
A guard that misread what it trusts must not allow, so an unreadable,
|
|
126
|
+
malformed or wrongly shaped settings file refuses this event, naming why.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return config.load()
|
|
131
|
+
except config.ConfigError as error:
|
|
132
|
+
refuse(
|
|
133
|
+
f"the ownership guard cannot use its settings ({error}), "
|
|
134
|
+
"so it cannot judge this call"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def config_root() -> Path:
|
|
139
|
+
"""The Claude Code configuration directory this session is using."""
|
|
140
|
+
|
|
141
|
+
configured = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
142
|
+
return Path(configured).expanduser() if configured else Path.home() / ".claude"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def settings_memory_directory(home: Path) -> Path | None:
|
|
146
|
+
"""``autoMemoryDirectory`` from settings this session cannot rewrite.
|
|
147
|
+
|
|
148
|
+
Claude Code reads that key from every settings scope, project and local
|
|
149
|
+
included. This deliberately reads only the USER file, because for a guard
|
|
150
|
+
the key is a scope-WIDENING primitive rather than a preference, and
|
|
151
|
+
``.claude/settings*`` live inside the worktree the guard gates: honouring
|
|
152
|
+
them would let a session nominate its own exemption -- write the key, then
|
|
153
|
+
write wherever it pointed. A boundary the guarded party can edit is not one.
|
|
154
|
+
|
|
155
|
+
Claude Code's own permission model draws the same line. It withholds the
|
|
156
|
+
other widening keys, ``permissions.allow`` and ``additionalDirectories``,
|
|
157
|
+
from project settings until the folder is trusted, and its docs warn that
|
|
158
|
+
``disableAllHooks`` cannot be enforced from user settings "because the
|
|
159
|
+
repository's project settings take precedence over yours". The trust dialog
|
|
160
|
+
is also never shown to a ``claude -p`` or SDK session -- which is how
|
|
161
|
+
subagents and scripted workflows commonly run.
|
|
162
|
+
|
|
163
|
+
The cost is stated rather than hidden: set the key in project or local
|
|
164
|
+
settings and memory writes are refused, with ``denial`` naming the path it
|
|
165
|
+
looked at. Put it in the user file and it is honoured.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
raw = json.loads((home / "settings.json").read_text(encoding="utf-8"))
|
|
170
|
+
except (OSError, ValueError):
|
|
171
|
+
return None
|
|
172
|
+
value = raw.get("autoMemoryDirectory") if isinstance(raw, dict) else None
|
|
173
|
+
# Claude Code accepts only an absolute path or one starting with "~/".
|
|
174
|
+
if not isinstance(value, str) or not value.startswith(("/", "~/")):
|
|
175
|
+
return None
|
|
176
|
+
return Path(value).expanduser()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def project_state_root(checkout: Path) -> Path:
|
|
180
|
+
"""``projects/<slug>``: Claude Code's own state directory for a checkout.
|
|
181
|
+
|
|
182
|
+
Two resolutions, in Claude Code's own precedence:
|
|
183
|
+
|
|
184
|
+
1. ``CLAUDE_CODE_PROJECT_DIR_NAME``, which Claude Code honours only beside
|
|
185
|
+
``CLAUDE_CONFIG_DIR``, replaces the derived name. It is read once from
|
|
186
|
+
the launching shell -- a settings ``env`` block cannot supply it -- so a
|
|
187
|
+
session cannot set it for itself. The pattern keeps it to ONE path
|
|
188
|
+
segment, which is what stops a traversal out of ``projects/``.
|
|
189
|
+
2. Otherwise a slug of the checkout path, every character outside
|
|
190
|
+
``[A-Za-z0-9]`` replaced by ``-``.
|
|
191
|
+
|
|
192
|
+
Nothing here is accepted from a tool call: a mutation event cannot nominate
|
|
193
|
+
a directory to be exempted.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
home = config_root()
|
|
197
|
+
name = os.environ.get("CLAUDE_CODE_PROJECT_DIR_NAME")
|
|
198
|
+
if os.environ.get("CLAUDE_CONFIG_DIR") and name and re.fullmatch(
|
|
199
|
+
r"[A-Za-z0-9_-]{1,64}", name
|
|
200
|
+
):
|
|
201
|
+
return home / "projects" / name
|
|
202
|
+
slug = re.sub(r"[^A-Za-z0-9]", "-", str(checkout))
|
|
203
|
+
return home / "projects" / slug
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def memory_path(primary: Path) -> Path:
|
|
207
|
+
"""Where Claude Code keeps this project's agent memory, existence aside.
|
|
208
|
+
|
|
209
|
+
``autoMemoryDirectory`` relocates the directory wholesale; otherwise it is
|
|
210
|
+
``memory`` under the project state root above.
|
|
211
|
+
|
|
212
|
+
The slug is taken from the PRIMARY checkout because auto memory is keyed on
|
|
213
|
+
the git repository: every worktree of this repo shares one memory directory,
|
|
214
|
+
which is the whole reason a finding survives the worktree that learned it.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
override = settings_memory_directory(config_root())
|
|
218
|
+
if override is not None:
|
|
219
|
+
return override
|
|
220
|
+
return project_state_root(primary) / "memory"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def claude_memory_root(primary: Path) -> Path | None:
|
|
224
|
+
"""The agent-memory directory when it is real, otherwise ``None``.
|
|
225
|
+
|
|
226
|
+
Fails closed on anything that is not already a plain directory. A symlink
|
|
227
|
+
at the memory directory or at the project directory above it could aim the
|
|
228
|
+
allowance at a checkout, another provider's tree, or anywhere else, so both
|
|
229
|
+
are refused; an absent directory is refused because a permitted ``mkdir``
|
|
230
|
+
under ``projects/`` would widen this from one known path to a namespace.
|
|
231
|
+
|
|
232
|
+
The last check holds no matter how the path was resolved: memory may never
|
|
233
|
+
be, contain, or sit inside the repository. Without it a relocated memory
|
|
234
|
+
directory naming a checkout would hand back exactly the writes the worktree
|
|
235
|
+
boundary exists to refuse.
|
|
236
|
+
|
|
237
|
+
This is proportionate rather than absolute. The threat model is a session
|
|
238
|
+
confusing itself about which tree it owns, not a hostile ``~/.claude`` --
|
|
239
|
+
whoever controls that directory already writes the session's instructions.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
memory = memory_path(primary)
|
|
243
|
+
project = memory.parent
|
|
244
|
+
if project.is_symlink() or memory.is_symlink() or not memory.is_dir():
|
|
245
|
+
return None
|
|
246
|
+
resolved, checkout = memory.resolve(), primary.resolve()
|
|
247
|
+
if boundary.is_inside(resolved, checkout) or boundary.is_inside(
|
|
248
|
+
checkout, resolved
|
|
249
|
+
):
|
|
250
|
+
return None
|
|
251
|
+
return resolved
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def lands_in_a_checkout(directory: Path) -> bool:
|
|
255
|
+
"""Whether ``directory``, once resolved, sits inside ANY git work tree.
|
|
256
|
+
|
|
257
|
+
This is the invariant the namespace claims -- that it holds no repository
|
|
258
|
+
state -- tested directly rather than through a proxy. It holds however the
|
|
259
|
+
path came to point there, so a redirection from a component ABOVE the
|
|
260
|
+
config directory, which no symlink check inside that directory can see, is
|
|
261
|
+
still caught.
|
|
262
|
+
|
|
263
|
+
It is deliberately a test of where the path ENDS UP rather than of whether
|
|
264
|
+
a symlink exists on the way. Platform aliases are ordinary: macOS resolves
|
|
265
|
+
``/tmp`` to ``/private/tmp`` and ``/var`` to ``/private/var``, so a config
|
|
266
|
+
directory under either has a symlinked ancestor while being entirely
|
|
267
|
+
legitimate. Refusing those would withhold the namespace from a perfectly
|
|
268
|
+
normal configuration and silently stop every tool that reads it.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
answer = checkout_per_files(directory)
|
|
272
|
+
if answer is None:
|
|
273
|
+
return boundary.run_git(directory, *WORK_TREE_PROBE) is not None
|
|
274
|
+
return answer
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# What `lands_in_a_checkout` asks git when the files do not settle it;
|
|
278
|
+
# `harness_state_roots` asks it of every such candidate at once.
|
|
279
|
+
WORK_TREE_PROBE = ("rev-parse", "--show-toplevel")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def checkout_per_files(directory: Path) -> bool | None:
|
|
283
|
+
"""Whether git's own files put ``directory`` in a work tree; None: ask git.
|
|
284
|
+
|
|
285
|
+
The same reader the boundary's `checkout_state` uses, so a file answer is
|
|
286
|
+
git's answer, and every layout it does not model is git's.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
return boundary.checkout_from_files(directory) is not None
|
|
291
|
+
except boundary.GitMustAnswer:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def harness_state_roots(root: Path, primary: Path) -> tuple[Path, ...]:
|
|
296
|
+
"""Claude Code's state directories for this session, when they are real.
|
|
297
|
+
|
|
298
|
+
These hold the harness's own per-project state -- session transcripts and
|
|
299
|
+
the subagent journals a session may hand to a reporting tool -- and no
|
|
300
|
+
repository state whatever. Both slugs are derived: transcripts are keyed on
|
|
301
|
+
the directory a session RUNS in, so a linked worktree has its own, while
|
|
302
|
+
agent memory is keyed on the repository and lives under the primary's.
|
|
303
|
+
|
|
304
|
+
This is not an allowance and grants no write. It is handed to the shell
|
|
305
|
+
parser as ``read_namespaces``, where its only effect is that a path-shaped
|
|
306
|
+
operand of an UNKNOWN runner landing inside one stops counting as evidence
|
|
307
|
+
of a declared write. Every command with a real parser still confines such a
|
|
308
|
+
path, and the Edit/Write surface never consults this at all -- so a session
|
|
309
|
+
still cannot write here with a file tool, only pass the location to a
|
|
310
|
+
command that was already free to execute arbitrary code.
|
|
311
|
+
|
|
312
|
+
Four gates, and they answer different threats rather than repeating one.
|
|
313
|
+
The directory must already exist as a plain directory. Neither it nor
|
|
314
|
+
``projects/`` above it may be a symlink, which is what stops a redirection
|
|
315
|
+
planted INSIDE the config directory, whatever it points at. The resolution
|
|
316
|
+
must not land in a git work tree, which is what stops a redirection from
|
|
317
|
+
any component ABOVE the config directory, where the symlink is invisible
|
|
318
|
+
from here. And it must not be, sit inside, or contain this session's own
|
|
319
|
+
checkout, which still holds if a checkout ever stops answering to git.
|
|
320
|
+
|
|
321
|
+
One case is deliberately left open: a symlink AT the config directory
|
|
322
|
+
aimed at an ordinary non-repository directory. Planting it requires write
|
|
323
|
+
access to ``~/.claude``, and whoever has that already writes this session's
|
|
324
|
+
hooks, settings and instructions -- the same boundary the agent-memory root
|
|
325
|
+
draws, and for the same reason. Nothing here is accepted from the event.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
checkout = primary.resolve()
|
|
329
|
+
candidates: list[Path] = []
|
|
330
|
+
for candidate in (root, primary):
|
|
331
|
+
directory = project_state_root(candidate)
|
|
332
|
+
if directory.is_symlink() or directory.parent.is_symlink():
|
|
333
|
+
continue
|
|
334
|
+
if not directory.is_dir():
|
|
335
|
+
continue
|
|
336
|
+
resolved = directory.resolve()
|
|
337
|
+
if boundary.is_inside(resolved, checkout) or boundary.is_inside(
|
|
338
|
+
checkout, resolved
|
|
339
|
+
):
|
|
340
|
+
continue
|
|
341
|
+
if resolved not in candidates:
|
|
342
|
+
candidates.append(resolved)
|
|
343
|
+
# `lands_in_a_checkout` last: git's files first, then one git start per
|
|
344
|
+
# candidate they do not settle, all at once. This runs on every mutating
|
|
345
|
+
# Bash call.
|
|
346
|
+
in_a_checkout = {directory: checkout_per_files(directory) for directory in candidates}
|
|
347
|
+
unsettled = [directory for directory, answer in in_a_checkout.items() if answer is None]
|
|
348
|
+
toplevels = boundary.run_git_all(
|
|
349
|
+
[(directory, WORK_TREE_PROBE) for directory in unsettled]
|
|
350
|
+
)
|
|
351
|
+
for directory, toplevel in zip(unsettled, toplevels):
|
|
352
|
+
in_a_checkout[directory] = toplevel is not None
|
|
353
|
+
return tuple(directory for directory in candidates if not in_a_checkout[directory])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def mutation_targets(
|
|
357
|
+
tool: object,
|
|
358
|
+
values: dict[str, object],
|
|
359
|
+
cwd: Path,
|
|
360
|
+
read_namespaces: tuple[Path, ...] = (),
|
|
361
|
+
) -> list[Path] | None:
|
|
362
|
+
"""Paths a mutation event would write, or ``None`` when unprovable."""
|
|
363
|
+
|
|
364
|
+
if tool in {"Edit", "Write", "NotebookEdit"}:
|
|
365
|
+
raw = values.get("file_path") or values.get("notebook_path")
|
|
366
|
+
if not isinstance(raw, str) or not raw:
|
|
367
|
+
boundary.deny("missing edit target")
|
|
368
|
+
target = boundary.resolve_shell_path(raw, cwd)
|
|
369
|
+
# Deliberately NOT given read_namespaces: a file tool names exactly one
|
|
370
|
+
# path and always writes it, so there is no input operand to misread.
|
|
371
|
+
return [target] if target else None
|
|
372
|
+
if tool == "Bash":
|
|
373
|
+
command = boundary.tool_command(values)
|
|
374
|
+
return (
|
|
375
|
+
boundary.mutation_directories(
|
|
376
|
+
command, cwd, read_namespaces=read_namespaces
|
|
377
|
+
)
|
|
378
|
+
if command
|
|
379
|
+
else None
|
|
380
|
+
)
|
|
381
|
+
boundary.deny("unsupported mutation event")
|
|
382
|
+
return None # unreachable; deny() raises
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def denial(target: Path, root: Path, primary: Path) -> str:
|
|
386
|
+
"""Explain a refusal, and say so plainly for a near-miss on agent memory.
|
|
387
|
+
|
|
388
|
+
Without the second clause a session that aimed at its own memory sees only
|
|
389
|
+
a message about worktrees, concludes the write is impossible, and drops the
|
|
390
|
+
finding -- which is exactly how this allowance came to be needed.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
message = (
|
|
394
|
+
f"mutation target {target} is outside this session's Claude worktree {root}"
|
|
395
|
+
)
|
|
396
|
+
expected = memory_path(primary)
|
|
397
|
+
if boundary.is_inside(target, expected):
|
|
398
|
+
message += (
|
|
399
|
+
f"; agent memory is writable only when {expected} is a real directory"
|
|
400
|
+
" (it is missing, or it or its parent is a symlink)"
|
|
401
|
+
)
|
|
402
|
+
return message
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# A push in progress (0086 P-06). A pre-push hook that runs kavach's
|
|
406
|
+
# `push-marker write <git-dir> <pid>` (`src/rule-checks/live-runs.ts`) leaves
|
|
407
|
+
# one marker per push in the worktree's own git folder and clears it on exit.
|
|
408
|
+
# While one names a live push, the push is checking this tree: an edit now
|
|
409
|
+
# makes it fail at its end ("inputs changed") or check what was not pushed.
|
|
410
|
+
# This reads the markers exactly as `live-runs.ts` does: the same name, the
|
|
411
|
+
# same JSON, and the same rule for an ended push (`isGone`), so a marker a
|
|
412
|
+
# killed push left behind, or one whose pid was reused, blocks nothing. A
|
|
413
|
+
# marker that cannot be read names no push, as there: it guards against a
|
|
414
|
+
# wasted push, not against the agent, whom the boundary above already holds.
|
|
415
|
+
PUSH_MARKER = re.compile(r"kavach-push-in-progress\.\d+\.json")
|
|
416
|
+
START_IDENTITY_FORMAT = "lstart-utc-c/1"
|
|
417
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class PushRun:
|
|
421
|
+
"""A push a marker records: its pid, start identity and start time."""
|
|
422
|
+
|
|
423
|
+
def __init__(self, pid: int, start_identity: str | None, started_at: str | None):
|
|
424
|
+
self.pid = pid
|
|
425
|
+
self.start_identity = start_identity
|
|
426
|
+
self.started_at = started_at
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def parse_push_run(raw: str) -> PushRun | None:
|
|
430
|
+
"""A marker's run, as `parseRun` reads it; ``None`` for anything else."""
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
value = json.loads(raw)
|
|
434
|
+
except ValueError:
|
|
435
|
+
return None
|
|
436
|
+
if not isinstance(value, dict):
|
|
437
|
+
return None
|
|
438
|
+
pid = value.get("pid")
|
|
439
|
+
if isinstance(pid, float) and pid.is_integer():
|
|
440
|
+
pid = int(pid)
|
|
441
|
+
if isinstance(pid, bool) or not isinstance(pid, int):
|
|
442
|
+
return None
|
|
443
|
+
if pid <= 1 or pid > MAX_SAFE_INTEGER:
|
|
444
|
+
return None
|
|
445
|
+
# A missing key is not JSON's null: `parseRun` refuses it, so this does.
|
|
446
|
+
if "startIdentity" not in value:
|
|
447
|
+
return None
|
|
448
|
+
identity = value["startIdentity"]
|
|
449
|
+
if identity is not None and not isinstance(identity, str):
|
|
450
|
+
return None
|
|
451
|
+
started = value.get("startedAt")
|
|
452
|
+
# Only a plain timestamp is quoted back in a refusal.
|
|
453
|
+
if not isinstance(started, str) or not re.fullmatch(r"[0-9T:.+Z-]{1,40}", started):
|
|
454
|
+
started = None
|
|
455
|
+
return PushRun(pid, identity, started)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def ps_field(pid: int, field: str, environment: dict[str, str]) -> str | None:
|
|
459
|
+
"""``ps -o <field>= -p <pid>``, stripped; ``None`` when ps fails."""
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
result = subprocess.run(
|
|
463
|
+
["ps", "-o", f"{field}=", "-p", str(pid)],
|
|
464
|
+
stdin=subprocess.DEVNULL,
|
|
465
|
+
capture_output=True,
|
|
466
|
+
text=True,
|
|
467
|
+
check=False,
|
|
468
|
+
env=environment,
|
|
469
|
+
)
|
|
470
|
+
except OSError:
|
|
471
|
+
return None
|
|
472
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def process_alive(pid: int) -> bool:
|
|
476
|
+
"""As `processProbe.isAlive`: only ESRCH or a zombie is dead."""
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
os.kill(pid, 0)
|
|
480
|
+
except (ProcessLookupError, OverflowError):
|
|
481
|
+
# OverflowError: a safe integer past the system's pid range names no
|
|
482
|
+
# process.
|
|
483
|
+
return False
|
|
484
|
+
except OSError:
|
|
485
|
+
# EPERM (someone else's process) or anything else is no evidence.
|
|
486
|
+
pass
|
|
487
|
+
state = ps_field(pid, "stat", {**os.environ, "LC_ALL": "C"})
|
|
488
|
+
return state is None or not state.startswith("Z")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def start_identity_of(pid: int) -> str | None:
|
|
492
|
+
"""As `processProbe.startIdentityOf`: `lstart` read in UTC and the C locale."""
|
|
493
|
+
|
|
494
|
+
started = ps_field(pid, "lstart", {**os.environ, "TZ": "UTC", "LC_ALL": "C"})
|
|
495
|
+
return f"{START_IDENTITY_FORMAT} {started}" if started else None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def is_current_identity(identity: str | None) -> bool:
|
|
499
|
+
return identity is not None and identity.startswith(f"{START_IDENTITY_FORMAT} ")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def push_is_gone(run: PushRun) -> bool:
|
|
503
|
+
"""As `isGone`: the pid is dead, or now a process that started later."""
|
|
504
|
+
|
|
505
|
+
if not process_alive(run.pid):
|
|
506
|
+
return True
|
|
507
|
+
if not is_current_identity(run.start_identity):
|
|
508
|
+
return False
|
|
509
|
+
current = start_identity_of(run.pid)
|
|
510
|
+
if current is None:
|
|
511
|
+
return not process_alive(run.pid)
|
|
512
|
+
return is_current_identity(current) and current != run.start_identity
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def git_dir_of(root: Path) -> Path | None:
|
|
516
|
+
"""The worktree's own git folder, from its files first, then from git."""
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
found = boundary.checkout_from_files(root)
|
|
520
|
+
except boundary.GitMustAnswer:
|
|
521
|
+
found = None
|
|
522
|
+
if found is not None:
|
|
523
|
+
return found[1]
|
|
524
|
+
answer = boundary.run_git(root, "rev-parse", "--absolute-git-dir")
|
|
525
|
+
return Path(answer) if answer else None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def live_push(root: Path) -> PushRun | None:
|
|
529
|
+
"""The first live push a marker in the worktree's git folder records."""
|
|
530
|
+
|
|
531
|
+
git_dir = git_dir_of(root)
|
|
532
|
+
if git_dir is None:
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
names = sorted(name for name in os.listdir(git_dir) if PUSH_MARKER.fullmatch(name))
|
|
536
|
+
except OSError:
|
|
537
|
+
return None
|
|
538
|
+
for name in names:
|
|
539
|
+
try:
|
|
540
|
+
raw = (git_dir / name).read_text(encoding="utf-8")
|
|
541
|
+
except (OSError, ValueError):
|
|
542
|
+
continue
|
|
543
|
+
run = parse_push_run(raw)
|
|
544
|
+
if run is not None and not push_is_gone(run):
|
|
545
|
+
return run
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def git_ignores(root: Path, target: Path) -> bool:
|
|
550
|
+
"""Whether git ignores ``target`` in the checkout at ``root``.
|
|
551
|
+
|
|
552
|
+
A tracked file is never ignored, whatever the patterns say; an answer git
|
|
553
|
+
cannot give counts as not ignored.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
# `./` keeps a name such as `:(top)x` from being read as pathspec magic.
|
|
557
|
+
relative = f"./{os.path.relpath(target, root)}"
|
|
558
|
+
result = boundary.run_git_result(root, "check-ignore", "-q", "--", relative)
|
|
559
|
+
return result.returncode == 0
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def refuse_during_push(tool: object, targets: list[Path], root: Path) -> None:
|
|
563
|
+
"""Refuse a write into the worktree while its push runs.
|
|
564
|
+
|
|
565
|
+
A Bash command that reached here is one the boundary could not prove
|
|
566
|
+
read-only, and its targets always include the directory it runs in, so
|
|
567
|
+
any such command run in the worktree waits: what it writes there cannot
|
|
568
|
+
be told. An Edit, Write or NotebookEdit names its one path, and a path git
|
|
569
|
+
ignores (a draft, a scratch file) changes nothing a push reads.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
checkout = root.resolve()
|
|
573
|
+
inside = [target for target in targets if boundary.is_inside(target.resolve(), checkout)]
|
|
574
|
+
if not inside:
|
|
575
|
+
return
|
|
576
|
+
run = live_push(root)
|
|
577
|
+
if run is None:
|
|
578
|
+
return
|
|
579
|
+
if tool == "Bash":
|
|
580
|
+
what = "this command may write in it"
|
|
581
|
+
else:
|
|
582
|
+
changed = [target for target in inside if not git_ignores(checkout, target.resolve())]
|
|
583
|
+
if not changed:
|
|
584
|
+
return
|
|
585
|
+
what = f"this edits {os.path.relpath(changed[0].resolve(), checkout)}, which git does not ignore"
|
|
586
|
+
started = f", started {run.started_at}" if run.started_at else ""
|
|
587
|
+
boundary.deny(
|
|
588
|
+
f"this worktree's push (pid {run.pid}{started}) is still running, and {what}. "
|
|
589
|
+
"Wait for the push to end, then retry. Read-only commands still run, "
|
|
590
|
+
"and files git ignores can still be written with Edit or Write."
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def main() -> None:
|
|
595
|
+
# First, so a misread settings file refuses every event, a read-only one
|
|
596
|
+
# included.
|
|
597
|
+
trusted = settings().trusted_commands
|
|
598
|
+
data = boundary.read_input()
|
|
599
|
+
cwd = Path(data.get("cwd") or Path.cwd()).resolve()
|
|
600
|
+
state = boundary.checkout_state(cwd)
|
|
601
|
+
if not state:
|
|
602
|
+
# Quote git rather than paraphrase it. This denial fires for every
|
|
603
|
+
# Bash, Edit and Write call, so a git that is broken for an unrelated
|
|
604
|
+
# reason presents as a total ownership failure and sends the session
|
|
605
|
+
# after its worktree instead of after the actual fault.
|
|
606
|
+
boundary.deny(
|
|
607
|
+
"cannot determine this session's repository checkout: "
|
|
608
|
+
f"{boundary.checkout_failure(cwd)}"
|
|
609
|
+
)
|
|
610
|
+
root, linked, primary = state
|
|
611
|
+
tool = data.get("tool_name")
|
|
612
|
+
values = data.get("tool_input")
|
|
613
|
+
if not isinstance(values, dict):
|
|
614
|
+
boundary.deny("missing tool input")
|
|
615
|
+
if tool == "Bash":
|
|
616
|
+
command = boundary.tool_command(values)
|
|
617
|
+
if command and boundary.shell_is_read_only(command):
|
|
618
|
+
return
|
|
619
|
+
# Only the shell parser reads these (a file tool is never given them), and
|
|
620
|
+
# finding them costs git starts, so an Edit or Write does not pay for them.
|
|
621
|
+
read_namespaces = harness_state_roots(root, primary) if tool == "Bash" else ()
|
|
622
|
+
targets = mutation_targets(tool, values, cwd, read_namespaces)
|
|
623
|
+
memory = claude_memory_root(primary)
|
|
624
|
+
|
|
625
|
+
def in_memory(target: Path) -> bool:
|
|
626
|
+
return memory is not None and boundary.is_inside(target, memory)
|
|
627
|
+
|
|
628
|
+
# Agent memory belongs to no checkout, so the worktree gate below has
|
|
629
|
+
# nothing to protect there. An event whose targets are ENTIRELY inside
|
|
630
|
+
# memory skips that gate outright, which lets a session that has not
|
|
631
|
+
# entered a worktree still record what it learned. That is an edit event in
|
|
632
|
+
# practice: a mutating shell command always counts the directory it runs in
|
|
633
|
+
# among its targets, because this parser cannot prove what it writes there.
|
|
634
|
+
if targets and all(in_memory(target) for target in targets):
|
|
635
|
+
return
|
|
636
|
+
if not linked or not boundary.is_claude_managed(root, primary):
|
|
637
|
+
boundary.deny("Claude mutations require this session's Claude-managed worktree; use EnterWorktree")
|
|
638
|
+
if tool == "Bash" and boundary.trusted_request(
|
|
639
|
+
boundary.tool_command(values), cwd, root, trusted
|
|
640
|
+
):
|
|
641
|
+
# A script the consumer reviewed and trusts, reached only after
|
|
642
|
+
# ownership above. What it writes is unproven, so it counts as
|
|
643
|
+
# writing in the checkout while a push runs.
|
|
644
|
+
refuse_during_push(tool, [root], root)
|
|
645
|
+
return
|
|
646
|
+
if targets is None:
|
|
647
|
+
boundary.deny("mutation targets could not be resolved")
|
|
648
|
+
for target in targets:
|
|
649
|
+
# From inside an owned worktree the two allowances compose, so
|
|
650
|
+
# `touch <memory>/note.md` works while the same command with a second
|
|
651
|
+
# operand outside both still fails on that operand.
|
|
652
|
+
if not (boundary.is_session_target(target, root, primary) or in_memory(target)):
|
|
653
|
+
boundary.deny(denial(target, root, primary))
|
|
654
|
+
refuse_during_push(tool, targets, root)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def run() -> None:
|
|
658
|
+
"""``main``, with anything but its own verdict refusing.
|
|
659
|
+
|
|
660
|
+
``main`` allows by returning and refuses with exit 2; an explicit exit 0
|
|
661
|
+
also allows. Any other exit, a bare ``sys.exit()`` included, refuses.
|
|
662
|
+
"""
|
|
663
|
+
|
|
664
|
+
try:
|
|
665
|
+
main()
|
|
666
|
+
except SystemExit as error:
|
|
667
|
+
if is_exit(error, 0, 2):
|
|
668
|
+
raise
|
|
669
|
+
refuse(f"the ownership guard failed ({failure(error)}), so it cannot judge this call")
|
|
670
|
+
except BaseException as error:
|
|
671
|
+
refuse(f"the ownership guard failed ({failure(error)}), so it cannot judge this call")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
if __name__ == "__main__":
|
|
675
|
+
refusal_log.guarded("worktree", run)
|