@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.
@@ -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