@veda-hq/kavach 0.3.0 → 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/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
 
@@ -86,6 +86,33 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
86
86
  Keep a CI check behind it: the guard reads one command line, not the files
87
87
  other programs change.
88
88
 
89
+ ## While a push runs
90
+
91
+ A push's checks read the worktree it was started from, and an edit made
92
+ while they run either fails the push at its end or leaves it checking what
93
+ was not pushed. So while a push runs, the worktree boundary refuses writes
94
+ into that worktree: any Bash command it cannot prove read-only, run there
95
+ (a trusted command included), and any Edit, Write or NotebookEdit of a path
96
+ git does not ignore. Reads still run, and the file tools can still write
97
+ files git ignores (drafts, scratch); a Bash command that writes one is still
98
+ refused. The refusal names the push's pid and start. Commands that only reach
99
+ the network, such as `gh pr comment`, wait too: the guard cannot tell what an
100
+ unproven command writes.
101
+
102
+ It knows a push is running from the marker the project's pre-push hook
103
+ writes. Write it when a real push starts and clear it on every exit:
104
+
105
+ ```sh
106
+ kavach-rule-checks push-marker write "$(git rev-parse --absolute-git-dir)" "$$"
107
+ trap 'kavach-rule-checks push-marker clear "$(git rev-parse --absolute-git-dir)" "$$"' EXIT
108
+ ```
109
+
110
+ Each push gets its own marker, `kavach-push-in-progress.<pid>.json` in the
111
+ worktree's own git folder, naming its pid and start. A marker whose pid has
112
+ ended or now belongs to a later process blocks nothing, so a killed push
113
+ leaves no lock behind, and a push in another worktree never blocks this one.
114
+ Without the marker, nothing here changes.
115
+
89
116
  ## Refusal log
90
117
 
91
118
  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.5.6",
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",
@@ -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,6 +402,195 @@ 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.
@@ -449,7 +639,9 @@ def main() -> None:
449
639
  boundary.tool_command(values), cwd, root, trusted
450
640
  ):
451
641
  # A script the consumer reviewed and trusts, reached only after
452
- # ownership above.
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)
453
645
  return
454
646
  if targets is None:
455
647
  boundary.deny("mutation targets could not be resolved")
@@ -459,6 +651,7 @@ def main() -> None:
459
651
  # operand outside both still fails on that operand.
460
652
  if not (boundary.is_session_target(target, root, primary) or in_memory(target)):
461
653
  boundary.deny(denial(target, root, primary))
654
+ refuse_during_push(tool, targets, root)
462
655
 
463
656
 
464
657
  def run() -> None: