@veda-hq/kavach 0.5.6 → 0.6.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/README.md +12 -0
- package/package.json +1 -1
- package/python/kavach/boundary.py +76 -16
- package/python/kavach/config.py +47 -4
- package/python/kavach/worktree_guard.py +9 -1
package/README.md
CHANGED
|
@@ -60,6 +60,10 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
|
|
|
60
60
|
"worktree": {
|
|
61
61
|
"trustedCommands": [
|
|
62
62
|
{ "script": "scripts/worktree.py", "pattern": "python3 scripts/worktree\\.py (?:status|retire --pr [1-9][0-9]*)" }
|
|
63
|
+
],
|
|
64
|
+
"readOnlyCommands": [
|
|
65
|
+
{ "pattern": "pnpm (?:health:check|(?:ls|list|why)(?: [A-Za-z0-9@][A-Za-z0-9@/._-]*)*(?: --depth [0-9]+)?)",
|
|
66
|
+
"env": { "AGENT_REMOTE": ["true"] }, "reason": "the script and pnpm's listing commands only read" }
|
|
63
67
|
]
|
|
64
68
|
},
|
|
65
69
|
"command": {
|
|
@@ -77,6 +81,14 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
|
|
|
77
81
|
pattern must match the whole command; trust only command lines of plain
|
|
78
82
|
words (a whole line with any shell metacharacter is never trusted by its
|
|
79
83
|
pattern alone).
|
|
84
|
+
- `readOnlyCommands`: a command line you reviewed as only reading, though
|
|
85
|
+
the guard cannot prove it (a package runner's script, say). It runs with no
|
|
86
|
+
owned worktree, from a checkout's root only, and only while the hook's own
|
|
87
|
+
environment holds one of the listed values for every variable in `env`,
|
|
88
|
+
which is required. The pattern is matched as a trusted command's is. Keep
|
|
89
|
+
the list to commands that write nothing however they are called, and
|
|
90
|
+
name each option a pattern allows: a runner's own options after the
|
|
91
|
+
subcommand (`pnpm ls -C elsewhere`, `--pnpmfile`) move or widen what runs.
|
|
80
92
|
- `protectedVariables`: environment variables a command may not assign, declare or
|
|
81
93
|
export.
|
|
82
94
|
- `pairedChanges`: a commit that may record a path ending in `changes` must
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/kavach",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
@@ -16,7 +16,7 @@ import stat
|
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import Protocol, Sequence
|
|
19
|
+
from typing import Mapping, Protocol, Sequence
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
# Admission to this set means one thing: NO invocation of the command mutates
|
|
@@ -3246,22 +3246,10 @@ def trusted_request(
|
|
|
3246
3246
|
|
|
3247
3247
|
if not trusted:
|
|
3248
3248
|
return False
|
|
3249
|
-
|
|
3250
|
-
# command: the whole-line match holds only for plain words.
|
|
3251
|
-
plain = TRUSTED_LINE_UNSAFE.search(command) is None
|
|
3252
|
-
opening: tuple[str, list[list[str]]] | None = None
|
|
3253
|
-
rest_is_inert: bool | None = None
|
|
3249
|
+
line = ReviewedLine(command)
|
|
3254
3250
|
for entry in trusted:
|
|
3255
|
-
if not (
|
|
3256
|
-
|
|
3257
|
-
opening = trusted_opening(command)
|
|
3258
|
-
first, rest = opening
|
|
3259
|
-
if not first or not entry.pattern.fullmatch(first):
|
|
3260
|
-
continue
|
|
3261
|
-
if rest_is_inert is None:
|
|
3262
|
-
rest_is_inert = all(segment_is_read_only(words) for words in rest)
|
|
3263
|
-
if not rest_is_inert:
|
|
3264
|
-
continue
|
|
3251
|
+
if not line.matches(entry.pattern):
|
|
3252
|
+
continue
|
|
3265
3253
|
resolved = root.resolve()
|
|
3266
3254
|
script = resolved / entry.script
|
|
3267
3255
|
if cwd.resolve() == resolved and script.is_file() and script.resolve() == script:
|
|
@@ -3269,6 +3257,78 @@ def trusted_request(
|
|
|
3269
3257
|
return False
|
|
3270
3258
|
|
|
3271
3259
|
|
|
3260
|
+
class ReviewedLine:
|
|
3261
|
+
"""One command line, as a consumer's reviewed pattern is matched to it.
|
|
3262
|
+
|
|
3263
|
+
A whole line of plain words matches when the pattern matches all of it. A
|
|
3264
|
+
loose pattern (`.*`) must not admit a line that hides another command, so
|
|
3265
|
+
any other line matches only by its first segment, as plain words, followed
|
|
3266
|
+
by nothing but read-only segments (reviewed redirections included). The
|
|
3267
|
+
parse is done once, for the first pattern that needs it.
|
|
3268
|
+
"""
|
|
3269
|
+
|
|
3270
|
+
def __init__(self, command: str) -> None:
|
|
3271
|
+
self.command = command
|
|
3272
|
+
self.plain = TRUSTED_LINE_UNSAFE.search(command) is None
|
|
3273
|
+
self.opening: tuple[str, list[list[str]]] | None = None
|
|
3274
|
+
self.rest_is_inert: bool | None = None
|
|
3275
|
+
|
|
3276
|
+
def matches(self, pattern: re.Pattern[str]) -> bool:
|
|
3277
|
+
if self.plain and pattern.fullmatch(self.command):
|
|
3278
|
+
return True
|
|
3279
|
+
if self.opening is None:
|
|
3280
|
+
self.opening = trusted_opening(self.command)
|
|
3281
|
+
first, rest = self.opening
|
|
3282
|
+
if not first or not pattern.fullmatch(first):
|
|
3283
|
+
return False
|
|
3284
|
+
if self.rest_is_inert is None:
|
|
3285
|
+
self.rest_is_inert = all(segment_is_read_only(words) for words in rest)
|
|
3286
|
+
return self.rest_is_inert
|
|
3287
|
+
|
|
3288
|
+
|
|
3289
|
+
class ReadOnlyEntry(Protocol):
|
|
3290
|
+
"""One reviewed read-only command, as `config.ReadOnlyCommand` carries it:
|
|
3291
|
+
`pattern` must match the whole command line, and `env` pairs each
|
|
3292
|
+
variable with the values the hook's environment may hold for it."""
|
|
3293
|
+
|
|
3294
|
+
pattern: re.Pattern[str]
|
|
3295
|
+
env: tuple[tuple[str, tuple[str, ...]], ...]
|
|
3296
|
+
|
|
3297
|
+
|
|
3298
|
+
def read_only_request(
|
|
3299
|
+
command: str,
|
|
3300
|
+
cwd: Path,
|
|
3301
|
+
root: Path,
|
|
3302
|
+
entries: Sequence[ReadOnlyEntry],
|
|
3303
|
+
environ: Mapping[str, str],
|
|
3304
|
+
) -> bool:
|
|
3305
|
+
"""Recognize a command line the consumer reviewed as only reading.
|
|
3306
|
+
|
|
3307
|
+
Such a command reads although this parser cannot prove it: a package
|
|
3308
|
+
runner's script, say, whose writes are the consumer's to review, not
|
|
3309
|
+
kavach's. It needs no owned worktree, so the match is held as tight as a
|
|
3310
|
+
trusted command's (``ReviewedLine``), and two more conditions apply:
|
|
3311
|
+
|
|
3312
|
+
- It runs from the checkout's root (``root``), so a relative script or
|
|
3313
|
+
a package manifest means the root's own, never a subfolder's.
|
|
3314
|
+
- Every variable the entry names holds one of its listed values in
|
|
3315
|
+
``environ``, the hook's own environment, which the command line cannot
|
|
3316
|
+
set.
|
|
3317
|
+
|
|
3318
|
+
No entries: nothing is admitted here.
|
|
3319
|
+
"""
|
|
3320
|
+
|
|
3321
|
+
if not entries or cwd.resolve() != root.resolve():
|
|
3322
|
+
return False
|
|
3323
|
+
line = ReviewedLine(command)
|
|
3324
|
+
for entry in entries:
|
|
3325
|
+
if all(environ.get(name) in values for name, values in entry.env) and line.matches(
|
|
3326
|
+
entry.pattern
|
|
3327
|
+
):
|
|
3328
|
+
return True
|
|
3329
|
+
return False
|
|
3330
|
+
|
|
3331
|
+
|
|
3272
3332
|
def trusted_opening(command: str) -> tuple[str, list[list[str]]]:
|
|
3273
3333
|
"""The first segment of ``command`` as plain words, and the segments after.
|
|
3274
3334
|
|
package/python/kavach/config.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
the environment variable KAVACH_CONFIG.
|
|
3
3
|
|
|
4
4
|
kavach knows no product. What one product trusts or requires (a script its
|
|
5
|
-
agents may run from any checkout,
|
|
6
|
-
alone, a file whose change needs another in the same commit) comes from this
|
|
5
|
+
agents may run from any checkout, a command it reviewed as only reading and allows in one
|
|
6
|
+
environment, environment variables an agent must leave alone, a file whose change needs another in the same commit) comes from this
|
|
7
7
|
file, and with no file every such rule is off.
|
|
8
8
|
|
|
9
9
|
A file that cannot be read, is not JSON, has a key this module does not know,
|
|
@@ -38,6 +38,18 @@ class TrustedCommand:
|
|
|
38
38
|
pattern: re.Pattern[str]
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ReadOnlyCommand:
|
|
43
|
+
"""A command line the consumer reviewed as only reading, allowed from a
|
|
44
|
+
checkout's root without an owned worktree, and only where the hook's own
|
|
45
|
+
environment holds one of the listed values for every variable in `env`.
|
|
46
|
+
`pattern` must match the whole command; `reason` says what was checked."""
|
|
47
|
+
|
|
48
|
+
pattern: re.Pattern[str]
|
|
49
|
+
env: tuple[tuple[str, tuple[str, ...]], ...]
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
41
53
|
@dataclass(frozen=True)
|
|
42
54
|
class PairedChange:
|
|
43
55
|
"""A commit that may record a path ending in `changes` must also certainly
|
|
@@ -54,13 +66,14 @@ class Config:
|
|
|
54
66
|
protected_variables: tuple[str, ...] = ()
|
|
55
67
|
protected_variables_reason: str = ""
|
|
56
68
|
paired_changes: tuple[PairedChange, ...] = ()
|
|
69
|
+
read_only_commands: tuple[ReadOnlyCommand, ...] = ()
|
|
57
70
|
|
|
58
71
|
|
|
59
72
|
DEFAULT = Config()
|
|
60
73
|
|
|
61
74
|
SHAPE: dict[str, set[str]] = {
|
|
62
75
|
"": {"worktree", "command"},
|
|
63
|
-
"worktree": {"trustedCommands"},
|
|
76
|
+
"worktree": {"trustedCommands", "readOnlyCommands"},
|
|
64
77
|
"command": {"protectedVariables", "protectedVariablesReason", "pairedChanges"},
|
|
65
78
|
}
|
|
66
79
|
|
|
@@ -100,6 +113,32 @@ def _trusted(value: Any, index: int) -> TrustedCommand:
|
|
|
100
113
|
return TrustedCommand(script, pattern)
|
|
101
114
|
|
|
102
115
|
|
|
116
|
+
def _read_only(value: Any, index: int) -> ReadOnlyCommand:
|
|
117
|
+
where = f"worktree.readOnlyCommands[{index}]"
|
|
118
|
+
entry = _object(value, where, {"pattern", "env", "reason"})
|
|
119
|
+
source = _text(entry.get("pattern"), f"{where}.pattern")
|
|
120
|
+
try:
|
|
121
|
+
pattern = re.compile(source)
|
|
122
|
+
except re.error as error:
|
|
123
|
+
raise ConfigError(f"{where}.pattern is not a regular expression: {error}") from None
|
|
124
|
+
# The environment is required: an entry with no condition would widen the
|
|
125
|
+
# guard on every machine, which is what kavach's own read-only list is
|
|
126
|
+
# kept narrow to avoid.
|
|
127
|
+
raw_env = entry.get("env")
|
|
128
|
+
if not isinstance(raw_env, dict) or not raw_env:
|
|
129
|
+
raise ConfigError(f"{where}.env must be a non-empty JSON object of variable names")
|
|
130
|
+
env: list[tuple[str, tuple[str, ...]]] = []
|
|
131
|
+
for name, values in raw_env.items():
|
|
132
|
+
if not VARIABLE_NAME.fullmatch(name):
|
|
133
|
+
raise ConfigError(f"{where}.env has a key that is not a variable name: {name!r}")
|
|
134
|
+
listed = _list(values, f"{where}.env.{name}")
|
|
135
|
+
if not listed:
|
|
136
|
+
raise ConfigError(f"{where}.env.{name} must list at least one value")
|
|
137
|
+
env.append((name, tuple(_text(item, f"{where}.env.{name}[{at}]") for at, item in enumerate(listed))))
|
|
138
|
+
reason = _text(entry.get("reason"), f"{where}.reason")
|
|
139
|
+
return ReadOnlyCommand(pattern, tuple(env), reason)
|
|
140
|
+
|
|
141
|
+
|
|
103
142
|
def _paired(value: Any, index: int) -> PairedChange:
|
|
104
143
|
where = f"command.pairedChanges[{index}]"
|
|
105
144
|
entry = _object(value, where, {"changes", "requires", "reason"})
|
|
@@ -135,7 +174,11 @@ def parse(data: Any) -> Config:
|
|
|
135
174
|
_paired(entry, index)
|
|
136
175
|
for index, entry in enumerate(_list(command.get("pairedChanges", []), "command.pairedChanges"))
|
|
137
176
|
)
|
|
138
|
-
|
|
177
|
+
read_only = tuple(
|
|
178
|
+
_read_only(entry, index)
|
|
179
|
+
for index, entry in enumerate(_list(worktree.get("readOnlyCommands", []), "worktree.readOnlyCommands"))
|
|
180
|
+
)
|
|
181
|
+
return Config(trusted, variables, reason, paired, read_only)
|
|
139
182
|
|
|
140
183
|
|
|
141
184
|
def load(environ: Mapping[str, str] | None = None) -> Config:
|
|
@@ -594,7 +594,8 @@ def refuse_during_push(tool: object, targets: list[Path], root: Path) -> None:
|
|
|
594
594
|
def main() -> None:
|
|
595
595
|
# First, so a misread settings file refuses every event, a read-only one
|
|
596
596
|
# included.
|
|
597
|
-
|
|
597
|
+
loaded = settings()
|
|
598
|
+
trusted = loaded.trusted_commands
|
|
598
599
|
data = boundary.read_input()
|
|
599
600
|
cwd = Path(data.get("cwd") or Path.cwd()).resolve()
|
|
600
601
|
state = boundary.checkout_state(cwd)
|
|
@@ -616,6 +617,13 @@ def main() -> None:
|
|
|
616
617
|
command = boundary.tool_command(values)
|
|
617
618
|
if command and boundary.shell_is_read_only(command):
|
|
618
619
|
return
|
|
620
|
+
# A read the consumer reviewed (`worktree.readOnlyCommands`), from the
|
|
621
|
+
# checkout's root and only in the environment its entry names: like a
|
|
622
|
+
# proven read, it needs no owned worktree and waits for no push.
|
|
623
|
+
if command and boundary.read_only_request(
|
|
624
|
+
command, cwd, root, loaded.read_only_commands, os.environ
|
|
625
|
+
):
|
|
626
|
+
return
|
|
619
627
|
# Only the shell parser reads these (a file tool is never given them), and
|
|
620
628
|
# finding them costs git starts, so an Edit or Write does not pay for them.
|
|
621
629
|
read_namespaces = harness_state_roots(root, primary) if tool == "Bash" else ()
|