@veda-hq/kavach 0.5.6 → 0.6.4

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
@@ -8,7 +8,7 @@ judged before it runs. Part of [Veda](https://github.com/veda-hq/veda).
8
8
  | Guard | Refuses | Entry point |
9
9
  | --- | --- | --- |
10
10
  | Worktree boundary | Any write outside the agent's own git worktree: a Bash command, an Edit or a Write; and, while a push runs, any write into it (below). Read-only commands pass from any checkout. | `python3 python/kavach/worktree_guard.py` |
11
- | Command guard | Git-hook bypasses (`--no-verify`, `core.hooksPath`…), history-destroying pushes, and the project's own command rules (settings below). | `python3 python/kavach/command_guard.py` |
11
+ | Command guard | Git-hook bypasses (`--no-verify`, `core.hooksPath`…), history-destroying pushes, and the project's own command rules (settings below). On trial, it warns of a message the shell would change (below). | `python3 python/kavach/command_guard.py` |
12
12
  | Rule checks | The project's own checks, written in TypeScript, refusing or warning with the rule's reason. | `kavach-rule-checks` (`dist/rule-checks/cli.js`) |
13
13
 
14
14
  The two Python guards read one Claude Code `PreToolUse` event on stdin. Exit
@@ -50,6 +50,20 @@ the worktree, bypass hooks or force-push, each executed in real bash to prove
50
50
  it does what its row says, and near-miss commands that must pass. Each guard
51
51
  must refuse every attack row and pass every near-miss.
52
52
 
53
+ ## What a refusal says
54
+
55
+ Each refusal names the way out. "mutation targets could not be resolved"
56
+ goes on to name what in the command the guard could not read (a redirection
57
+ into a file, a heredoc, a pipeline, a `;`/`&&` chain, `$(...)` or backticks,
58
+ an interpreter running a file such as `node <file>`, a program it does not
59
+ know), then the alternative: one plain command per call, files written with
60
+ the Edit or Write tools, and anything more as a script under the worktree's
61
+ `.tmp/` run through the repository's package runner (`pnpm exec tsx
62
+ .tmp/x.ts`). A write outside the worktree says where it aimed (the main
63
+ checkout, another worktree or its git folder, the repository's git folder, a
64
+ system temp folder) and where it belongs instead. The leading words of each
65
+ refusal are unchanged, so searches of older logs still match.
66
+
53
67
  ## Settings
54
68
 
55
69
  `KAVACH_CONFIG` names a JSON file; unset, every project rule is off. A file
@@ -60,6 +74,10 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
60
74
  "worktree": {
61
75
  "trustedCommands": [
62
76
  { "script": "scripts/worktree.py", "pattern": "python3 scripts/worktree\\.py (?:status|retire --pr [1-9][0-9]*)" }
77
+ ],
78
+ "readOnlyCommands": [
79
+ { "pattern": "pnpm (?:health:check|(?:ls|list|why)(?: [A-Za-z0-9@][A-Za-z0-9@/._-]*)*(?: --depth [0-9]+)?)",
80
+ "env": { "AGENT_REMOTE": ["true"] }, "reason": "the script and pnpm's listing commands only read" }
63
81
  ]
64
82
  },
65
83
  "command": {
@@ -67,7 +85,9 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
67
85
  "protectedVariablesReason": "it marks a hosted session; leave it as the session set it",
68
86
  "pairedChanges": [
69
87
  { "changes": "schema.sql", "requires": "changesets/", "reason": "a schema change needs its changeset" }
70
- ]
88
+ ],
89
+ "shellExpansion": "warn",
90
+ "shellExpansionRefusesFrom": "2026-11-21"
71
91
  }
72
92
  }
73
93
  ```
@@ -77,6 +97,14 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
77
97
  pattern must match the whole command; trust only command lines of plain
78
98
  words (a whole line with any shell metacharacter is never trusted by its
79
99
  pattern alone).
100
+ - `readOnlyCommands`: a command line you reviewed as only reading, though
101
+ the guard cannot prove it (a package runner's script, say). It runs with no
102
+ owned worktree, from a checkout's root only, and only while the hook's own
103
+ environment holds one of the listed values for every variable in `env`,
104
+ which is required. The pattern is matched as a trusted command's is. Keep
105
+ the list to commands that write nothing however they are called, and
106
+ name each option a pattern allows: a runner's own options after the
107
+ subcommand (`pnpm ls -C elsewhere`, `--pnpmfile`) move or widen what runs.
80
108
  - `protectedVariables`: environment variables a command may not assign, declare or
81
109
  export.
82
110
  - `pairedChanges`: a commit that may record a path ending in `changes` must
@@ -85,6 +113,45 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
85
113
  longer refused for that reason alone (`--no-verify` and the rest still are).
86
114
  Keep a CI check behind it: the guard reads one command line, not the files
87
115
  other programs change.
116
+ - `shellExpansion`: `"warn"` (the default) or `"refuse"`, the mode of the
117
+ rule on trial below. `shellExpansionRefusesFrom` (a day, `YYYY-MM-DD`) is
118
+ only shown in its warning; nothing switches on that day. A kavach older
119
+ than this rule refuses every call on these unknown keys, so add them only
120
+ once the pinned kavach knows them.
121
+
122
+ ## A rule on trial: messages the shell changes
123
+
124
+ In bash and zsh a double-quoted (or unquoted) message is expanded before git
125
+ reads it: ``git commit -m "fix `parse`"`` runs `parse` and commits "fix ",
126
+ `$(...)` does the same, and an interactive shell replaces `!word` from its
127
+ history. commitlint reads only the header, so the loss ships. The command
128
+ guard finds backticks, `$(...)` and `!word` (a `!` before a blank, `=`, `(`,
129
+ a closing quote or the end is left alone) in the value of `git commit` and
130
+ `git tag`'s `-m`/`--message`, and of `--body`/`-b` on `gh pr create`,
131
+ `comment`, `edit` and `review` and `gh issue create`, `comment` and `edit`.
132
+ Single quotes, an escaped `` \` ``, `-F <file>` and `--body-file` are fine,
133
+ and so is a value that is wholly one command substitution
134
+ (`-m "$(cat file)"`, `--body "$(git log -1 --format=%s)"`): its author meant
135
+ the expansion, so nothing is lost. A substitution or backticks inside other
136
+ text (`-m "fix $(foo) bar"`) still fire, except `$(cat <<'EOF' ... EOF)`,
137
+ the heredoc whose text arrives whole (with an unquoted delimiter only while
138
+ its body runs nothing). This rule lets that heredoc pass; the worktree
139
+ boundary still refuses any heredoc, so from a worktree use `-F <file>`.
140
+
141
+ A new refusal starts by warning, since a wrong one would stop every session
142
+ at once. In `"warn"` the command runs, and the guard answers on stdout:
143
+
144
+ ```json
145
+ {"hookSpecificOutput": {"hookEventName": "PreToolUse",
146
+ "additionalContext": "kavach warning (refuses from 2026-11-21): the shell expands the backticks `parse` inside this double-quoted -m before git sees it, ..."}}
147
+ ```
148
+
149
+ In `"refuse"` it exits 2 with the same reason. Either way each fire is a row
150
+ of the refusal log naming the rule (`commit-message-shell-expansion`) and the
151
+ mode, so the trial's fires can be read for wrong ones before it refuses. Any
152
+ other refusal of the same command comes first. While it warns, an error in
153
+ the rule's own detection allows the call and logs a warning row naming the
154
+ error's type; refusing, it fails closed, as every other check always does.
88
155
 
89
156
  ## While a push runs
90
157
 
@@ -117,7 +184,8 @@ Without the marker, nothing here changes.
117
184
 
118
185
  Each refusal of the two Python guards appends a row (time, guard, tool, first
119
186
  line of the reason, start of the command) to
120
- `<git common dir>/kavach/refusals.jsonl`. It is best effort and never changes
187
+ `<git common dir>/kavach/refusals.jsonl`; a rule on trial adds a row for
188
+ each warning too, and names itself in its rows (`rule`, `mode`). It is best effort and never changes
121
189
  a verdict; `KAVACH_REFUSAL_LOG_OFF=1` turns it off. The rule-check engine
122
190
  keeps its own fire log (`--fires-dir`).
123
191
 
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.4",
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
 
@@ -10,6 +10,12 @@ command that sets a protected variable, and a commit that records one path
10
10
  of a paired change without the other. Each is read from the repository the
11
11
  command actually targets.
12
12
 
13
+ One rule is on trial: a message the shell would change before git or gh
14
+ reads it (backticks, ``$(...)`` or ``!word`` in a double-quoted ``-m`` or
15
+ ``--body``). Until the settings say ``"shellExpansion": "refuse"`` it allows
16
+ the command and prints a PreToolUse answer on stdout that puts the warning in
17
+ front of the agent; either way each fire is a row of the refusal log.
18
+
13
19
  Two checks, and the command runs only when both allow it: bash_guard.py
14
20
  first, then git_safety.py, which reads the code the first cannot (below)
15
21
  and runs against the adversarial corpus in ``corpus/``, every row of which
@@ -124,7 +130,28 @@ def cannot_name_git():
124
130
  command = json.loads(os.environ["HOOK_INPUT"])["tool_input"]["command"]
125
131
  except Exception:
126
132
  return False
127
- return isinstance(command, str) and not re.search(r"(?i)git|[\\\x27\"$`*?\[{|<>(]", command)
133
+ if not isinstance(command, str) or re.search(r"(?i)git|[\\\x27\"$`*?\[{|<>(]", command):
134
+ return False
135
+ # `gh pr comment 1 -b fix!now` has a message git_safety.py reads; a `!`
136
+ # anywhere else (`if ! make; then ...`) keeps the fast exit it had.
137
+ return not ("!" in command and re.search(r"(?<![\w.-])gh(?![\w.-])", command))
138
+
139
+
140
+ def fired(rule, mode, message):
141
+ """Log one fire of a rule on trial (git_safety.hook_main's ``fired``): a
142
+ refusal's row is written as it ends, named here; a warning's now, since
143
+ an allowed call writes no row of its own. Logging never changes a
144
+ verdict."""
145
+ try:
146
+ import refusal_log
147
+ if mode == "refuse":
148
+ refusal_log.label(rule, mode)
149
+ else:
150
+ refusal_log.record(
151
+ "command", os.environ.get("HOOK_INPUT", ""), "WARNING: " + message, rule=rule, mode=mode
152
+ )
153
+ except BaseException:
154
+ return
128
155
 
129
156
 
130
157
  def main():
@@ -146,7 +173,7 @@ def main():
146
173
  return 0
147
174
  return verdict(
148
175
  "the shell-code check",
149
- lambda: __import__("git_safety").hook_main(loaded),
176
+ lambda: __import__("git_safety").hook_main(loaded, fired),
150
177
  "git_safety.py",
151
178
  "git_safety",
152
179
  )
@@ -2,9 +2,10 @@
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
7
- file, and with no file every such rule is off.
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
+ file, and with no file every such rule is off. So does the mode of a rule on
8
+ trial (`command.shellExpansion`), which warns until the file says "refuse".
8
9
 
9
10
  A file that cannot be read, is not JSON, has a key this module does not know,
10
11
  or a value of the wrong shape raises ConfigError. Each guard's entry point
@@ -38,6 +39,18 @@ class TrustedCommand:
38
39
  pattern: re.Pattern[str]
39
40
 
40
41
 
42
+ @dataclass(frozen=True)
43
+ class ReadOnlyCommand:
44
+ """A command line the consumer reviewed as only reading, allowed from a
45
+ checkout's root without an owned worktree, and only where the hook's own
46
+ environment holds one of the listed values for every variable in `env`.
47
+ `pattern` must match the whole command; `reason` says what was checked."""
48
+
49
+ pattern: re.Pattern[str]
50
+ env: tuple[tuple[str, tuple[str, ...]], ...]
51
+ reason: str
52
+
53
+
41
54
  @dataclass(frozen=True)
42
55
  class PairedChange:
43
56
  """A commit that may record a path ending in `changes` must also certainly
@@ -54,14 +67,27 @@ class Config:
54
67
  protected_variables: tuple[str, ...] = ()
55
68
  protected_variables_reason: str = ""
56
69
  paired_changes: tuple[PairedChange, ...] = ()
70
+ read_only_commands: tuple[ReadOnlyCommand, ...] = ()
71
+ # The shell-expansion rule (git_safety.py): "warn" allows with a warning,
72
+ # "refuse" blocks. A new refusal warns first, so the default is "warn".
73
+ shell_expansion: str = "warn"
74
+ # The day it is to start refusing, shown in its warning (YYYY-MM-DD).
75
+ # Shown only: what makes it refuse is "refuse" above, set once its log
76
+ # shows no wrong fire.
77
+ shell_expansion_refuses_from: str = ""
57
78
 
58
79
 
59
80
  DEFAULT = Config()
81
+ SHELL_EXPANSION_MODES = ("warn", "refuse")
82
+ DAY = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
60
83
 
61
84
  SHAPE: dict[str, set[str]] = {
62
85
  "": {"worktree", "command"},
63
- "worktree": {"trustedCommands"},
64
- "command": {"protectedVariables", "protectedVariablesReason", "pairedChanges"},
86
+ "worktree": {"trustedCommands", "readOnlyCommands"},
87
+ "command": {
88
+ "protectedVariables", "protectedVariablesReason", "pairedChanges",
89
+ "shellExpansion", "shellExpansionRefusesFrom",
90
+ },
65
91
  }
66
92
 
67
93
 
@@ -100,6 +126,32 @@ def _trusted(value: Any, index: int) -> TrustedCommand:
100
126
  return TrustedCommand(script, pattern)
101
127
 
102
128
 
129
+ def _read_only(value: Any, index: int) -> ReadOnlyCommand:
130
+ where = f"worktree.readOnlyCommands[{index}]"
131
+ entry = _object(value, where, {"pattern", "env", "reason"})
132
+ source = _text(entry.get("pattern"), f"{where}.pattern")
133
+ try:
134
+ pattern = re.compile(source)
135
+ except re.error as error:
136
+ raise ConfigError(f"{where}.pattern is not a regular expression: {error}") from None
137
+ # The environment is required: an entry with no condition would widen the
138
+ # guard on every machine, which is what kavach's own read-only list is
139
+ # kept narrow to avoid.
140
+ raw_env = entry.get("env")
141
+ if not isinstance(raw_env, dict) or not raw_env:
142
+ raise ConfigError(f"{where}.env must be a non-empty JSON object of variable names")
143
+ env: list[tuple[str, tuple[str, ...]]] = []
144
+ for name, values in raw_env.items():
145
+ if not VARIABLE_NAME.fullmatch(name):
146
+ raise ConfigError(f"{where}.env has a key that is not a variable name: {name!r}")
147
+ listed = _list(values, f"{where}.env.{name}")
148
+ if not listed:
149
+ raise ConfigError(f"{where}.env.{name} must list at least one value")
150
+ env.append((name, tuple(_text(item, f"{where}.env.{name}[{at}]") for at, item in enumerate(listed))))
151
+ reason = _text(entry.get("reason"), f"{where}.reason")
152
+ return ReadOnlyCommand(pattern, tuple(env), reason)
153
+
154
+
103
155
  def _paired(value: Any, index: int) -> PairedChange:
104
156
  where = f"command.pairedChanges[{index}]"
105
157
  entry = _object(value, where, {"changes", "requires", "reason"})
@@ -135,7 +187,17 @@ def parse(data: Any) -> Config:
135
187
  _paired(entry, index)
136
188
  for index, entry in enumerate(_list(command.get("pairedChanges", []), "command.pairedChanges"))
137
189
  )
138
- return Config(trusted, variables, reason, paired)
190
+ read_only = tuple(
191
+ _read_only(entry, index)
192
+ for index, entry in enumerate(_list(worktree.get("readOnlyCommands", []), "worktree.readOnlyCommands"))
193
+ )
194
+ mode = command.get("shellExpansion", "warn")
195
+ if mode not in SHELL_EXPANSION_MODES:
196
+ raise ConfigError(f"command.shellExpansion must be one of {', '.join(SHELL_EXPANSION_MODES)}")
197
+ refuses_from = command.get("shellExpansionRefusesFrom", "")
198
+ if refuses_from != "" and not (isinstance(refuses_from, str) and DAY.fullmatch(refuses_from)):
199
+ raise ConfigError("command.shellExpansionRefusesFrom must be a day, YYYY-MM-DD")
200
+ return Config(trusted, variables, reason, paired, read_only, mode, refuses_from)
139
201
 
140
202
 
141
203
  def load(environ: Mapping[str, str] | None = None) -> Config:
@@ -44,7 +44,7 @@ import shlex
44
44
  import subprocess
45
45
  import sys
46
46
  from dataclasses import dataclass, field
47
- from typing import Any, Sequence
47
+ from typing import Any, Callable, Sequence
48
48
 
49
49
  MAX_DEPTH = 6
50
50
 
@@ -74,6 +74,11 @@ class Blocked(Exception):
74
74
  """Raised with the reason a command is refused."""
75
75
 
76
76
 
77
+ class DetectionError(Exception):
78
+ """The shell-expansion detection failed while lexing a message word;
79
+ its text is the type of the error the lexer met."""
80
+
81
+
77
82
  # ---------------------------------------------------------------------------
78
83
  # Lexing: the subset of bash that decides which words a command receives.
79
84
  # ---------------------------------------------------------------------------
@@ -103,6 +108,10 @@ class Word:
103
108
  glob_leading: bool = False
104
109
  # An unquoted `{a,b}` / `{1..3}`: bash turns the word into several.
105
110
  braces: bool = False
111
+ # What the shell replaces in this word that a message's author likely
112
+ # meant as text (Analyzer.shell_expansion): (kind, the fragment as typed).
113
+ # Kinds: "backticks", "substitution" (`$(...)`), "history" (`!word`).
114
+ hazards: list[tuple[str, str]] = field(default_factory=list)
106
115
 
107
116
  def could_be_flag(self) -> bool:
108
117
  """Might the shell turn this word into something starting with '-'/'+'?"""
@@ -373,6 +382,34 @@ class Lexer:
373
382
 
374
383
  # -- expansions ----------------------------------------------------------
375
384
 
385
+ def hazard(self, find: Callable[[], tuple[str, str] | None]) -> None:
386
+ """Note what ``find`` finds the shell would change in this word. The
387
+ finding is the shell-expansion rule's alone, so an error in it never
388
+ stops the lexing the other checks need: it is noted as an ("error",
389
+ <type>) hazard, which the rule turns into its own failure if the word
390
+ is a message (Analyzer.message_value)."""
391
+
392
+ try:
393
+ found = find()
394
+ except Exception as error:
395
+ found = ("error", type(error).__name__)
396
+ if found is not None:
397
+ self.word.hazards.append(found)
398
+
399
+ def history(self, index: int) -> None:
400
+ """Note a `!` at ``index`` that an interactive shell's history
401
+ expansion would replace: one followed by a word, not by a blank, `=`,
402
+ `(`, a closing quote or the end (`fix!`, `a != b` stay text)."""
403
+
404
+ def find() -> tuple[str, str] | None:
405
+ following = self.text[index + 1 : index + 2]
406
+ if not following or following in " \t\n=(\"":
407
+ return None
408
+ fragment = re.match(r"![^\s\"';&|()<>]*", self.text[index:])
409
+ return ("history", fragment.group(0) if fragment else "!")
410
+
411
+ self.hazard(find)
412
+
376
413
  def dollar(self, in_double: bool) -> None:
377
414
  text, index = self.text, self.index
378
415
  following = text[index + 1 : index + 2]
@@ -380,7 +417,15 @@ class Lexer:
380
417
  # `$((...))` arithmetic closes the same way; its body, analysed as
381
418
  # a program, is a harmless subshell unless it nests a `$(...)`.
382
419
  end = matching_close(text, index + 2, "(", ")")
383
- self.word.bodies.append(text[index + 2 : end - 1])
420
+ body = text[index + 2 : end - 1]
421
+ # Arithmetic gives a number, never lost text; a heredoc read by
422
+ # `cat` is how a message is meant to be substituted.
423
+ self.hazard(
424
+ lambda: None
425
+ if body.startswith("(") or heredoc_message(body)
426
+ else ("substitution", text[index:end])
427
+ )
428
+ self.word.bodies.append(body)
384
429
  self.add(text[index:end], dynamic=True)
385
430
  self.index = end
386
431
  return
@@ -421,6 +466,8 @@ class Lexer:
421
466
  if end < 0:
422
467
  raise Blocked("unterminated backquote")
423
468
  body = self.text[self.index + 1 : end].replace("\\`", "`")
469
+ fragment = self.text[self.index : end + 1]
470
+ self.hazard(lambda: ("backticks", fragment))
424
471
  self.word.bodies.append(body)
425
472
  self.add(self.text[self.index : end + 1], dynamic=True)
426
473
  self.index = end + 1
@@ -538,6 +585,8 @@ class Lexer:
538
585
  self.add(char)
539
586
  self.index += 1
540
587
  continue
588
+ if char == "!":
589
+ self.history(self.index)
541
590
  self.add(char)
542
591
  self.index += 1
543
592
  self.finish_command(";")
@@ -571,12 +620,19 @@ class Lexer:
571
620
  self.add(text[self.index + 1], quoted=True)
572
621
  self.index += 2
573
622
  continue
623
+ if char == "\\" and text[self.index + 1 : self.index + 2] == "!":
624
+ # `\!` stops history expansion; bash keeps the backslash.
625
+ self.add("\\!", quoted=True)
626
+ self.index += 2
627
+ continue
574
628
  if char == "$":
575
629
  self.dollar(in_double=True)
576
630
  continue
577
631
  if char == "`":
578
632
  self.backquote()
579
633
  continue
634
+ if char == "!":
635
+ self.history(self.index)
580
636
  self.add(char, quoted=True)
581
637
  self.index += 1
582
638
  raise Blocked("unterminated double quote")
@@ -597,6 +653,30 @@ def parse(script: str) -> list[Command]:
597
653
  return Lexer(script).run()
598
654
 
599
655
 
656
+ def heredoc_message(body: str) -> bool:
657
+ """Whether a `$(...)` body is the heredoc idiom for a message,
658
+ `cat <<'EOF' ... EOF`: one plain `cat` reading one heredoc whose body
659
+ runs nothing. Its text arrives whole; that is what the idiom is for. With
660
+ an unquoted delimiter a `$(...)` or backtick in the body still runs, so
661
+ that body is no exemption."""
662
+
663
+ try:
664
+ commands = parse(body)
665
+ except Blocked:
666
+ return False
667
+ if len(commands) != 1:
668
+ return False
669
+ (command,) = commands
670
+ return (
671
+ [(word.text, word.dynamic, word.quoted) for word in command.words] == [("cat", False, False)]
672
+ and len(command.heredocs) == 1
673
+ and not command.expanded_bodies
674
+ and not command.redirect_targets
675
+ and not command.input_targets
676
+ and not command.herestrings
677
+ )
678
+
679
+
600
680
  # ---------------------------------------------------------------------------
601
681
  # Analysis.
602
682
  # ---------------------------------------------------------------------------
@@ -721,6 +801,86 @@ def dangerous_long(word: str) -> str | None:
721
801
  return None
722
802
 
723
803
 
804
+ # The shell-expansion rule (`command.shellExpansion` in config.py): a message
805
+ # given on the command line in double quotes (or none) loses what the shell
806
+ # expands in it. `git commit -m "fix `parse`"` runs `parse` and commits "fix ";
807
+ # `!word` is replaced by an interactive shell's history. commitlint reads only
808
+ # the header, so the loss ships. A message from a file (`-F`, `--body-file`),
809
+ # in single quotes, or read by `$(cat <<'EOF' ... EOF)` arrives whole.
810
+ SHELL_EXPANSION_RULE = "commit-message-shell-expansion"
811
+ SHELL_EXPANSION_MODES = ("warn", "refuse")
812
+ # gh's commands whose `--body`/`-b` is the text written; elsewhere `-b` may
813
+ # mean another thing (`gh pr checkout -b <branch>`).
814
+ GH_BODY_COMMANDS = {
815
+ ("pr", "create"), ("pr", "comment"), ("pr", "edit"), ("pr", "review"),
816
+ ("issue", "create"), ("issue", "comment"), ("issue", "edit"),
817
+ }
818
+ # Their other options that take a value, so a value is never read as `-b`.
819
+ GH_VALUE_OPTIONS = {
820
+ "-t", "--title", "-B", "--base", "-H", "--head", "-a", "--assignee", "-l", "--label",
821
+ "-m", "--milestone", "-p", "--project", "-r", "--reviewer", "-R", "--repo", "-F",
822
+ "--body-file", "-T", "--template", "--add-label", "--remove-label", "--add-assignee",
823
+ "--remove-assignee", "--add-reviewer", "--remove-reviewer", "--add-project", "--remove-project",
824
+ }
825
+ # git tag's long options that take the next word as their value.
826
+ TAG_VALUE_LONG = {
827
+ "--file", "--local-user", "--cleanup", "--sort", "--format", "--contains", "--no-contains",
828
+ "--points-at", "--merged", "--no-merged", "--trailer",
829
+ }
830
+ EXPANSION_KINDS = {
831
+ "backticks": "the backticks {}",
832
+ "substitution": "the command substitution {}",
833
+ "history": "the history expansion {}",
834
+ }
835
+ FRAGMENT_LIMIT = 60
836
+
837
+
838
+ def is_message_option(name: str) -> bool:
839
+ """`--message` or an abbreviation git resolves to it (`--mes`)."""
840
+
841
+ return len(name) > 2 and "--message".startswith(name)
842
+
843
+
844
+ @dataclass(frozen=True)
845
+ class Expansion:
846
+ """One expansion the shell makes in a message value: the program
847
+ (`git commit`, `gh pr create`), the option, what is expanded (a kind of
848
+ EXPANSION_KINDS and the text as typed), and whether the value was quoted."""
849
+
850
+ program: str
851
+ flag: str
852
+ kind: str
853
+ fragment: str
854
+ quoted: bool
855
+
856
+
857
+ def shell_expansion_message(found: Sequence[Expansion]) -> str:
858
+ """The rule's reason, naming each expansion of the first option found."""
859
+
860
+ first = found[0]
861
+ named: list[str] = []
862
+ for item in found:
863
+ if (item.program, item.flag) != (first.program, first.flag):
864
+ continue
865
+ fragment = item.fragment.replace("\n", " ")
866
+ if len(fragment) > FRAGMENT_LIMIT:
867
+ fragment = fragment[: FRAGMENT_LIMIT - 3] + "..."
868
+ named.append(EXPANSION_KINDS[item.kind].format(fragment))
869
+ what = named[0] if len(named) == 1 else ", ".join(named[:-1]) + " and " + named[-1]
870
+ quoting = "double-quoted" if first.quoted else "unquoted"
871
+ tool = first.program.split(" ", 1)[0]
872
+ if first.program == "git commit":
873
+ remedy = "Write the message to a file and use `git commit -F <file>` (or `gh ... --body-file <file>`)."
874
+ elif first.program == "git tag":
875
+ remedy = "Write the message to a file and use `git tag -F <file>`."
876
+ else:
877
+ remedy = f"Write the body to a file and use `{first.program} --body-file <file>`."
878
+ return (
879
+ f"the shell expands {what} inside this {quoting} {first.flag} before {tool} sees it, "
880
+ f"so that text silently disappears from the message. {remedy}"
881
+ )
882
+
883
+
724
884
  @dataclass
725
885
  class Scope:
726
886
  """What earlier commands in the same script changed for later ones."""
@@ -812,7 +972,12 @@ def expand_braces(words: list[Word]) -> list[Word]:
812
972
  continue
813
973
  alternatives = None if word.first_quoted is not None else brace_alternatives(word.text)
814
974
  if alternatives is None:
815
- out.append(Word(text=word.text, dynamic=True, quoted=word.quoted, bodies=word.bodies, started=True, first_dynamic=0))
975
+ out.append(
976
+ Word(
977
+ text=word.text, dynamic=True, quoted=word.quoted, bodies=word.bodies, started=True,
978
+ first_dynamic=0, hazards=word.hazards,
979
+ )
980
+ )
816
981
  continue
817
982
  for index, text in enumerate(alternatives):
818
983
  out.append(
@@ -825,14 +990,28 @@ def expand_braces(words: list[Word]) -> list[Word]:
825
990
  first_dynamic=word.first_dynamic,
826
991
  glob=any(char in text for char in "*?["),
827
992
  glob_leading=text[:1] in ("*", "?", "["),
993
+ hazards=word.hazards if index == 0 else [],
828
994
  )
829
995
  )
830
996
  return out
831
997
 
832
998
 
833
999
  class Analyzer:
834
- def __init__(self, base_cwd: str | None, read_config: bool = True, paired_changes: Sequence[Any] = ()) -> None:
1000
+ def __init__(
1001
+ self,
1002
+ base_cwd: str | None,
1003
+ read_config: bool = True,
1004
+ paired_changes: Sequence[Any] = (),
1005
+ tolerate_detection: bool = False,
1006
+ ) -> None:
835
1007
  self.base_cwd = base_cwd
1008
+ # The shell-expansion rule on trial ("warn") must not stop a call by
1009
+ # failing: an error in its detection is then noted in
1010
+ # `detection_failures` (the error's type) and the analysis goes on.
1011
+ # Refusing, it fails closed like every other check: the error
1012
+ # propagates and the entry blocks.
1013
+ self.tolerate_detection = tolerate_detection
1014
+ self.detection_failures: list[str] = []
836
1015
  self.read_config = read_config
837
1016
  # The consumer's paired changes (config.PairedChange: `changes`,
838
1017
  # `requires`, `reason`); empty turns the env -C half of that rule off.
@@ -846,6 +1025,48 @@ class Analyzer:
846
1025
  # The current command (or a program it runs) was moved by `env -C`,
847
1026
  # which bash_guard.py's paired-change model does not follow.
848
1027
  self.chdir_by_env = False
1028
+ # Messages the shell would change before git or gh reads them, in the
1029
+ # order found, each once (a program may be read twice).
1030
+ self.expansions: list[Expansion] = []
1031
+
1032
+ def detect(self, step: Callable[[], None]) -> None:
1033
+ """Run one step of the shell-expansion detection, which is nothing
1034
+ the other checks rely on; see ``tolerate_detection``."""
1035
+
1036
+ try:
1037
+ step()
1038
+ except Exception as error:
1039
+ if not self.tolerate_detection:
1040
+ raise
1041
+ name = str(error) if isinstance(error, DetectionError) else type(error).__name__
1042
+ self.detection_failures.append(name)
1043
+
1044
+ def message_value(
1045
+ self, program: str, flag: str, word: Word, value: str | None = None, option: str | None = None
1046
+ ) -> None:
1047
+ """Note what the shell expands in ``word``, the value of ``flag``;
1048
+ ``value`` is the value's text when the word also holds the option
1049
+ (`-m"$(x)"`, `--body=$(x)`). With ``option``, only when that option
1050
+ is `--message` (or git's abbreviation of it).
1051
+
1052
+ A value that is wholly one command substitution (`"$(cat file)"`,
1053
+ `` "`git log -1`" ``) is the author's own choice: its output is the
1054
+ message, and nothing is lost. One inside other text is the slip."""
1055
+
1056
+ def step() -> None:
1057
+ if option is not None and not is_message_option(option):
1058
+ return
1059
+ text = word.text if value is None else value
1060
+ for kind, fragment in word.hazards:
1061
+ if kind == "error":
1062
+ raise DetectionError(fragment)
1063
+ if kind in ("substitution", "backticks") and text == fragment:
1064
+ continue
1065
+ found = Expansion(program, flag, kind, fragment, word.quoted)
1066
+ if found not in self.expansions:
1067
+ self.expansions.append(found)
1068
+
1069
+ self.detect(step)
849
1070
 
850
1071
  # -- entry -------------------------------------------------------------
851
1072
 
@@ -1047,6 +1268,8 @@ class Analyzer:
1047
1268
  raise Blocked(f"'{name}' on '{word.text}' can disable the repository hooks. {HOOK_REMEDY}")
1048
1269
  if name == "git":
1049
1270
  self.git(words, scope, depth)
1271
+ elif name == "gh":
1272
+ self.detect(lambda: self.gh(words))
1050
1273
 
1051
1274
  @staticmethod
1052
1275
  def written_operands(name: str, operands: list[Word]) -> list[Word]:
@@ -1420,6 +1643,8 @@ class Analyzer:
1420
1643
  elif name == "config":
1421
1644
  self.config(args)
1422
1645
  else:
1646
+ if name == "tag":
1647
+ self.detect(lambda: self.tag(args))
1423
1648
  for word in args:
1424
1649
  if word.text == "--":
1425
1650
  break
@@ -1565,6 +1790,9 @@ class Analyzer:
1565
1790
  # `--message="$MSG"`: the option NAME is literal, only its value is not.
1566
1791
  if is_no_verify(literal):
1567
1792
  raise Blocked(f"--no-verify is forbidden on all git commands. {HOOK_REMEDY}")
1793
+ self.message_value(
1794
+ "git commit", "--message", word, text.split("=", 1)[1], option=literal.split("=", 1)[0]
1795
+ )
1568
1796
  index += 1
1569
1797
  continue
1570
1798
  if literal.startswith("-") and len(literal) > 1 and not literal.startswith("--"):
@@ -1578,7 +1806,11 @@ class Analyzer:
1578
1806
  if flag in "mFCct":
1579
1807
  consumed = True
1580
1808
  if position == len(cluster) - 1 and literal == text:
1809
+ if flag == "m" and index + 1 < len(args):
1810
+ self.message_value("git commit", "-m", args[index + 1])
1581
1811
  index += 1 # the value is the next word
1812
+ elif flag == "m":
1813
+ self.message_value("git commit", "-m", word, text[position + 2 :])
1582
1814
  break
1583
1815
  if flag in "Su":
1584
1816
  consumed = True
@@ -1596,11 +1828,84 @@ class Analyzer:
1596
1828
  # git takes any unambiguous abbreviation (`--mes '-n'` is a
1597
1829
  # message); an ambiguous one is an error and nothing runs.
1598
1830
  takes_value = len(text) > 2 and any(option.startswith(text) for option in value_long)
1831
+ if takes_value and index + 1 < len(args):
1832
+ self.message_value("git commit", "--message", args[index + 1], option=text)
1599
1833
  index += 2 if takes_value else 1
1600
1834
  continue
1601
1835
  index += 1
1602
1836
  return stages_all
1603
1837
 
1838
+ def tag(self, args: list[Word]) -> None:
1839
+ """Note the shell's expansions in git tag's message values."""
1840
+
1841
+ index = 0
1842
+ while index < len(args):
1843
+ word = args[index]
1844
+ text = word.text
1845
+ literal = text if word.first_dynamic is None else text[: word.first_dynamic]
1846
+ index += 1
1847
+ if text == "--":
1848
+ return
1849
+ if literal.startswith("--"):
1850
+ name, attached, _value = literal.partition("=")
1851
+ if is_message_option(name):
1852
+ if attached:
1853
+ self.message_value("git tag", "--message", word, text.split("=", 1)[1])
1854
+ elif index < len(args):
1855
+ self.message_value("git tag", "--message", args[index])
1856
+ index += 1
1857
+ elif not attached and len(name) > 2 and any(option.startswith(name) for option in TAG_VALUE_LONG):
1858
+ index += 1
1859
+ continue
1860
+ if literal.startswith("-") and len(literal) > 1:
1861
+ cluster = literal[1:]
1862
+ for position, flag in enumerate(cluster):
1863
+ if flag not in "mFu":
1864
+ continue
1865
+ last = position == len(cluster) - 1 and literal == text
1866
+ if flag == "m":
1867
+ if not last:
1868
+ self.message_value("git tag", "-m", word, text[position + 2 :])
1869
+ elif index < len(args):
1870
+ self.message_value("git tag", "-m", args[index])
1871
+ if last:
1872
+ index += 1
1873
+ break
1874
+
1875
+ def gh(self, words: list[Word]) -> None:
1876
+ """Note the shell's expansions in the body of a gh pull request or
1877
+ issue, created, edited, reviewed or commented on."""
1878
+
1879
+ command: list[str] = []
1880
+ index = 1
1881
+ while index < len(words) and len(command) < 2:
1882
+ text = words[index].text
1883
+ index += 1
1884
+ if text in ("-R", "--repo"):
1885
+ index += 1
1886
+ elif not text.startswith("-"):
1887
+ command.append(text)
1888
+ if tuple(command) not in GH_BODY_COMMANDS:
1889
+ return
1890
+ program = "gh " + " ".join(command)
1891
+ while index < len(words):
1892
+ word = words[index]
1893
+ text = word.text
1894
+ literal = text if word.first_dynamic is None else text[: word.first_dynamic]
1895
+ index += 1
1896
+ if text == "--":
1897
+ return
1898
+ if literal in ("-b", "--body") and literal == text:
1899
+ if index < len(words):
1900
+ self.message_value(program, literal, words[index])
1901
+ index += 1
1902
+ elif literal.startswith("--body="):
1903
+ self.message_value(program, "--body", word, text.split("=", 1)[1])
1904
+ elif literal.startswith("-b") and not literal.startswith("--"):
1905
+ self.message_value(program, "-b", word, text[2:])
1906
+ elif literal in GH_VALUE_OPTIONS and literal == text:
1907
+ index += 1
1908
+
1604
1909
  def push(
1605
1910
  self,
1606
1911
  args: list[Word],
@@ -1917,12 +2222,38 @@ def check(
1917
2222
  ``paired_changes`` are the consumer's (config.PairedChange); none turns
1918
2223
  that rule off."""
1919
2224
 
1920
- analyzer = Analyzer(cwd, read_config=read_config, paired_changes=paired_changes)
2225
+ return analyse(command, cwd, read_config=read_config, paired_changes=paired_changes)[0]
2226
+
2227
+
2228
+ def analyse(
2229
+ command: str, cwd: str | None, *, read_config: bool = True, paired_changes: Sequence[Any] = ()
2230
+ ) -> tuple[str | None, list[Expansion]]:
2231
+ """``check``'s reason, and the expansions the shell would make in the
2232
+ command's messages (the shell-expansion rule, judged by ``hook_main``)."""
2233
+
2234
+ reason, analyzer = analysed(command, cwd, read_config=read_config, paired_changes=paired_changes)
2235
+ return reason, analyzer.expansions
2236
+
2237
+
2238
+ def analysed(
2239
+ command: str,
2240
+ cwd: str | None,
2241
+ *,
2242
+ read_config: bool = True,
2243
+ paired_changes: Sequence[Any] = (),
2244
+ tolerate_detection: bool = False,
2245
+ ) -> tuple[str | None, Analyzer]:
2246
+ """``check``'s reason and the Analyzer that read the command, whose
2247
+ ``expansions`` and ``detection_failures`` the shell-expansion rule reads."""
2248
+
2249
+ analyzer = Analyzer(
2250
+ cwd, read_config=read_config, paired_changes=paired_changes, tolerate_detection=tolerate_detection
2251
+ )
1921
2252
  try:
1922
2253
  analyzer.script(command, cwd)
1923
2254
  except Blocked as reason:
1924
- return str(reason)
1925
- return None
2255
+ return str(reason), analyzer
2256
+ return None, analyzer
1926
2257
 
1927
2258
 
1928
2259
  # ---------------------------------------------------------------------------
@@ -1961,20 +2292,86 @@ def read_hook_input(raw: str) -> tuple[str, str]:
1961
2292
  return command, cwd if isinstance(cwd, str) and os.path.isabs(cwd) else os.getcwd()
1962
2293
 
1963
2294
 
1964
- def hook_main(settings: Any = None) -> None:
2295
+ # The way out for a refusal that names none: what this guard can read is a
2296
+ # git command written out plainly.
2297
+ PLAIN_REMEDY = (
2298
+ "Run the git command as a plain command of its own, with literal words: no eval, "
2299
+ "`bash -c`, pipe into a shell or wrapper option around it, and nothing computed at run time."
2300
+ )
2301
+ REMEDIES = (HOOK_REMEDY, FORCE_REMEDY, AMBIGUOUS_REMEDY, PAIRED_REMEDY, "Write the flags literally")
2302
+
2303
+
2304
+ def with_way_out(reason: str) -> str:
2305
+ """``reason``, ending in the way out when it names none of its own."""
2306
+
2307
+ if any(remedy in reason for remedy in REMEDIES):
2308
+ return reason
2309
+ return f"{reason.rstrip('.')}. {PLAIN_REMEDY}"
2310
+
2311
+
2312
+ def warning(message: str, refuses_from: str) -> str:
2313
+ """The PreToolUse answer that allows the call and puts ``message`` in
2314
+ front of the agent: one JSON object, the whole of stdout."""
2315
+
2316
+ when = f"refuses from {refuses_from}" if refuses_from else "refuses after its trial"
2317
+ context = (
2318
+ f"kavach warning ({when}): {message} This call still runs while the rule is on trial: "
2319
+ "check the text it wrote and correct it."
2320
+ )
2321
+ return json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": context}})
2322
+
2323
+
2324
+ def hook_main(settings: Any = None, fired: Any = None) -> None:
1965
2325
  """command_guard.py's second check: the hook input is in ``HOOK_INPUT``,
1966
- ``settings`` the consumer's config.Config (None: no paired changes).
2326
+ ``settings`` the consumer's config.Config (None: no paired changes, the
2327
+ shell-expansion rule warns).
1967
2328
 
1968
2329
  Always ends in SystemExit(0) or SystemExit(2); the entry blocks on any
1969
2330
  other end, an exception included. The paired-change rule is
1970
2331
  bash_guard.py's, whose index model the command-guard suite holds, except
1971
2332
  for commits moved by `env -C` (Analyzer.env_chdir_paired).
2333
+
2334
+ The shell-expansion rule runs in the mode ``settings`` gives it: "refuse"
2335
+ blocks, "warn" allows with the warning (``warning``) on stdout. Any other
2336
+ refusal comes first. ``fired(rule, mode, message)``, when given, hears of
2337
+ each fire before the verdict, so the entry can log it.
2338
+
2339
+ While it warns, the rule cannot stop a call by failing: an error in its
2340
+ detection (finding message values and what the shell expands in them,
2341
+ building its reason) allows the call and is reported to ``fired`` as a
2342
+ warning that names the error's type, so the trial's log shows it. Every
2343
+ other check fails closed as before, and so does the rule once it refuses.
1972
2344
  """
1973
2345
 
1974
2346
  paired = tuple(getattr(settings, "paired_changes", ()))
2347
+ mode = getattr(settings, "shell_expansion", "warn")
2348
+ refuses_from = getattr(settings, "shell_expansion_refuses_from", "")
2349
+ tolerate = mode != "refuse"
1975
2350
  command, cwd = read_hook_input(os.environ.get("HOOK_INPUT", ""))
1976
2351
  if command.strip():
1977
- reason = check(command, cwd, paired_changes=paired)
2352
+ reason, analyzer = analysed(command, cwd, paired_changes=paired, tolerate_detection=tolerate)
1978
2353
  if reason is not None:
1979
- block(f"BLOCKED: {reason}")
2354
+ block(f"BLOCKED: {with_way_out(reason)}")
2355
+ failures = list(analyzer.detection_failures)
2356
+ message = None
2357
+ if analyzer.expansions:
2358
+ try:
2359
+ message = shell_expansion_message(analyzer.expansions)
2360
+ except Exception as error:
2361
+ if not tolerate:
2362
+ raise
2363
+ failures.append(type(error).__name__)
2364
+ if failures and fired is not None:
2365
+ named = ", ".join(dict.fromkeys(failures))
2366
+ fired(
2367
+ SHELL_EXPANSION_RULE,
2368
+ mode,
2369
+ f"the shell-expansion check failed ({named}), so it did not judge this command; the call was allowed.",
2370
+ )
2371
+ if message is not None:
2372
+ if fired is not None:
2373
+ fired(SHELL_EXPANSION_RULE, mode, message)
2374
+ if mode == "refuse":
2375
+ block(f"BLOCKED: {message}")
2376
+ print(warning(message, refuses_from))
1980
2377
  raise SystemExit(0)
@@ -4,7 +4,10 @@ say which refusals a guard made and how often.
4
4
  Where: ``<git common dir>/kavach/refusals.jsonl`` of the repository the
5
5
  event's ``cwd`` belongs to, shared by all of its worktrees. Each row holds the
6
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
7
+ command or path. A rule on trial also writes a row each time it only warns,
8
+ and names itself in both kinds of row (``rule``, ``mode``: "warn" or
9
+ "refuse"), so its trial can be read for wrong fires before it refuses.
10
+ Commands can hold secrets typed on a command line, so the log
8
11
  stays inside the repository's git directory, never in the working tree.
9
12
 
10
13
  Best effort: logging never changes a verdict. Any failure (no git, no
@@ -75,8 +78,30 @@ def excerpt(tool_input: Any) -> str:
75
78
  return ""
76
79
 
77
80
 
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."""
81
+ # The rule and mode the next refusal row names (``label``), taken by it.
82
+ _LABEL: dict[str, str] = {}
83
+
84
+
85
+ def label(rule: str, mode: str) -> None:
86
+ """Name the rule and mode of the refusal about to be made, so its one
87
+ row says which rule fired and in which mode."""
88
+
89
+ _LABEL.clear()
90
+ _LABEL.update(rule=rule, mode=mode)
91
+
92
+
93
+ def record(
94
+ guard: str,
95
+ raw_input: str,
96
+ stderr_text: str,
97
+ now: Callable[[], float] = time.time,
98
+ *,
99
+ rule: str | None = None,
100
+ mode: str | None = None,
101
+ ) -> None:
102
+ """Append one row for a refusal, or for a warning (``mode`` "warn");
103
+ never raises. ``rule`` and ``mode`` are written only when given, so the
104
+ rows of rules with no name keep their shape."""
80
105
 
81
106
  try:
82
107
  if os.environ.get(OFF_VAR):
@@ -102,6 +127,10 @@ def record(guard: str, raw_input: str, stderr_text: str, now: Callable[[], float
102
127
  "reason": reason,
103
128
  "excerpt": excerpt(event.get("tool_input")),
104
129
  }
130
+ if rule is not None:
131
+ row["rule"] = rule
132
+ if mode is not None:
133
+ row["mode"] = mode
105
134
  directory.mkdir(parents=True, exist_ok=True)
106
135
  with open(directory / FILE, "a", encoding="utf-8") as handle:
107
136
  handle.write(json.dumps(row, ensure_ascii=False) + "\n")
@@ -140,9 +169,13 @@ def guarded(guard: str, body: Callable[[], Any], raw_input: Callable[[], str] |
140
169
  previous = sys.stderr, sys.stdin
141
170
  sys.stderr, sys.stdin = stderr, stdin # type: ignore[assignment]
142
171
 
172
+ _LABEL.clear()
173
+
143
174
  def log() -> None:
144
175
  text = raw_input() if raw_input is not None else stdin.copy.getvalue()
145
- record(guard, text, stderr.copy.getvalue())
176
+ named = dict(_LABEL)
177
+ _LABEL.clear()
178
+ record(guard, text, stderr.copy.getvalue(), rule=named.get("rule"), mode=named.get("mode"))
146
179
 
147
180
  try:
148
181
  result = body()
@@ -22,8 +22,10 @@ import importlib.util
22
22
  import json
23
23
  import os
24
24
  import re
25
+ import shlex
25
26
  import subprocess
26
27
  import sys
28
+ import tempfile
27
29
  from pathlib import Path
28
30
  from types import ModuleType
29
31
  from typing import Any, NoReturn
@@ -399,7 +401,227 @@ def denial(target: Path, root: Path, primary: Path) -> str:
399
401
  f"; agent memory is writable only when {expected} is a real directory"
400
402
  " (it is missing, or it or its parent is a symlink)"
401
403
  )
402
- return message
404
+ return message
405
+ try:
406
+ return message + where_instead(target, root, primary)
407
+ except Exception:
408
+ # Words only: a hint that fails must not turn the denial into a crash.
409
+ return message + GENERIC_INSTEAD
410
+
411
+
412
+ GENERIC_INSTEAD = "; write only inside this worktree, and keep scratch files in its .tmp/"
413
+
414
+
415
+ def system_temp_roots() -> list[Path]:
416
+ """The system's temp folders, resolved: where scratch files go by habit."""
417
+
418
+ roots = [Path("/tmp"), Path("/var/tmp"), Path(tempfile.gettempdir())]
419
+ if os.environ.get("TMPDIR"):
420
+ roots.append(Path(os.environ["TMPDIR"]))
421
+ return [root.resolve() for root in roots]
422
+
423
+
424
+ def linked_worktrees(git_dir: Path) -> list[tuple[Path, Path]]:
425
+ """Each linked worktree's own git folder and its work tree, read from
426
+ ``<git dir>/worktrees/*/gitdir``; what cannot be read is left out."""
427
+
428
+ found: list[tuple[Path, Path]] = []
429
+ try:
430
+ entries = sorted((git_dir / "worktrees").iterdir())
431
+ except OSError:
432
+ return found
433
+ for entry in entries:
434
+ try:
435
+ pointer = (entry / "gitdir").read_text(encoding="utf-8").strip()
436
+ except (OSError, ValueError):
437
+ continue
438
+ if pointer:
439
+ found.append((entry.resolve(), Path(pointer).parent.resolve()))
440
+ return found
441
+
442
+
443
+ def where_instead(target: Path, root: Path, primary: Path) -> str:
444
+ """Where the write belongs instead, after naming the place it aimed at.
445
+
446
+ Only words: the verdict is already made. Generic on purpose, since every
447
+ consumer's checkout differs: the main checkout, another worktree or its
448
+ git folder, the repository's git folder, a system temp folder, elsewhere.
449
+ """
450
+
451
+ resolved, checkout, own = target.resolve(), primary.resolve(), root.resolve()
452
+ git_dir = checkout / ".git"
453
+ own_git_dir = git_dir_of(root)
454
+ worktrees = linked_worktrees(git_dir)
455
+ if own_git_dir is not None and boundary.is_inside(resolved, own_git_dir.resolve()):
456
+ return "; that is this worktree's git folder: change it only through git commands run in this worktree"
457
+ if boundary.is_inside(resolved, git_dir / "worktrees"):
458
+ return (
459
+ "; that is another worktree's git folder: for this worktree's own branch run git here, and "
460
+ "for anything else write a script under this worktree's .tmp/ and run it from this worktree"
461
+ )
462
+ if boundary.is_inside(resolved, git_dir):
463
+ return "; that is the repository's git folder: change it only through git commands run in this worktree"
464
+ if any(boundary.is_inside(resolved, tree) for _gitdir, tree in worktrees if tree != own):
465
+ return (
466
+ "; that is another worktree, which another session may own: make the change in this "
467
+ "worktree, or ask the user to make it there"
468
+ )
469
+ if boundary.is_inside(resolved, checkout):
470
+ relative = resolved.relative_to(checkout)
471
+ return (
472
+ f"; that is the main checkout: make the change in this worktree's own copy, {own / relative}"
473
+ )
474
+ if any(boundary.is_inside(resolved, temp) for temp in system_temp_roots()):
475
+ return (
476
+ "; that is a system temp folder: keep scratch files in this worktree's .tmp/ "
477
+ "(keep it git-ignored) and write them with the Edit or Write tools"
478
+ )
479
+ return GENERIC_INSTEAD
480
+
481
+
482
+ UNRESOLVED = "mutation targets could not be resolved"
483
+ WAY_OUT = (
484
+ "Run one plain command per call; write files with the Edit or Write tools; for more, write a "
485
+ "script under this worktree's .tmp/ and run it through the repository's package runner "
486
+ "(for example `pnpm exec tsx .tmp/x.ts`)."
487
+ )
488
+ REDIRECTION_WAY_OUT = (
489
+ " A redirection into a file is never read, wherever it points: drop it and read the output, "
490
+ "or write the file with Write."
491
+ )
492
+ HEREDOC_WAY_OUT = (
493
+ " Write a heredoc's text to a file with Write instead (for a commit message, `git commit -F <file>`)."
494
+ )
495
+ INTERPRETERS = {
496
+ "python", "python2", "python3", "node", "nodejs", "bun", "deno", "perl", "ruby", "php", "lua",
497
+ "tsx", "ts-node", "osascript", "pwsh", "bash", "sh", "zsh", "dash", "ksh", "mksh", "fish",
498
+ }
499
+ SHELL_NAMES = {"bash", "sh", "zsh", "dash", "ksh", "mksh", "fish"}
500
+ # The option that makes each interpreter run its next word as a program.
501
+ INLINE_FLAGS = {"-e", "--eval", "-p", "--print"}
502
+ SHOWN = 80
503
+
504
+
505
+ def inline_flags(head: str) -> set[str]:
506
+ if head in SHELL_NAMES or head.startswith("python"):
507
+ return {"-c"}
508
+ if head == "osascript":
509
+ return {"-e"}
510
+ if head == "pwsh":
511
+ return {"-c", "-Command"}
512
+ return INLINE_FLAGS
513
+
514
+
515
+ def shell_reader() -> ModuleType | None:
516
+ """git_safety.py's shell lexer, which sees quoting, to name what a
517
+ refused command holds; None when it cannot load (the refusal then names
518
+ nothing). Loaded only for a refusal, so an allowed call never pays."""
519
+
520
+ name = "kavach_worktree_shell"
521
+ if name in sys.modules:
522
+ return sys.modules[name]
523
+ try:
524
+ spec = importlib.util.spec_from_file_location(name, HERE / "git_safety.py")
525
+ if spec is None or spec.loader is None:
526
+ return None
527
+ module = importlib.util.module_from_spec(spec)
528
+ sys.modules[name] = module
529
+ spec.loader.exec_module(module)
530
+ except (Exception, SystemExit):
531
+ sys.modules.pop(name, None)
532
+ return None
533
+ return module
534
+
535
+
536
+ def shown(text: str) -> str:
537
+ text = text.replace("\n", " ")
538
+ return text if len(text) <= SHOWN else text[: SHOWN - 3] + "..."
539
+
540
+
541
+ def unreadable_constructs(command: str, cwd: Path) -> list[str]:
542
+ """What in ``command`` keeps its writes from being read, most specific
543
+ first: a heredoc, a redirection into a file, a substitution, an
544
+ interpreter, a program the guard does not know, then a pipeline or a
545
+ chain. Words for a refusal only: the verdict is ``mutation_directories``'s."""
546
+
547
+ reader = shell_reader()
548
+ if reader is None:
549
+ return []
550
+ try:
551
+ commands = reader.parse(command)
552
+ except Exception:
553
+ return []
554
+ found: list[str] = []
555
+
556
+ def add(text: str) -> None:
557
+ if text not in found:
558
+ found.append(text)
559
+
560
+ for each in commands:
561
+ if each.heredocs:
562
+ add("a heredoc (`<<`)")
563
+ for each in commands:
564
+ for target in each.redirect_targets:
565
+ if target.text != os.devnull and not re.fullmatch(r"[0-9]+|-", target.text):
566
+ add(f"an output redirection (`>` to {shown(target.text)})")
567
+ if any(
568
+ word.bodies
569
+ for each in commands
570
+ for word in (*each.words, *each.redirect_targets, *each.input_targets, *each.herestrings)
571
+ ) or any(each.expanded_bodies for each in commands):
572
+ add("a command substitution (`$(...)`, backticks or `<(...)`)")
573
+ for each in commands:
574
+ words = list(each.words)
575
+ while words and words[0].is_assignment() is not None:
576
+ words.pop(0)
577
+ if not words or words[0].dynamic:
578
+ continue
579
+ head = os.path.basename(words[0].text)
580
+ if head in INTERPRETERS or re.fullmatch(r"python[0-9]+(\.[0-9]+)*", head):
581
+ inline = next((word.text for word in words[1:] if word.text in inline_flags(head)), None)
582
+ if inline is not None:
583
+ add(f"an inline {head} program (`{head} {inline}`)")
584
+ elif any(not word.text.startswith("-") for word in words[1:]):
585
+ typed = " ".join(word.text for word in words)
586
+ add(f"an interpreter running a file or module (`{shown(typed)}`)")
587
+ continue
588
+ own = each.heredocs or each.redirect_targets or any(word.bodies or word.dynamic for word in words)
589
+ if not own and boundary.mutation_directories(shlex.join(word.text for word in words), cwd) is None:
590
+ add(f"`{head}`, a program whose writes the guard does not know")
591
+ if any(each.piped_from is not None for each in commands):
592
+ add("a pipeline (`|`)")
593
+ if any(each.follows in (";", "&&", "||", "\n", "&") for each in commands[1:]):
594
+ add("a chain of commands (`;`, `&&` or `||`)")
595
+ return found
596
+
597
+
598
+ def unresolved(tool: object, values: dict[str, object], cwd: Path) -> str:
599
+ """The refusal for a call whose writes could not be read, naming what in
600
+ it could not be read and the way out. The leading words stay as they
601
+ were, so earlier logs and searches still match."""
602
+
603
+ if tool != "Bash":
604
+ return (
605
+ f"{UNRESOLVED}: the file path cannot be read as one place. "
606
+ "Give the tool an absolute path inside this worktree."
607
+ )
608
+ command = boundary.tool_command(values)
609
+ try:
610
+ found = unreadable_constructs(command, cwd) if command else []
611
+ except Exception:
612
+ found = []
613
+ if not found:
614
+ return f"{UNRESOLVED}: the guard cannot read off this command what it writes. {WAY_OUT}"
615
+ named = found[0] if len(found) == 1 else ", ".join(found[:-1]) + " and " + found[-1]
616
+ extra = ""
617
+ if any(item.startswith("an output redirection") for item in found):
618
+ extra += REDIRECTION_WAY_OUT
619
+ if "a heredoc (`<<`)" in found:
620
+ extra += HEREDOC_WAY_OUT
621
+ return (
622
+ f"{UNRESOLVED}: this command uses {named}, so the guard cannot read off it what it writes. "
623
+ f"{WAY_OUT}{extra}"
624
+ )
403
625
 
404
626
 
405
627
  # A push in progress (0086 P-06). A pre-push hook that runs kavach's
@@ -594,7 +816,8 @@ def refuse_during_push(tool: object, targets: list[Path], root: Path) -> None:
594
816
  def main() -> None:
595
817
  # First, so a misread settings file refuses every event, a read-only one
596
818
  # included.
597
- trusted = settings().trusted_commands
819
+ loaded = settings()
820
+ trusted = loaded.trusted_commands
598
821
  data = boundary.read_input()
599
822
  cwd = Path(data.get("cwd") or Path.cwd()).resolve()
600
823
  state = boundary.checkout_state(cwd)
@@ -616,6 +839,13 @@ def main() -> None:
616
839
  command = boundary.tool_command(values)
617
840
  if command and boundary.shell_is_read_only(command):
618
841
  return
842
+ # A read the consumer reviewed (`worktree.readOnlyCommands`), from the
843
+ # checkout's root and only in the environment its entry names: like a
844
+ # proven read, it needs no owned worktree and waits for no push.
845
+ if command and boundary.read_only_request(
846
+ command, cwd, root, loaded.read_only_commands, os.environ
847
+ ):
848
+ return
619
849
  # Only the shell parser reads these (a file tool is never given them), and
620
850
  # finding them costs git starts, so an Edit or Write does not pay for them.
621
851
  read_namespaces = harness_state_roots(root, primary) if tool == "Bash" else ()
@@ -644,7 +874,7 @@ def main() -> None:
644
874
  refuse_during_push(tool, [root], root)
645
875
  return
646
876
  if targets is None:
647
- boundary.deny("mutation targets could not be resolved")
877
+ boundary.deny(unresolved(tool, values, cwd))
648
878
  for target in targets:
649
879
  # From inside an owned worktree the two allowances compose, so
650
880
  # `touch <memory>/note.md` works while the same command with a second