@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 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.5.6",
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
- # A loose pattern (`.*`) must not trust a whole line that hides another
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 (plain and entry.pattern.fullmatch(command)):
3256
- if opening is None:
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
 
@@ -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, environment variables an agent must leave
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
- return Config(trusted, variables, reason, paired)
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
- trusted = settings().trusted_commands
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 ()