@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.
@@ -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)