@veda-hq/kavach 0.3.0 → 0.6.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.
package/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  कवच, armour. Guards for coding agents: each tool call an agent makes is
4
4
  judged before it runs. Part of [Veda](https://github.com/veda-hq/veda).
5
5
 
6
- ## v0.3: three guards and a rule-check engine
6
+ ## v0.5: three guards and a rule-check engine
7
7
 
8
8
  | Guard | Refuses | Entry point |
9
9
  | --- | --- | --- |
10
- | Worktree boundary | Any write outside the agent's own git worktree: a Bash command, an Edit or a Write. Read-only commands pass from any checkout. | `python3 python/kavach/worktree_guard.py` |
10
+ | Worktree boundary | Any write outside the agent's own git worktree: a Bash command, an Edit or a Write; and, while a push runs, any write into it (below). Read-only commands pass from any checkout. | `python3 python/kavach/worktree_guard.py` |
11
11
  | Command guard | Git-hook bypasses (`--no-verify`, `core.hooksPath`…), history-destroying pushes, and the project's own command rules (settings below). | `python3 python/kavach/command_guard.py` |
12
12
  | Rule checks | The project's own checks, written in TypeScript, refusing or warning with the rule's reason. | `kavach-rule-checks` (`dist/rule-checks/cli.js`) |
13
13
 
@@ -60,6 +60,10 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
60
60
  "worktree": {
61
61
  "trustedCommands": [
62
62
  { "script": "scripts/worktree.py", "pattern": "python3 scripts/worktree\\.py (?:status|retire --pr [1-9][0-9]*)" }
63
+ ],
64
+ "readOnlyCommands": [
65
+ { "pattern": "pnpm (?:health:check|(?:ls|list|why)(?: [A-Za-z0-9@][A-Za-z0-9@/._-]*)*(?: --depth [0-9]+)?)",
66
+ "env": { "AGENT_REMOTE": ["true"] }, "reason": "the script and pnpm's listing commands only read" }
63
67
  ]
64
68
  },
65
69
  "command": {
@@ -77,6 +81,14 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
77
81
  pattern must match the whole command; trust only command lines of plain
78
82
  words (a whole line with any shell metacharacter is never trusted by its
79
83
  pattern alone).
84
+ - `readOnlyCommands`: a command line you reviewed as only reading, though
85
+ the guard cannot prove it (a package runner's script, say). It runs with no
86
+ owned worktree, from a checkout's root only, and only while the hook's own
87
+ environment holds one of the listed values for every variable in `env`,
88
+ which is required. The pattern is matched as a trusted command's is. Keep
89
+ the list to commands that write nothing however they are called, and
90
+ name each option a pattern allows: a runner's own options after the
91
+ subcommand (`pnpm ls -C elsewhere`, `--pnpmfile`) move or widen what runs.
80
92
  - `protectedVariables`: environment variables a command may not assign, declare or
81
93
  export.
82
94
  - `pairedChanges`: a commit that may record a path ending in `changes` must
@@ -86,6 +98,33 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
86
98
  Keep a CI check behind it: the guard reads one command line, not the files
87
99
  other programs change.
88
100
 
101
+ ## While a push runs
102
+
103
+ A push's checks read the worktree it was started from, and an edit made
104
+ while they run either fails the push at its end or leaves it checking what
105
+ was not pushed. So while a push runs, the worktree boundary refuses writes
106
+ into that worktree: any Bash command it cannot prove read-only, run there
107
+ (a trusted command included), and any Edit, Write or NotebookEdit of a path
108
+ git does not ignore. Reads still run, and the file tools can still write
109
+ files git ignores (drafts, scratch); a Bash command that writes one is still
110
+ refused. The refusal names the push's pid and start. Commands that only reach
111
+ the network, such as `gh pr comment`, wait too: the guard cannot tell what an
112
+ unproven command writes.
113
+
114
+ It knows a push is running from the marker the project's pre-push hook
115
+ writes. Write it when a real push starts and clear it on every exit:
116
+
117
+ ```sh
118
+ kavach-rule-checks push-marker write "$(git rev-parse --absolute-git-dir)" "$$"
119
+ trap 'kavach-rule-checks push-marker clear "$(git rev-parse --absolute-git-dir)" "$$"' EXIT
120
+ ```
121
+
122
+ Each push gets its own marker, `kavach-push-in-progress.<pid>.json` in the
123
+ worktree's own git folder, naming its pid and start. A marker whose pid has
124
+ ended or now belongs to a later process blocks nothing, so a killed push
125
+ leaves no lock behind, and a push in another worktree never blocks this one.
126
+ Without the marker, nothing here changes.
127
+
89
128
  ## Refusal log
90
129
 
91
130
  Each refusal of the two Python guards appends a row (time, guard, tool, first
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veda-hq/kavach",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "Guards for coding agents: refuses writes outside the agent's worktree, git-hook bypasses and history-destroying pushes, and runs a project's own rule checks on each tool call. Read-only commands pass.",
5
5
  "keywords": [
6
6
  "coding agents",
@@ -16,7 +16,7 @@ import stat
16
16
  import subprocess
17
17
  import sys
18
18
  from pathlib import Path
19
- from typing import Protocol, Sequence
19
+ from typing import Mapping, Protocol, Sequence
20
20
 
21
21
 
22
22
  # Admission to this set means one thing: NO invocation of the command mutates
@@ -3246,22 +3246,10 @@ def trusted_request(
3246
3246
 
3247
3247
  if not trusted:
3248
3248
  return False
3249
- # A loose pattern (`.*`) must not trust a whole line that hides another
3250
- # command: the whole-line match holds only for plain words.
3251
- plain = TRUSTED_LINE_UNSAFE.search(command) is None
3252
- opening: tuple[str, list[list[str]]] | None = None
3253
- rest_is_inert: bool | None = None
3249
+ line = ReviewedLine(command)
3254
3250
  for entry in trusted:
3255
- if not (plain and entry.pattern.fullmatch(command)):
3256
- if opening is None:
3257
- opening = trusted_opening(command)
3258
- first, rest = opening
3259
- if not first or not entry.pattern.fullmatch(first):
3260
- continue
3261
- if rest_is_inert is None:
3262
- rest_is_inert = all(segment_is_read_only(words) for words in rest)
3263
- if not rest_is_inert:
3264
- continue
3251
+ if not line.matches(entry.pattern):
3252
+ continue
3265
3253
  resolved = root.resolve()
3266
3254
  script = resolved / entry.script
3267
3255
  if cwd.resolve() == resolved and script.is_file() and script.resolve() == script:
@@ -3269,6 +3257,78 @@ def trusted_request(
3269
3257
  return False
3270
3258
 
3271
3259
 
3260
+ class ReviewedLine:
3261
+ """One command line, as a consumer's reviewed pattern is matched to it.
3262
+
3263
+ A whole line of plain words matches when the pattern matches all of it. A
3264
+ loose pattern (`.*`) must not admit a line that hides another command, so
3265
+ any other line matches only by its first segment, as plain words, followed
3266
+ by nothing but read-only segments (reviewed redirections included). The
3267
+ parse is done once, for the first pattern that needs it.
3268
+ """
3269
+
3270
+ def __init__(self, command: str) -> None:
3271
+ self.command = command
3272
+ self.plain = TRUSTED_LINE_UNSAFE.search(command) is None
3273
+ self.opening: tuple[str, list[list[str]]] | None = None
3274
+ self.rest_is_inert: bool | None = None
3275
+
3276
+ def matches(self, pattern: re.Pattern[str]) -> bool:
3277
+ if self.plain and pattern.fullmatch(self.command):
3278
+ return True
3279
+ if self.opening is None:
3280
+ self.opening = trusted_opening(self.command)
3281
+ first, rest = self.opening
3282
+ if not first or not pattern.fullmatch(first):
3283
+ return False
3284
+ if self.rest_is_inert is None:
3285
+ self.rest_is_inert = all(segment_is_read_only(words) for words in rest)
3286
+ return self.rest_is_inert
3287
+
3288
+
3289
+ class ReadOnlyEntry(Protocol):
3290
+ """One reviewed read-only command, as `config.ReadOnlyCommand` carries it:
3291
+ `pattern` must match the whole command line, and `env` pairs each
3292
+ variable with the values the hook's environment may hold for it."""
3293
+
3294
+ pattern: re.Pattern[str]
3295
+ env: tuple[tuple[str, tuple[str, ...]], ...]
3296
+
3297
+
3298
+ def read_only_request(
3299
+ command: str,
3300
+ cwd: Path,
3301
+ root: Path,
3302
+ entries: Sequence[ReadOnlyEntry],
3303
+ environ: Mapping[str, str],
3304
+ ) -> bool:
3305
+ """Recognize a command line the consumer reviewed as only reading.
3306
+
3307
+ Such a command reads although this parser cannot prove it: a package
3308
+ runner's script, say, whose writes are the consumer's to review, not
3309
+ kavach's. It needs no owned worktree, so the match is held as tight as a
3310
+ trusted command's (``ReviewedLine``), and two more conditions apply:
3311
+
3312
+ - It runs from the checkout's root (``root``), so a relative script or
3313
+ a package manifest means the root's own, never a subfolder's.
3314
+ - Every variable the entry names holds one of its listed values in
3315
+ ``environ``, the hook's own environment, which the command line cannot
3316
+ set.
3317
+
3318
+ No entries: nothing is admitted here.
3319
+ """
3320
+
3321
+ if not entries or cwd.resolve() != root.resolve():
3322
+ return False
3323
+ line = ReviewedLine(command)
3324
+ for entry in entries:
3325
+ if all(environ.get(name) in values for name, values in entry.env) and line.matches(
3326
+ entry.pattern
3327
+ ):
3328
+ return True
3329
+ return False
3330
+
3331
+
3272
3332
  def trusted_opening(command: str) -> tuple[str, list[list[str]]]:
3273
3333
  """The first segment of ``command`` as plain words, and the segments after.
3274
3334
 
@@ -2,8 +2,8 @@
2
2
  the environment variable KAVACH_CONFIG.
3
3
 
4
4
  kavach knows no product. What one product trusts or requires (a script its
5
- agents may run from any checkout, environment variables an agent must leave
6
- alone, a file whose change needs another in the same commit) comes from this
5
+ agents may run from any checkout, a command it reviewed as only reading and allows in one
6
+ environment, environment variables an agent must leave alone, a file whose change needs another in the same commit) comes from this
7
7
  file, and with no file every such rule is off.
8
8
 
9
9
  A file that cannot be read, is not JSON, has a key this module does not know,
@@ -38,6 +38,18 @@ class TrustedCommand:
38
38
  pattern: re.Pattern[str]
39
39
 
40
40
 
41
+ @dataclass(frozen=True)
42
+ class ReadOnlyCommand:
43
+ """A command line the consumer reviewed as only reading, allowed from a
44
+ checkout's root without an owned worktree, and only where the hook's own
45
+ environment holds one of the listed values for every variable in `env`.
46
+ `pattern` must match the whole command; `reason` says what was checked."""
47
+
48
+ pattern: re.Pattern[str]
49
+ env: tuple[tuple[str, tuple[str, ...]], ...]
50
+ reason: str
51
+
52
+
41
53
  @dataclass(frozen=True)
42
54
  class PairedChange:
43
55
  """A commit that may record a path ending in `changes` must also certainly
@@ -54,13 +66,14 @@ class Config:
54
66
  protected_variables: tuple[str, ...] = ()
55
67
  protected_variables_reason: str = ""
56
68
  paired_changes: tuple[PairedChange, ...] = ()
69
+ read_only_commands: tuple[ReadOnlyCommand, ...] = ()
57
70
 
58
71
 
59
72
  DEFAULT = Config()
60
73
 
61
74
  SHAPE: dict[str, set[str]] = {
62
75
  "": {"worktree", "command"},
63
- "worktree": {"trustedCommands"},
76
+ "worktree": {"trustedCommands", "readOnlyCommands"},
64
77
  "command": {"protectedVariables", "protectedVariablesReason", "pairedChanges"},
65
78
  }
66
79
 
@@ -100,6 +113,32 @@ def _trusted(value: Any, index: int) -> TrustedCommand:
100
113
  return TrustedCommand(script, pattern)
101
114
 
102
115
 
116
+ def _read_only(value: Any, index: int) -> ReadOnlyCommand:
117
+ where = f"worktree.readOnlyCommands[{index}]"
118
+ entry = _object(value, where, {"pattern", "env", "reason"})
119
+ source = _text(entry.get("pattern"), f"{where}.pattern")
120
+ try:
121
+ pattern = re.compile(source)
122
+ except re.error as error:
123
+ raise ConfigError(f"{where}.pattern is not a regular expression: {error}") from None
124
+ # The environment is required: an entry with no condition would widen the
125
+ # guard on every machine, which is what kavach's own read-only list is
126
+ # kept narrow to avoid.
127
+ raw_env = entry.get("env")
128
+ if not isinstance(raw_env, dict) or not raw_env:
129
+ raise ConfigError(f"{where}.env must be a non-empty JSON object of variable names")
130
+ env: list[tuple[str, tuple[str, ...]]] = []
131
+ for name, values in raw_env.items():
132
+ if not VARIABLE_NAME.fullmatch(name):
133
+ raise ConfigError(f"{where}.env has a key that is not a variable name: {name!r}")
134
+ listed = _list(values, f"{where}.env.{name}")
135
+ if not listed:
136
+ raise ConfigError(f"{where}.env.{name} must list at least one value")
137
+ env.append((name, tuple(_text(item, f"{where}.env.{name}[{at}]") for at, item in enumerate(listed))))
138
+ reason = _text(entry.get("reason"), f"{where}.reason")
139
+ return ReadOnlyCommand(pattern, tuple(env), reason)
140
+
141
+
103
142
  def _paired(value: Any, index: int) -> PairedChange:
104
143
  where = f"command.pairedChanges[{index}]"
105
144
  entry = _object(value, where, {"changes", "requires", "reason"})
@@ -135,7 +174,11 @@ def parse(data: Any) -> Config:
135
174
  _paired(entry, index)
136
175
  for index, entry in enumerate(_list(command.get("pairedChanges", []), "command.pairedChanges"))
137
176
  )
138
- return Config(trusted, variables, reason, paired)
177
+ read_only = tuple(
178
+ _read_only(entry, index)
179
+ for index, entry in enumerate(_list(worktree.get("readOnlyCommands", []), "worktree.readOnlyCommands"))
180
+ )
181
+ return Config(trusted, variables, reason, paired, read_only)
139
182
 
140
183
 
141
184
  def load(environ: Mapping[str, str] | None = None) -> Config:
@@ -22,6 +22,7 @@ import importlib.util
22
22
  import json
23
23
  import os
24
24
  import re
25
+ import subprocess
25
26
  import sys
26
27
  from pathlib import Path
27
28
  from types import ModuleType
@@ -401,10 +402,200 @@ def denial(target: Path, root: Path, primary: Path) -> str:
401
402
  return message
402
403
 
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
+
404
594
  def main() -> None:
405
595
  # First, so a misread settings file refuses every event, a read-only one
406
596
  # included.
407
- trusted = settings().trusted_commands
597
+ loaded = settings()
598
+ trusted = loaded.trusted_commands
408
599
  data = boundary.read_input()
409
600
  cwd = Path(data.get("cwd") or Path.cwd()).resolve()
410
601
  state = boundary.checkout_state(cwd)
@@ -426,6 +617,13 @@ def main() -> None:
426
617
  command = boundary.tool_command(values)
427
618
  if command and boundary.shell_is_read_only(command):
428
619
  return
620
+ # A read the consumer reviewed (`worktree.readOnlyCommands`), from the
621
+ # checkout's root and only in the environment its entry names: like a
622
+ # proven read, it needs no owned worktree and waits for no push.
623
+ if command and boundary.read_only_request(
624
+ command, cwd, root, loaded.read_only_commands, os.environ
625
+ ):
626
+ return
429
627
  # Only the shell parser reads these (a file tool is never given them), and
430
628
  # finding them costs git starts, so an Edit or Write does not pay for them.
431
629
  read_namespaces = harness_state_roots(root, primary) if tool == "Bash" else ()
@@ -449,7 +647,9 @@ def main() -> None:
449
647
  boundary.tool_command(values), cwd, root, trusted
450
648
  ):
451
649
  # A script the consumer reviewed and trusts, reached only after
452
- # ownership above.
650
+ # ownership above. What it writes is unproven, so it counts as
651
+ # writing in the checkout while a push runs.
652
+ refuse_during_push(tool, [root], root)
453
653
  return
454
654
  if targets is None:
455
655
  boundary.deny("mutation targets could not be resolved")
@@ -459,6 +659,7 @@ def main() -> None:
459
659
  # operand outside both still fails on that operand.
460
660
  if not (boundary.is_session_target(target, root, primary) or in_memory(target)):
461
661
  boundary.deny(denial(target, root, primary))
662
+ refuse_during_push(tool, targets, root)
462
663
 
463
664
 
464
665
  def run() -> None: