@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.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +675 -0
|
@@ -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
|