@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 +71 -3
- package/package.json +1 -1
- package/python/kavach/boundary.py +76 -16
- package/python/kavach/command_guard.py +29 -2
- package/python/kavach/config.py +68 -6
- package/python/kavach/git_safety.py +408 -11
- package/python/kavach/refusal_log.py +37 -4
- package/python/kavach/worktree_guard.py +233 -3
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
)
|
package/python/kavach/config.py
CHANGED
|
@@ -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,
|
|
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": {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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__(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|