@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 +29 -2
- package/package.json +1 -1
- package/python/kavach/worktree_guard.py +194 -1
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.
|
|
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
|
+
"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:
|