@veda-hq/kavach 0.0.0-stage → 0.3.0

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,157 @@
1
+ """The guards' refusal log: one JSON row per refusal, so a later reading can
2
+ say which refusals a guard made and how often.
3
+
4
+ Where: ``<git common dir>/kavach/refusals.jsonl`` of the repository the
5
+ event's ``cwd`` belongs to, shared by all of its worktrees. Each row holds the
6
+ time, the guard, the tool, the first line of the reason and the start of the
7
+ command or path. Commands can hold secrets typed on a command line, so the log
8
+ stays inside the repository's git directory, never in the working tree.
9
+
10
+ Best effort: logging never changes a verdict. Any failure (no git, no
11
+ repository, a read-only git directory) is swallowed, and the guard's own
12
+ exit stands.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import io
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any, Callable, TextIO
25
+
26
+ FILE = "refusals.jsonl"
27
+ EXCERPT = 200
28
+ # Set to any non-empty value to turn the log off (a consumer's own test runs).
29
+ OFF_VAR = "KAVACH_REFUSAL_LOG_OFF"
30
+
31
+
32
+ class _Tee(io.TextIOBase):
33
+ """Writes through to ``target`` and keeps a copy."""
34
+
35
+ def __init__(self, target: TextIO) -> None:
36
+ self.target = target
37
+ self.copy = io.StringIO()
38
+
39
+ def write(self, text: str) -> int:
40
+ self.copy.write(text)
41
+ return self.target.write(text)
42
+
43
+ def flush(self) -> None:
44
+ self.target.flush()
45
+
46
+
47
+ def log_dir(cwd: str) -> Path | None:
48
+ """``<git common dir>/kavach`` for ``cwd``, or None when git cannot say."""
49
+
50
+ try:
51
+ result = subprocess.run(
52
+ ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
53
+ cwd=cwd,
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=5,
57
+ check=False,
58
+ )
59
+ except (OSError, subprocess.SubprocessError):
60
+ return None
61
+ common = result.stdout.strip()
62
+ if result.returncode != 0 or not common:
63
+ return None
64
+ return Path(common) / "kavach"
65
+
66
+
67
+ def excerpt(tool_input: Any) -> str:
68
+ """The command, or the file path, clipped."""
69
+
70
+ if isinstance(tool_input, dict):
71
+ for key in ("command", "file_path", "notebook_path", "path"):
72
+ value = tool_input.get(key)
73
+ if isinstance(value, str):
74
+ return value[:EXCERPT]
75
+ return ""
76
+
77
+
78
+ def record(guard: str, raw_input: str, stderr_text: str, now: Callable[[], float] = time.time) -> None:
79
+ """Append one row for a refusal; never raises."""
80
+
81
+ try:
82
+ if os.environ.get(OFF_VAR):
83
+ return
84
+ try:
85
+ event = json.loads(raw_input)
86
+ except ValueError:
87
+ event = {}
88
+ if not isinstance(event, dict):
89
+ event = {}
90
+ cwd = event.get("cwd") if isinstance(event.get("cwd"), str) else os.getcwd()
91
+ directory = log_dir(cwd)
92
+ if directory is None:
93
+ return
94
+ reason = next(
95
+ (line for line in stderr_text.splitlines() if line.strip()),
96
+ "",
97
+ )[:EXCERPT]
98
+ row = {
99
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now())),
100
+ "guard": guard,
101
+ "tool": event.get("tool_name") if isinstance(event.get("tool_name"), str) else "",
102
+ "reason": reason,
103
+ "excerpt": excerpt(event.get("tool_input")),
104
+ }
105
+ directory.mkdir(parents=True, exist_ok=True)
106
+ with open(directory / FILE, "a", encoding="utf-8") as handle:
107
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
108
+ except BaseException: # noqa: BLE001 - logging must never change a verdict
109
+ return
110
+
111
+
112
+ class _ReadTee:
113
+ """Reads through ``source`` and keeps a copy of the text read. Errors
114
+ pass through untouched, so the guard reads its input as it always did."""
115
+
116
+ def __init__(self, source: Any) -> None:
117
+ self.source = source
118
+ self.copy = io.StringIO()
119
+
120
+ def read(self, *args: Any) -> Any:
121
+ text = self.source.read(*args)
122
+ if isinstance(text, str):
123
+ self.copy.write(text)
124
+ return text
125
+
126
+ def __getattr__(self, name: str) -> Any:
127
+ return getattr(self.source, name)
128
+
129
+
130
+ def guarded(guard: str, body: Callable[[], Any], raw_input: Callable[[], str] | None = None) -> Any:
131
+ """Run ``body``; when it refuses (exit 2 raised, or 2 returned), log it.
132
+
133
+ The verdict passes through unchanged. ``raw_input`` returns the event
134
+ text when the guard reads its input another way; by default the text the
135
+ guard read from ``sys.stdin`` is used.
136
+ """
137
+
138
+ stderr = _Tee(sys.stderr)
139
+ stdin = _ReadTee(sys.stdin)
140
+ previous = sys.stderr, sys.stdin
141
+ sys.stderr, sys.stdin = stderr, stdin # type: ignore[assignment]
142
+
143
+ def log() -> None:
144
+ text = raw_input() if raw_input is not None else stdin.copy.getvalue()
145
+ record(guard, text, stderr.copy.getvalue())
146
+
147
+ try:
148
+ result = body()
149
+ except SystemExit as end:
150
+ if type(end.code) is int and end.code == 2:
151
+ log()
152
+ raise
153
+ finally:
154
+ sys.stderr, sys.stdin = previous
155
+ if type(result) is int and result == 2:
156
+ log()
157
+ return result
@@ -0,0 +1,482 @@
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 sys
26
+ from pathlib import Path
27
+ from types import ModuleType
28
+ from typing import Any, NoReturn
29
+
30
+
31
+ HERE = Path(__file__).resolve().parent
32
+
33
+
34
+ # Claude Code lets a hook's exit 1 through, so an error in this guard or in the
35
+ # boundary it loads would wave every mutation on. Any failure refuses instead,
36
+ # naming the error. Not caught here: an interpreter that cannot start or is
37
+ # killed, and a syntax error in this file, which the guard's behaviour suite
38
+ # (`tests/test_worktree_guard.py`) exists to catch before it ships.
39
+ def refuse(reason: str) -> NoReturn:
40
+ print(f"BLOCKED: {reason}", file=sys.stderr)
41
+ raise SystemExit(2)
42
+
43
+
44
+ def is_exit(error: SystemExit, *codes: int) -> bool:
45
+ """Whether ``error`` exits with one of ``codes``, as an int: Python exits 1
46
+ for ``SystemExit(2.0)`` although ``2.0 == 2``."""
47
+
48
+ return type(error.code) is int and error.code in codes
49
+
50
+
51
+ def failure(error: BaseException) -> str:
52
+ if isinstance(error, SystemExit):
53
+ return f"exit {error.code!r} instead of a verdict"
54
+ return f"{type(error).__name__}: {error}"
55
+
56
+
57
+ def load_module(name: str, path: Path, *, register: bool = False) -> ModuleType:
58
+ """A module of this package, by file; a module that fails to load refuses.
59
+
60
+ Loaded by path rather than imported, so the guard runs as a plain script
61
+ from wherever the package is installed. With ``register`` the module is
62
+ entered in ``sys.modules`` under ``name`` while it runs, which a dataclass
63
+ module needs; a load that fails puts back whatever was there.
64
+
65
+ Loading has no verdict to give, so any exit but a refusal (2) refuses too:
66
+ an exit 0 here would skip the check, an exit 1 would wave the call on.
67
+ """
68
+
69
+ previous = sys.modules.get(name)
70
+
71
+ def forget() -> None:
72
+ if not register:
73
+ return
74
+ if previous is None:
75
+ sys.modules.pop(name, None)
76
+ else:
77
+ sys.modules[name] = previous
78
+
79
+ try:
80
+ spec = importlib.util.spec_from_file_location(name, path)
81
+ if spec is None or spec.loader is None:
82
+ raise ImportError(f"no loader for {path}")
83
+ module = importlib.util.module_from_spec(spec)
84
+ if register:
85
+ sys.modules[name] = module
86
+ spec.loader.exec_module(module)
87
+ except SystemExit as error:
88
+ forget()
89
+ if is_exit(error, 2):
90
+ raise
91
+ refuse(
92
+ f"the ownership guard could not load {path.name} ({failure(error)}), "
93
+ "so it cannot judge this call"
94
+ )
95
+ except BaseException as error:
96
+ forget()
97
+ refuse(
98
+ f"the ownership guard could not load {path.name} ({failure(error)}), "
99
+ "so it cannot judge this call"
100
+ )
101
+ return module
102
+
103
+
104
+ def load_boundary(path: Path) -> ModuleType:
105
+ """The shared shell/path boundary; a module that fails to load refuses."""
106
+
107
+ return load_module("kavach_worktree_boundary", path)
108
+
109
+
110
+ def load_config(path: Path) -> ModuleType:
111
+ """The settings loader; a module that fails to load refuses."""
112
+
113
+ return load_module("kavach_worktree_config", path, register=True)
114
+
115
+
116
+ boundary = load_boundary(HERE / "boundary.py")
117
+ config = load_config(HERE / "config.py")
118
+ refusal_log = load_module("kavach_worktree_refusal_log", HERE / "refusal_log.py")
119
+
120
+
121
+ def settings() -> Any:
122
+ """The consumer's settings (``config.Config``); unusable settings refuse.
123
+
124
+ A guard that misread what it trusts must not allow, so an unreadable,
125
+ malformed or wrongly shaped settings file refuses this event, naming why.
126
+ """
127
+
128
+ try:
129
+ return config.load()
130
+ except config.ConfigError as error:
131
+ refuse(
132
+ f"the ownership guard cannot use its settings ({error}), "
133
+ "so it cannot judge this call"
134
+ )
135
+
136
+
137
+ def config_root() -> Path:
138
+ """The Claude Code configuration directory this session is using."""
139
+
140
+ configured = os.environ.get("CLAUDE_CONFIG_DIR")
141
+ return Path(configured).expanduser() if configured else Path.home() / ".claude"
142
+
143
+
144
+ def settings_memory_directory(home: Path) -> Path | None:
145
+ """``autoMemoryDirectory`` from settings this session cannot rewrite.
146
+
147
+ Claude Code reads that key from every settings scope, project and local
148
+ included. This deliberately reads only the USER file, because for a guard
149
+ the key is a scope-WIDENING primitive rather than a preference, and
150
+ ``.claude/settings*`` live inside the worktree the guard gates: honouring
151
+ them would let a session nominate its own exemption -- write the key, then
152
+ write wherever it pointed. A boundary the guarded party can edit is not one.
153
+
154
+ Claude Code's own permission model draws the same line. It withholds the
155
+ other widening keys, ``permissions.allow`` and ``additionalDirectories``,
156
+ from project settings until the folder is trusted, and its docs warn that
157
+ ``disableAllHooks`` cannot be enforced from user settings "because the
158
+ repository's project settings take precedence over yours". The trust dialog
159
+ is also never shown to a ``claude -p`` or SDK session -- which is how
160
+ subagents and scripted workflows commonly run.
161
+
162
+ The cost is stated rather than hidden: set the key in project or local
163
+ settings and memory writes are refused, with ``denial`` naming the path it
164
+ looked at. Put it in the user file and it is honoured.
165
+ """
166
+
167
+ try:
168
+ raw = json.loads((home / "settings.json").read_text(encoding="utf-8"))
169
+ except (OSError, ValueError):
170
+ return None
171
+ value = raw.get("autoMemoryDirectory") if isinstance(raw, dict) else None
172
+ # Claude Code accepts only an absolute path or one starting with "~/".
173
+ if not isinstance(value, str) or not value.startswith(("/", "~/")):
174
+ return None
175
+ return Path(value).expanduser()
176
+
177
+
178
+ def project_state_root(checkout: Path) -> Path:
179
+ """``projects/<slug>``: Claude Code's own state directory for a checkout.
180
+
181
+ Two resolutions, in Claude Code's own precedence:
182
+
183
+ 1. ``CLAUDE_CODE_PROJECT_DIR_NAME``, which Claude Code honours only beside
184
+ ``CLAUDE_CONFIG_DIR``, replaces the derived name. It is read once from
185
+ the launching shell -- a settings ``env`` block cannot supply it -- so a
186
+ session cannot set it for itself. The pattern keeps it to ONE path
187
+ segment, which is what stops a traversal out of ``projects/``.
188
+ 2. Otherwise a slug of the checkout path, every character outside
189
+ ``[A-Za-z0-9]`` replaced by ``-``.
190
+
191
+ Nothing here is accepted from a tool call: a mutation event cannot nominate
192
+ a directory to be exempted.
193
+ """
194
+
195
+ home = config_root()
196
+ name = os.environ.get("CLAUDE_CODE_PROJECT_DIR_NAME")
197
+ if os.environ.get("CLAUDE_CONFIG_DIR") and name and re.fullmatch(
198
+ r"[A-Za-z0-9_-]{1,64}", name
199
+ ):
200
+ return home / "projects" / name
201
+ slug = re.sub(r"[^A-Za-z0-9]", "-", str(checkout))
202
+ return home / "projects" / slug
203
+
204
+
205
+ def memory_path(primary: Path) -> Path:
206
+ """Where Claude Code keeps this project's agent memory, existence aside.
207
+
208
+ ``autoMemoryDirectory`` relocates the directory wholesale; otherwise it is
209
+ ``memory`` under the project state root above.
210
+
211
+ The slug is taken from the PRIMARY checkout because auto memory is keyed on
212
+ the git repository: every worktree of this repo shares one memory directory,
213
+ which is the whole reason a finding survives the worktree that learned it.
214
+ """
215
+
216
+ override = settings_memory_directory(config_root())
217
+ if override is not None:
218
+ return override
219
+ return project_state_root(primary) / "memory"
220
+
221
+
222
+ def claude_memory_root(primary: Path) -> Path | None:
223
+ """The agent-memory directory when it is real, otherwise ``None``.
224
+
225
+ Fails closed on anything that is not already a plain directory. A symlink
226
+ at the memory directory or at the project directory above it could aim the
227
+ allowance at a checkout, another provider's tree, or anywhere else, so both
228
+ are refused; an absent directory is refused because a permitted ``mkdir``
229
+ under ``projects/`` would widen this from one known path to a namespace.
230
+
231
+ The last check holds no matter how the path was resolved: memory may never
232
+ be, contain, or sit inside the repository. Without it a relocated memory
233
+ directory naming a checkout would hand back exactly the writes the worktree
234
+ boundary exists to refuse.
235
+
236
+ This is proportionate rather than absolute. The threat model is a session
237
+ confusing itself about which tree it owns, not a hostile ``~/.claude`` --
238
+ whoever controls that directory already writes the session's instructions.
239
+ """
240
+
241
+ memory = memory_path(primary)
242
+ project = memory.parent
243
+ if project.is_symlink() or memory.is_symlink() or not memory.is_dir():
244
+ return None
245
+ resolved, checkout = memory.resolve(), primary.resolve()
246
+ if boundary.is_inside(resolved, checkout) or boundary.is_inside(
247
+ checkout, resolved
248
+ ):
249
+ return None
250
+ return resolved
251
+
252
+
253
+ def lands_in_a_checkout(directory: Path) -> bool:
254
+ """Whether ``directory``, once resolved, sits inside ANY git work tree.
255
+
256
+ This is the invariant the namespace claims -- that it holds no repository
257
+ state -- tested directly rather than through a proxy. It holds however the
258
+ path came to point there, so a redirection from a component ABOVE the
259
+ config directory, which no symlink check inside that directory can see, is
260
+ still caught.
261
+
262
+ It is deliberately a test of where the path ENDS UP rather than of whether
263
+ a symlink exists on the way. Platform aliases are ordinary: macOS resolves
264
+ ``/tmp`` to ``/private/tmp`` and ``/var`` to ``/private/var``, so a config
265
+ directory under either has a symlinked ancestor while being entirely
266
+ legitimate. Refusing those would withhold the namespace from a perfectly
267
+ normal configuration and silently stop every tool that reads it.
268
+ """
269
+
270
+ answer = checkout_per_files(directory)
271
+ if answer is None:
272
+ return boundary.run_git(directory, *WORK_TREE_PROBE) is not None
273
+ return answer
274
+
275
+
276
+ # What `lands_in_a_checkout` asks git when the files do not settle it;
277
+ # `harness_state_roots` asks it of every such candidate at once.
278
+ WORK_TREE_PROBE = ("rev-parse", "--show-toplevel")
279
+
280
+
281
+ def checkout_per_files(directory: Path) -> bool | None:
282
+ """Whether git's own files put ``directory`` in a work tree; None: ask git.
283
+
284
+ The same reader the boundary's `checkout_state` uses, so a file answer is
285
+ git's answer, and every layout it does not model is git's.
286
+ """
287
+
288
+ try:
289
+ return boundary.checkout_from_files(directory) is not None
290
+ except boundary.GitMustAnswer:
291
+ return None
292
+
293
+
294
+ def harness_state_roots(root: Path, primary: Path) -> tuple[Path, ...]:
295
+ """Claude Code's state directories for this session, when they are real.
296
+
297
+ These hold the harness's own per-project state -- session transcripts and
298
+ the subagent journals a session may hand to a reporting tool -- and no
299
+ repository state whatever. Both slugs are derived: transcripts are keyed on
300
+ the directory a session RUNS in, so a linked worktree has its own, while
301
+ agent memory is keyed on the repository and lives under the primary's.
302
+
303
+ This is not an allowance and grants no write. It is handed to the shell
304
+ parser as ``read_namespaces``, where its only effect is that a path-shaped
305
+ operand of an UNKNOWN runner landing inside one stops counting as evidence
306
+ of a declared write. Every command with a real parser still confines such a
307
+ path, and the Edit/Write surface never consults this at all -- so a session
308
+ still cannot write here with a file tool, only pass the location to a
309
+ command that was already free to execute arbitrary code.
310
+
311
+ Four gates, and they answer different threats rather than repeating one.
312
+ The directory must already exist as a plain directory. Neither it nor
313
+ ``projects/`` above it may be a symlink, which is what stops a redirection
314
+ planted INSIDE the config directory, whatever it points at. The resolution
315
+ must not land in a git work tree, which is what stops a redirection from
316
+ any component ABOVE the config directory, where the symlink is invisible
317
+ from here. And it must not be, sit inside, or contain this session's own
318
+ checkout, which still holds if a checkout ever stops answering to git.
319
+
320
+ One case is deliberately left open: a symlink AT the config directory
321
+ aimed at an ordinary non-repository directory. Planting it requires write
322
+ access to ``~/.claude``, and whoever has that already writes this session's
323
+ hooks, settings and instructions -- the same boundary the agent-memory root
324
+ draws, and for the same reason. Nothing here is accepted from the event.
325
+ """
326
+
327
+ checkout = primary.resolve()
328
+ candidates: list[Path] = []
329
+ for candidate in (root, primary):
330
+ directory = project_state_root(candidate)
331
+ if directory.is_symlink() or directory.parent.is_symlink():
332
+ continue
333
+ if not directory.is_dir():
334
+ continue
335
+ resolved = directory.resolve()
336
+ if boundary.is_inside(resolved, checkout) or boundary.is_inside(
337
+ checkout, resolved
338
+ ):
339
+ continue
340
+ if resolved not in candidates:
341
+ candidates.append(resolved)
342
+ # `lands_in_a_checkout` last: git's files first, then one git start per
343
+ # candidate they do not settle, all at once. This runs on every mutating
344
+ # Bash call.
345
+ in_a_checkout = {directory: checkout_per_files(directory) for directory in candidates}
346
+ unsettled = [directory for directory, answer in in_a_checkout.items() if answer is None]
347
+ toplevels = boundary.run_git_all(
348
+ [(directory, WORK_TREE_PROBE) for directory in unsettled]
349
+ )
350
+ for directory, toplevel in zip(unsettled, toplevels):
351
+ in_a_checkout[directory] = toplevel is not None
352
+ return tuple(directory for directory in candidates if not in_a_checkout[directory])
353
+
354
+
355
+ def mutation_targets(
356
+ tool: object,
357
+ values: dict[str, object],
358
+ cwd: Path,
359
+ read_namespaces: tuple[Path, ...] = (),
360
+ ) -> list[Path] | None:
361
+ """Paths a mutation event would write, or ``None`` when unprovable."""
362
+
363
+ if tool in {"Edit", "Write", "NotebookEdit"}:
364
+ raw = values.get("file_path") or values.get("notebook_path")
365
+ if not isinstance(raw, str) or not raw:
366
+ boundary.deny("missing edit target")
367
+ target = boundary.resolve_shell_path(raw, cwd)
368
+ # Deliberately NOT given read_namespaces: a file tool names exactly one
369
+ # path and always writes it, so there is no input operand to misread.
370
+ return [target] if target else None
371
+ if tool == "Bash":
372
+ command = boundary.tool_command(values)
373
+ return (
374
+ boundary.mutation_directories(
375
+ command, cwd, read_namespaces=read_namespaces
376
+ )
377
+ if command
378
+ else None
379
+ )
380
+ boundary.deny("unsupported mutation event")
381
+ return None # unreachable; deny() raises
382
+
383
+
384
+ def denial(target: Path, root: Path, primary: Path) -> str:
385
+ """Explain a refusal, and say so plainly for a near-miss on agent memory.
386
+
387
+ Without the second clause a session that aimed at its own memory sees only
388
+ a message about worktrees, concludes the write is impossible, and drops the
389
+ finding -- which is exactly how this allowance came to be needed.
390
+ """
391
+
392
+ message = (
393
+ f"mutation target {target} is outside this session's Claude worktree {root}"
394
+ )
395
+ expected = memory_path(primary)
396
+ if boundary.is_inside(target, expected):
397
+ message += (
398
+ f"; agent memory is writable only when {expected} is a real directory"
399
+ " (it is missing, or it or its parent is a symlink)"
400
+ )
401
+ return message
402
+
403
+
404
+ def main() -> None:
405
+ # First, so a misread settings file refuses every event, a read-only one
406
+ # included.
407
+ trusted = settings().trusted_commands
408
+ data = boundary.read_input()
409
+ cwd = Path(data.get("cwd") or Path.cwd()).resolve()
410
+ state = boundary.checkout_state(cwd)
411
+ if not state:
412
+ # Quote git rather than paraphrase it. This denial fires for every
413
+ # Bash, Edit and Write call, so a git that is broken for an unrelated
414
+ # reason presents as a total ownership failure and sends the session
415
+ # after its worktree instead of after the actual fault.
416
+ boundary.deny(
417
+ "cannot determine this session's repository checkout: "
418
+ f"{boundary.checkout_failure(cwd)}"
419
+ )
420
+ root, linked, primary = state
421
+ tool = data.get("tool_name")
422
+ values = data.get("tool_input")
423
+ if not isinstance(values, dict):
424
+ boundary.deny("missing tool input")
425
+ if tool == "Bash":
426
+ command = boundary.tool_command(values)
427
+ if command and boundary.shell_is_read_only(command):
428
+ return
429
+ # Only the shell parser reads these (a file tool is never given them), and
430
+ # finding them costs git starts, so an Edit or Write does not pay for them.
431
+ read_namespaces = harness_state_roots(root, primary) if tool == "Bash" else ()
432
+ targets = mutation_targets(tool, values, cwd, read_namespaces)
433
+ memory = claude_memory_root(primary)
434
+
435
+ def in_memory(target: Path) -> bool:
436
+ return memory is not None and boundary.is_inside(target, memory)
437
+
438
+ # Agent memory belongs to no checkout, so the worktree gate below has
439
+ # nothing to protect there. An event whose targets are ENTIRELY inside
440
+ # memory skips that gate outright, which lets a session that has not
441
+ # entered a worktree still record what it learned. That is an edit event in
442
+ # practice: a mutating shell command always counts the directory it runs in
443
+ # among its targets, because this parser cannot prove what it writes there.
444
+ if targets and all(in_memory(target) for target in targets):
445
+ return
446
+ if not linked or not boundary.is_claude_managed(root, primary):
447
+ boundary.deny("Claude mutations require this session's Claude-managed worktree; use EnterWorktree")
448
+ if tool == "Bash" and boundary.trusted_request(
449
+ boundary.tool_command(values), cwd, root, trusted
450
+ ):
451
+ # A script the consumer reviewed and trusts, reached only after
452
+ # ownership above.
453
+ return
454
+ if targets is None:
455
+ boundary.deny("mutation targets could not be resolved")
456
+ for target in targets:
457
+ # From inside an owned worktree the two allowances compose, so
458
+ # `touch <memory>/note.md` works while the same command with a second
459
+ # operand outside both still fails on that operand.
460
+ if not (boundary.is_session_target(target, root, primary) or in_memory(target)):
461
+ boundary.deny(denial(target, root, primary))
462
+
463
+
464
+ def run() -> None:
465
+ """``main``, with anything but its own verdict refusing.
466
+
467
+ ``main`` allows by returning and refuses with exit 2; an explicit exit 0
468
+ also allows. Any other exit, a bare ``sys.exit()`` included, refuses.
469
+ """
470
+
471
+ try:
472
+ main()
473
+ except SystemExit as error:
474
+ if is_exit(error, 0, 2):
475
+ raise
476
+ refuse(f"the ownership guard failed ({failure(error)}), so it cannot judge this call")
477
+ except BaseException as error:
478
+ refuse(f"the ownership guard failed ({failure(error)}), so it cannot judge this call")
479
+
480
+
481
+ if __name__ == "__main__":
482
+ refusal_log.guarded("worktree", run)