@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,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)