@veda-hq/kavach 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +107 -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 +482 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The command guard: a Claude Code PreToolUse hook for Bash tool calls.
|
|
2
|
+
|
|
3
|
+
Run as ``python3 <kavach>/python/kavach/command_guard.py`` with one PreToolUse
|
|
4
|
+
event on stdin. Exit 0 allows the command; exit 2 refuses it, and stderr
|
|
5
|
+
(``BLOCKED: ...``) is fed back to the agent. Any other end refuses too.
|
|
6
|
+
|
|
7
|
+
It blocks git-hook bypasses, history-destroying pushes and what the
|
|
8
|
+
consumer's settings (config.py, from the file KAVACH_CONFIG names) add: a
|
|
9
|
+
command that sets a protected variable, and a commit that records one path
|
|
10
|
+
of a paired change without the other. Each is read from the repository the
|
|
11
|
+
command actually targets.
|
|
12
|
+
|
|
13
|
+
Two checks, and the command runs only when both allow it: bash_guard.py
|
|
14
|
+
first, then git_safety.py, which reads the code the first cannot (below)
|
|
15
|
+
and runs against the adversarial corpus in ``corpus/``, every row of which
|
|
16
|
+
is also executed in real bash and git.
|
|
17
|
+
|
|
18
|
+
Limits of bash_guard.py, accepted: it is a static reading of one command
|
|
19
|
+
line, not a shell. It cannot see code it would have to run: ``eval``,
|
|
20
|
+
``bash -c '...'``, functions and aliases, ``$(...)`` that computes a path,
|
|
21
|
+
``env -C``, or shell state set by an earlier Bash call (an exported GIT_DIR,
|
|
22
|
+
a cd, ``set -P``) beyond the hook input's cwd. git_safety.py reads eval,
|
|
23
|
+
``bash -c``, aliases, ``$(...)``, backticks, heredocs and pipes into a shell
|
|
24
|
+
for hook bypasses, forced pushes and paired-change commits. Within one line
|
|
25
|
+
bash_guard.py models git's own index writers (add, reset, an earlier
|
|
26
|
+
commit…), but not files changed by other programs (rm, cp, ``>``
|
|
27
|
+
redirection). Where a path is shell-computed it fails closed; where the
|
|
28
|
+
command hides git inside another program it cannot. So the consumer should
|
|
29
|
+
keep its own CI check behind the paired-change rule: one that fails any pull
|
|
30
|
+
request whose ``requires`` side does not match its ``changes`` side. Heredoc
|
|
31
|
+
bodies and quoted strings are stripped before the text checks, so flags are
|
|
32
|
+
only caught as real arguments, not inside commit messages or echoed text.
|
|
33
|
+
|
|
34
|
+
Both checks are imported, not fed on stdin, so Python keeps their compiled
|
|
35
|
+
cache: code on stdin recompiles on every call. sys.path[0] is this file's
|
|
36
|
+
directory instead of the cwd, so no file in the command's directory can
|
|
37
|
+
stand in for a module of the guard or of the standard library (``os`` and
|
|
38
|
+
``sys`` are loaded before this file runs). Any failure blocks, a module
|
|
39
|
+
missing or any error raised in it: Claude Code lets a hook's exit 1
|
|
40
|
+
through, so a crash would wave the command on. Still open: an interpreter
|
|
41
|
+
that cannot start, or one killed by a signal. Only a check's integer 0 or 2
|
|
42
|
+
is a verdict.
|
|
43
|
+
|
|
44
|
+
The hook's stdin is read here, byte for byte, never passed on through a
|
|
45
|
+
child's environment: Linux caps one environment string at 128 KiB and macOS
|
|
46
|
+
all of exec's strings at about 1 MiB, and over the cap the exec fails, which
|
|
47
|
+
Claude Code lets through. Both checks read HOOK_INPUT from os.environ, so
|
|
48
|
+
the entry sets it there, decoded as the environment decodes it so a payload
|
|
49
|
+
that is not UTF-8 still arrives. A failed read leaves the input unknown,
|
|
50
|
+
however much of it arrived, so it blocks. Settings that cannot be read
|
|
51
|
+
(config.ConfigError) block as well: a guard that misread its settings must
|
|
52
|
+
not allow.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import os
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
HERE = os.path.dirname(os.path.realpath(__file__))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def isolate():
|
|
62
|
+
"""Put this file's directory first on sys.path, where Python put the
|
|
63
|
+
script's directory (run as a file) or the cwd (``-c``, ``-m``). Under
|
|
64
|
+
``-P`` or ``-I`` Python put neither there, so the directory goes in front."""
|
|
65
|
+
if sys.flags.safe_path:
|
|
66
|
+
sys.path.insert(0, HERE)
|
|
67
|
+
else:
|
|
68
|
+
sys.path[0] = HERE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def verdict(check, run, missing, module):
|
|
72
|
+
"""0 or 2 from one check. Any other end of it blocks and says why."""
|
|
73
|
+
try:
|
|
74
|
+
run()
|
|
75
|
+
except SystemExit as end:
|
|
76
|
+
# Only its two verdicts pass: the integers 0 (allow) and 2 (block). A bare
|
|
77
|
+
# sys.exit() (None) gives neither, and Python exits 1 for 2.0.
|
|
78
|
+
if type(end.code) is int and end.code in (0, 2):
|
|
79
|
+
return end.code
|
|
80
|
+
reason = "exited with %r instead of a verdict" % (end.code,)
|
|
81
|
+
except BaseException as error:
|
|
82
|
+
if isinstance(error, (ModuleNotFoundError, FileNotFoundError)) and (
|
|
83
|
+
getattr(error, "name", None) == module or getattr(error, "filename", None) == module
|
|
84
|
+
):
|
|
85
|
+
reason = "could not load " + missing
|
|
86
|
+
else:
|
|
87
|
+
reason = "failed (%s: %s)" % (type(error).__name__, error)
|
|
88
|
+
else:
|
|
89
|
+
reason = "finished without a verdict"
|
|
90
|
+
sys.stderr.write("BLOCKED: %s %s, so it cannot judge this command.\n" % (check, reason))
|
|
91
|
+
return 2
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def settings():
|
|
95
|
+
"""The consumer's config.Config, or None once it has said why it blocks."""
|
|
96
|
+
try:
|
|
97
|
+
import config
|
|
98
|
+
except BaseException as error:
|
|
99
|
+
if isinstance(error, ModuleNotFoundError) and error.name == "config":
|
|
100
|
+
reason = "could not load config.py"
|
|
101
|
+
else:
|
|
102
|
+
reason = "could not load its settings (%s: %s)" % (type(error).__name__, error)
|
|
103
|
+
sys.stderr.write("BLOCKED: the command guard %s, so it cannot judge this command.\n" % reason)
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return config.load()
|
|
107
|
+
except config.ConfigError as error:
|
|
108
|
+
reason = "has unusable settings (%s)" % (error,)
|
|
109
|
+
except BaseException as error:
|
|
110
|
+
reason = "could not load its settings (%s: %s)" % (type(error).__name__, error)
|
|
111
|
+
sys.stderr.write("BLOCKED: the command guard %s, so it cannot judge this command.\n" % reason)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cannot_name_git():
|
|
116
|
+
"""A command git_safety.py has nothing to read: no "git" in any case,
|
|
117
|
+
nothing that could spell it (quoting, an escape, an expansion, a glob or
|
|
118
|
+
a brace), and nothing that could feed a shell a program another command
|
|
119
|
+
wrote (a pipe, a redirection, a process substitution): `echo fhs otrg
|
|
120
|
+
--enqbd | tr a-z b-z | bash` runs `git push --force`. The fast exit in
|
|
121
|
+
bash_guard.py, narrowed."""
|
|
122
|
+
import json, re
|
|
123
|
+
try:
|
|
124
|
+
command = json.loads(os.environ["HOOK_INPUT"])["tool_input"]["command"]
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
return isinstance(command, str) and not re.search(r"(?i)git|[\\\x27\"$`*?\[{|<>(]", command)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
isolate()
|
|
132
|
+
try:
|
|
133
|
+
os.environ["HOOK_INPUT"] = os.fsdecode(sys.stdin.buffer.read())
|
|
134
|
+
except BaseException as error:
|
|
135
|
+
sys.stderr.write(
|
|
136
|
+
"BLOCKED: the git-command check could not read its hook input (%s), so it cannot judge this command.\n"
|
|
137
|
+
% type(error).__name__
|
|
138
|
+
)
|
|
139
|
+
return 2
|
|
140
|
+
loaded = settings()
|
|
141
|
+
if loaded is None:
|
|
142
|
+
return 2
|
|
143
|
+
if verdict("the git-command check", lambda: __import__("bash_guard"), "bash_guard.py", "bash_guard") == 2:
|
|
144
|
+
return 2
|
|
145
|
+
if cannot_name_git():
|
|
146
|
+
return 0
|
|
147
|
+
return verdict(
|
|
148
|
+
"the shell-code check",
|
|
149
|
+
lambda: __import__("git_safety").hook_main(loaded),
|
|
150
|
+
"git_safety.py",
|
|
151
|
+
"git_safety",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run():
|
|
156
|
+
"""``main``, with each refusal written to the refusal log (refusal_log.py)."""
|
|
157
|
+
isolate()
|
|
158
|
+
try:
|
|
159
|
+
import refusal_log
|
|
160
|
+
except BaseException as error:
|
|
161
|
+
sys.stderr.write(
|
|
162
|
+
"BLOCKED: the command guard could not load refusal_log.py (%s), so it cannot judge this command.\n"
|
|
163
|
+
% type(error).__name__
|
|
164
|
+
)
|
|
165
|
+
return 2
|
|
166
|
+
return refusal_log.guarded("command", main, lambda: os.environ.get("HOOK_INPUT", ""))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
sys.exit(run())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The consumer's settings for the guards, read from the JSON file named by
|
|
2
|
+
the environment variable KAVACH_CONFIG.
|
|
3
|
+
|
|
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
|
|
7
|
+
file, and with no file every such rule is off.
|
|
8
|
+
|
|
9
|
+
A file that cannot be read, is not JSON, has a key this module does not know,
|
|
10
|
+
or a value of the wrong shape raises ConfigError. Each guard's entry point
|
|
11
|
+
turns that into a refusal: a guard that misread its settings must not allow.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, Mapping
|
|
21
|
+
|
|
22
|
+
ENV_VAR = "KAVACH_CONFIG"
|
|
23
|
+
# A variable name as the shell accepts it in an assignment.
|
|
24
|
+
VARIABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(Exception):
|
|
28
|
+
"""The settings file is unreadable or not in the shape below."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TrustedCommand:
|
|
33
|
+
"""A script in the consumer's repository that an agent may run from any
|
|
34
|
+
checkout, and the exact command lines that run it. `script` is a path
|
|
35
|
+
relative to the repository root; `pattern` must match the whole command."""
|
|
36
|
+
|
|
37
|
+
script: str
|
|
38
|
+
pattern: re.Pattern[str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PairedChange:
|
|
43
|
+
"""A commit that may record a path ending in `changes` must also certainly
|
|
44
|
+
record a path containing `requires`."""
|
|
45
|
+
|
|
46
|
+
changes: str
|
|
47
|
+
requires: str
|
|
48
|
+
reason: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Config:
|
|
53
|
+
trusted_commands: tuple[TrustedCommand, ...] = ()
|
|
54
|
+
protected_variables: tuple[str, ...] = ()
|
|
55
|
+
protected_variables_reason: str = ""
|
|
56
|
+
paired_changes: tuple[PairedChange, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
DEFAULT = Config()
|
|
60
|
+
|
|
61
|
+
SHAPE: dict[str, set[str]] = {
|
|
62
|
+
"": {"worktree", "command"},
|
|
63
|
+
"worktree": {"trustedCommands"},
|
|
64
|
+
"command": {"protectedVariables", "protectedVariablesReason", "pairedChanges"},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _object(value: Any, where: str, allowed: set[str]) -> dict[str, Any]:
|
|
69
|
+
if not isinstance(value, dict):
|
|
70
|
+
raise ConfigError(f"{where or 'the settings'} must be a JSON object")
|
|
71
|
+
unknown = sorted(set(value) - allowed)
|
|
72
|
+
if unknown:
|
|
73
|
+
raise ConfigError(f"{where or 'the settings'} has unknown key(s): {', '.join(unknown)}")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _text(value: Any, where: str) -> str:
|
|
78
|
+
if not isinstance(value, str) or not value:
|
|
79
|
+
raise ConfigError(f"{where} must be a non-empty string")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _list(value: Any, where: str) -> list[Any]:
|
|
84
|
+
if not isinstance(value, list):
|
|
85
|
+
raise ConfigError(f"{where} must be a JSON array")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _trusted(value: Any, index: int) -> TrustedCommand:
|
|
90
|
+
where = f"worktree.trustedCommands[{index}]"
|
|
91
|
+
entry = _object(value, where, {"script", "pattern"})
|
|
92
|
+
script = _text(entry.get("script"), f"{where}.script")
|
|
93
|
+
if script.startswith("/") or ".." in script.split("/"):
|
|
94
|
+
raise ConfigError(f"{where}.script must be a path inside the repository")
|
|
95
|
+
source = _text(entry.get("pattern"), f"{where}.pattern")
|
|
96
|
+
try:
|
|
97
|
+
pattern = re.compile(source)
|
|
98
|
+
except re.error as error:
|
|
99
|
+
raise ConfigError(f"{where}.pattern is not a regular expression: {error}") from None
|
|
100
|
+
return TrustedCommand(script, pattern)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _paired(value: Any, index: int) -> PairedChange:
|
|
104
|
+
where = f"command.pairedChanges[{index}]"
|
|
105
|
+
entry = _object(value, where, {"changes", "requires", "reason"})
|
|
106
|
+
return PairedChange(
|
|
107
|
+
_text(entry.get("changes"), f"{where}.changes"),
|
|
108
|
+
_text(entry.get("requires"), f"{where}.requires"),
|
|
109
|
+
_text(entry.get("reason"), f"{where}.reason"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse(data: Any) -> Config:
|
|
114
|
+
"""The Config a decoded JSON document describes."""
|
|
115
|
+
top = _object(data, "", SHAPE[""])
|
|
116
|
+
worktree = _object(top.get("worktree", {}), "worktree", SHAPE["worktree"])
|
|
117
|
+
command = _object(top.get("command", {}), "command", SHAPE["command"])
|
|
118
|
+
trusted = tuple(
|
|
119
|
+
_trusted(entry, index)
|
|
120
|
+
for index, entry in enumerate(_list(worktree.get("trustedCommands", []), "worktree.trustedCommands"))
|
|
121
|
+
)
|
|
122
|
+
variables = tuple(
|
|
123
|
+
_text(name, f"command.protectedVariables[{index}]")
|
|
124
|
+
for index, name in enumerate(_list(command.get("protectedVariables", []), "command.protectedVariables"))
|
|
125
|
+
)
|
|
126
|
+
for index, name in enumerate(variables):
|
|
127
|
+
if not VARIABLE_NAME.fullmatch(name):
|
|
128
|
+
raise ConfigError(f"command.protectedVariables[{index}] is not a variable name: {name!r}")
|
|
129
|
+
reason = command.get("protectedVariablesReason", "")
|
|
130
|
+
if variables:
|
|
131
|
+
reason = _text(reason, "command.protectedVariablesReason")
|
|
132
|
+
elif reason != "":
|
|
133
|
+
raise ConfigError("command.protectedVariablesReason needs command.protectedVariables")
|
|
134
|
+
paired = tuple(
|
|
135
|
+
_paired(entry, index)
|
|
136
|
+
for index, entry in enumerate(_list(command.get("pairedChanges", []), "command.pairedChanges"))
|
|
137
|
+
)
|
|
138
|
+
return Config(trusted, variables, reason, paired)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load(environ: Mapping[str, str] | None = None) -> Config:
|
|
142
|
+
"""The settings named by KAVACH_CONFIG, or DEFAULT when it is unset or empty."""
|
|
143
|
+
env = os.environ if environ is None else environ
|
|
144
|
+
path = env.get(ENV_VAR, "")
|
|
145
|
+
if not path:
|
|
146
|
+
return DEFAULT
|
|
147
|
+
try:
|
|
148
|
+
with open(path, encoding="utf-8") as handle:
|
|
149
|
+
text = handle.read()
|
|
150
|
+
except OSError as error:
|
|
151
|
+
raise ConfigError(f"{ENV_VAR} names {path!r}, which cannot be read ({type(error).__name__})") from None
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(text)
|
|
154
|
+
except json.JSONDecodeError as error:
|
|
155
|
+
raise ConfigError(f"{path} is not JSON: {error}") from None
|
|
156
|
+
return parse(data)
|