@veda-hq/kavach 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/package.json +1 -1
- package/python/kavach/boundary.py +76 -16
- package/python/kavach/config.py +47 -4
- package/python/kavach/worktree_guard.py +203 -2
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
कवच, armour. Guards for coding agents: each tool call an agent makes is
|
|
4
4
|
judged before it runs. Part of [Veda](https://github.com/veda-hq/veda).
|
|
5
5
|
|
|
6
|
-
## v0.
|
|
6
|
+
## v0.5: three guards and a rule-check engine
|
|
7
7
|
|
|
8
8
|
| Guard | Refuses | Entry point |
|
|
9
9
|
| --- | --- | --- |
|
|
10
|
-
| Worktree boundary | Any write outside the agent's own git worktree: a Bash command, an Edit or a Write. Read-only commands pass from any checkout. | `python3 python/kavach/worktree_guard.py` |
|
|
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
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` |
|
|
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
|
|
|
@@ -60,6 +60,10 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
|
|
|
60
60
|
"worktree": {
|
|
61
61
|
"trustedCommands": [
|
|
62
62
|
{ "script": "scripts/worktree.py", "pattern": "python3 scripts/worktree\\.py (?:status|retire --pr [1-9][0-9]*)" }
|
|
63
|
+
],
|
|
64
|
+
"readOnlyCommands": [
|
|
65
|
+
{ "pattern": "pnpm (?:health:check|(?:ls|list|why)(?: [A-Za-z0-9@][A-Za-z0-9@/._-]*)*(?: --depth [0-9]+)?)",
|
|
66
|
+
"env": { "AGENT_REMOTE": ["true"] }, "reason": "the script and pnpm's listing commands only read" }
|
|
63
67
|
]
|
|
64
68
|
},
|
|
65
69
|
"command": {
|
|
@@ -77,6 +81,14 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
|
|
|
77
81
|
pattern must match the whole command; trust only command lines of plain
|
|
78
82
|
words (a whole line with any shell metacharacter is never trusted by its
|
|
79
83
|
pattern alone).
|
|
84
|
+
- `readOnlyCommands`: a command line you reviewed as only reading, though
|
|
85
|
+
the guard cannot prove it (a package runner's script, say). It runs with no
|
|
86
|
+
owned worktree, from a checkout's root only, and only while the hook's own
|
|
87
|
+
environment holds one of the listed values for every variable in `env`,
|
|
88
|
+
which is required. The pattern is matched as a trusted command's is. Keep
|
|
89
|
+
the list to commands that write nothing however they are called, and
|
|
90
|
+
name each option a pattern allows: a runner's own options after the
|
|
91
|
+
subcommand (`pnpm ls -C elsewhere`, `--pnpmfile`) move or widen what runs.
|
|
80
92
|
- `protectedVariables`: environment variables a command may not assign, declare or
|
|
81
93
|
export.
|
|
82
94
|
- `pairedChanges`: a commit that may record a path ending in `changes` must
|
|
@@ -86,6 +98,33 @@ that cannot be read, is not JSON, or has an unknown key refuses every call.
|
|
|
86
98
|
Keep a CI check behind it: the guard reads one command line, not the files
|
|
87
99
|
other programs change.
|
|
88
100
|
|
|
101
|
+
## While a push runs
|
|
102
|
+
|
|
103
|
+
A push's checks read the worktree it was started from, and an edit made
|
|
104
|
+
while they run either fails the push at its end or leaves it checking what
|
|
105
|
+
was not pushed. So while a push runs, the worktree boundary refuses writes
|
|
106
|
+
into that worktree: any Bash command it cannot prove read-only, run there
|
|
107
|
+
(a trusted command included), and any Edit, Write or NotebookEdit of a path
|
|
108
|
+
git does not ignore. Reads still run, and the file tools can still write
|
|
109
|
+
files git ignores (drafts, scratch); a Bash command that writes one is still
|
|
110
|
+
refused. The refusal names the push's pid and start. Commands that only reach
|
|
111
|
+
the network, such as `gh pr comment`, wait too: the guard cannot tell what an
|
|
112
|
+
unproven command writes.
|
|
113
|
+
|
|
114
|
+
It knows a push is running from the marker the project's pre-push hook
|
|
115
|
+
writes. Write it when a real push starts and clear it on every exit:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
kavach-rule-checks push-marker write "$(git rev-parse --absolute-git-dir)" "$$"
|
|
119
|
+
trap 'kavach-rule-checks push-marker clear "$(git rev-parse --absolute-git-dir)" "$$"' EXIT
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Each push gets its own marker, `kavach-push-in-progress.<pid>.json` in the
|
|
123
|
+
worktree's own git folder, naming its pid and start. A marker whose pid has
|
|
124
|
+
ended or now belongs to a later process blocks nothing, so a killed push
|
|
125
|
+
leaves no lock behind, and a push in another worktree never blocks this one.
|
|
126
|
+
Without the marker, nothing here changes.
|
|
127
|
+
|
|
89
128
|
## Refusal log
|
|
90
129
|
|
|
91
130
|
Each refusal of the two Python guards appends a row (time, guard, tool, first
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/kavach",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Guards for coding agents: refuses writes outside the agent's worktree, git-hook bypasses and history-destroying pushes, and runs a project's own rule checks on each tool call. Read-only commands pass.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coding agents",
|
|
@@ -16,7 +16,7 @@ import stat
|
|
|
16
16
|
import subprocess
|
|
17
17
|
import sys
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import Protocol, Sequence
|
|
19
|
+
from typing import Mapping, Protocol, Sequence
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
# Admission to this set means one thing: NO invocation of the command mutates
|
|
@@ -3246,22 +3246,10 @@ def trusted_request(
|
|
|
3246
3246
|
|
|
3247
3247
|
if not trusted:
|
|
3248
3248
|
return False
|
|
3249
|
-
|
|
3250
|
-
# command: the whole-line match holds only for plain words.
|
|
3251
|
-
plain = TRUSTED_LINE_UNSAFE.search(command) is None
|
|
3252
|
-
opening: tuple[str, list[list[str]]] | None = None
|
|
3253
|
-
rest_is_inert: bool | None = None
|
|
3249
|
+
line = ReviewedLine(command)
|
|
3254
3250
|
for entry in trusted:
|
|
3255
|
-
if not (
|
|
3256
|
-
|
|
3257
|
-
opening = trusted_opening(command)
|
|
3258
|
-
first, rest = opening
|
|
3259
|
-
if not first or not entry.pattern.fullmatch(first):
|
|
3260
|
-
continue
|
|
3261
|
-
if rest_is_inert is None:
|
|
3262
|
-
rest_is_inert = all(segment_is_read_only(words) for words in rest)
|
|
3263
|
-
if not rest_is_inert:
|
|
3264
|
-
continue
|
|
3251
|
+
if not line.matches(entry.pattern):
|
|
3252
|
+
continue
|
|
3265
3253
|
resolved = root.resolve()
|
|
3266
3254
|
script = resolved / entry.script
|
|
3267
3255
|
if cwd.resolve() == resolved and script.is_file() and script.resolve() == script:
|
|
@@ -3269,6 +3257,78 @@ def trusted_request(
|
|
|
3269
3257
|
return False
|
|
3270
3258
|
|
|
3271
3259
|
|
|
3260
|
+
class ReviewedLine:
|
|
3261
|
+
"""One command line, as a consumer's reviewed pattern is matched to it.
|
|
3262
|
+
|
|
3263
|
+
A whole line of plain words matches when the pattern matches all of it. A
|
|
3264
|
+
loose pattern (`.*`) must not admit a line that hides another command, so
|
|
3265
|
+
any other line matches only by its first segment, as plain words, followed
|
|
3266
|
+
by nothing but read-only segments (reviewed redirections included). The
|
|
3267
|
+
parse is done once, for the first pattern that needs it.
|
|
3268
|
+
"""
|
|
3269
|
+
|
|
3270
|
+
def __init__(self, command: str) -> None:
|
|
3271
|
+
self.command = command
|
|
3272
|
+
self.plain = TRUSTED_LINE_UNSAFE.search(command) is None
|
|
3273
|
+
self.opening: tuple[str, list[list[str]]] | None = None
|
|
3274
|
+
self.rest_is_inert: bool | None = None
|
|
3275
|
+
|
|
3276
|
+
def matches(self, pattern: re.Pattern[str]) -> bool:
|
|
3277
|
+
if self.plain and pattern.fullmatch(self.command):
|
|
3278
|
+
return True
|
|
3279
|
+
if self.opening is None:
|
|
3280
|
+
self.opening = trusted_opening(self.command)
|
|
3281
|
+
first, rest = self.opening
|
|
3282
|
+
if not first or not pattern.fullmatch(first):
|
|
3283
|
+
return False
|
|
3284
|
+
if self.rest_is_inert is None:
|
|
3285
|
+
self.rest_is_inert = all(segment_is_read_only(words) for words in rest)
|
|
3286
|
+
return self.rest_is_inert
|
|
3287
|
+
|
|
3288
|
+
|
|
3289
|
+
class ReadOnlyEntry(Protocol):
|
|
3290
|
+
"""One reviewed read-only command, as `config.ReadOnlyCommand` carries it:
|
|
3291
|
+
`pattern` must match the whole command line, and `env` pairs each
|
|
3292
|
+
variable with the values the hook's environment may hold for it."""
|
|
3293
|
+
|
|
3294
|
+
pattern: re.Pattern[str]
|
|
3295
|
+
env: tuple[tuple[str, tuple[str, ...]], ...]
|
|
3296
|
+
|
|
3297
|
+
|
|
3298
|
+
def read_only_request(
|
|
3299
|
+
command: str,
|
|
3300
|
+
cwd: Path,
|
|
3301
|
+
root: Path,
|
|
3302
|
+
entries: Sequence[ReadOnlyEntry],
|
|
3303
|
+
environ: Mapping[str, str],
|
|
3304
|
+
) -> bool:
|
|
3305
|
+
"""Recognize a command line the consumer reviewed as only reading.
|
|
3306
|
+
|
|
3307
|
+
Such a command reads although this parser cannot prove it: a package
|
|
3308
|
+
runner's script, say, whose writes are the consumer's to review, not
|
|
3309
|
+
kavach's. It needs no owned worktree, so the match is held as tight as a
|
|
3310
|
+
trusted command's (``ReviewedLine``), and two more conditions apply:
|
|
3311
|
+
|
|
3312
|
+
- It runs from the checkout's root (``root``), so a relative script or
|
|
3313
|
+
a package manifest means the root's own, never a subfolder's.
|
|
3314
|
+
- Every variable the entry names holds one of its listed values in
|
|
3315
|
+
``environ``, the hook's own environment, which the command line cannot
|
|
3316
|
+
set.
|
|
3317
|
+
|
|
3318
|
+
No entries: nothing is admitted here.
|
|
3319
|
+
"""
|
|
3320
|
+
|
|
3321
|
+
if not entries or cwd.resolve() != root.resolve():
|
|
3322
|
+
return False
|
|
3323
|
+
line = ReviewedLine(command)
|
|
3324
|
+
for entry in entries:
|
|
3325
|
+
if all(environ.get(name) in values for name, values in entry.env) and line.matches(
|
|
3326
|
+
entry.pattern
|
|
3327
|
+
):
|
|
3328
|
+
return True
|
|
3329
|
+
return False
|
|
3330
|
+
|
|
3331
|
+
|
|
3272
3332
|
def trusted_opening(command: str) -> tuple[str, list[list[str]]]:
|
|
3273
3333
|
"""The first segment of ``command`` as plain words, and the segments after.
|
|
3274
3334
|
|
package/python/kavach/config.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
the environment variable KAVACH_CONFIG.
|
|
3
3
|
|
|
4
4
|
kavach knows no product. What one product trusts or requires (a script its
|
|
5
|
-
agents may run from any checkout,
|
|
6
|
-
alone, a file whose change needs another in the same commit) comes from this
|
|
5
|
+
agents may run from any checkout, a command it reviewed as only reading and allows in one
|
|
6
|
+
environment, environment variables an agent must leave alone, a file whose change needs another in the same commit) comes from this
|
|
7
7
|
file, and with no file every such rule is off.
|
|
8
8
|
|
|
9
9
|
A file that cannot be read, is not JSON, has a key this module does not know,
|
|
@@ -38,6 +38,18 @@ class TrustedCommand:
|
|
|
38
38
|
pattern: re.Pattern[str]
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ReadOnlyCommand:
|
|
43
|
+
"""A command line the consumer reviewed as only reading, allowed from a
|
|
44
|
+
checkout's root without an owned worktree, and only where the hook's own
|
|
45
|
+
environment holds one of the listed values for every variable in `env`.
|
|
46
|
+
`pattern` must match the whole command; `reason` says what was checked."""
|
|
47
|
+
|
|
48
|
+
pattern: re.Pattern[str]
|
|
49
|
+
env: tuple[tuple[str, tuple[str, ...]], ...]
|
|
50
|
+
reason: str
|
|
51
|
+
|
|
52
|
+
|
|
41
53
|
@dataclass(frozen=True)
|
|
42
54
|
class PairedChange:
|
|
43
55
|
"""A commit that may record a path ending in `changes` must also certainly
|
|
@@ -54,13 +66,14 @@ class Config:
|
|
|
54
66
|
protected_variables: tuple[str, ...] = ()
|
|
55
67
|
protected_variables_reason: str = ""
|
|
56
68
|
paired_changes: tuple[PairedChange, ...] = ()
|
|
69
|
+
read_only_commands: tuple[ReadOnlyCommand, ...] = ()
|
|
57
70
|
|
|
58
71
|
|
|
59
72
|
DEFAULT = Config()
|
|
60
73
|
|
|
61
74
|
SHAPE: dict[str, set[str]] = {
|
|
62
75
|
"": {"worktree", "command"},
|
|
63
|
-
"worktree": {"trustedCommands"},
|
|
76
|
+
"worktree": {"trustedCommands", "readOnlyCommands"},
|
|
64
77
|
"command": {"protectedVariables", "protectedVariablesReason", "pairedChanges"},
|
|
65
78
|
}
|
|
66
79
|
|
|
@@ -100,6 +113,32 @@ def _trusted(value: Any, index: int) -> TrustedCommand:
|
|
|
100
113
|
return TrustedCommand(script, pattern)
|
|
101
114
|
|
|
102
115
|
|
|
116
|
+
def _read_only(value: Any, index: int) -> ReadOnlyCommand:
|
|
117
|
+
where = f"worktree.readOnlyCommands[{index}]"
|
|
118
|
+
entry = _object(value, where, {"pattern", "env", "reason"})
|
|
119
|
+
source = _text(entry.get("pattern"), f"{where}.pattern")
|
|
120
|
+
try:
|
|
121
|
+
pattern = re.compile(source)
|
|
122
|
+
except re.error as error:
|
|
123
|
+
raise ConfigError(f"{where}.pattern is not a regular expression: {error}") from None
|
|
124
|
+
# The environment is required: an entry with no condition would widen the
|
|
125
|
+
# guard on every machine, which is what kavach's own read-only list is
|
|
126
|
+
# kept narrow to avoid.
|
|
127
|
+
raw_env = entry.get("env")
|
|
128
|
+
if not isinstance(raw_env, dict) or not raw_env:
|
|
129
|
+
raise ConfigError(f"{where}.env must be a non-empty JSON object of variable names")
|
|
130
|
+
env: list[tuple[str, tuple[str, ...]]] = []
|
|
131
|
+
for name, values in raw_env.items():
|
|
132
|
+
if not VARIABLE_NAME.fullmatch(name):
|
|
133
|
+
raise ConfigError(f"{where}.env has a key that is not a variable name: {name!r}")
|
|
134
|
+
listed = _list(values, f"{where}.env.{name}")
|
|
135
|
+
if not listed:
|
|
136
|
+
raise ConfigError(f"{where}.env.{name} must list at least one value")
|
|
137
|
+
env.append((name, tuple(_text(item, f"{where}.env.{name}[{at}]") for at, item in enumerate(listed))))
|
|
138
|
+
reason = _text(entry.get("reason"), f"{where}.reason")
|
|
139
|
+
return ReadOnlyCommand(pattern, tuple(env), reason)
|
|
140
|
+
|
|
141
|
+
|
|
103
142
|
def _paired(value: Any, index: int) -> PairedChange:
|
|
104
143
|
where = f"command.pairedChanges[{index}]"
|
|
105
144
|
entry = _object(value, where, {"changes", "requires", "reason"})
|
|
@@ -135,7 +174,11 @@ def parse(data: Any) -> Config:
|
|
|
135
174
|
_paired(entry, index)
|
|
136
175
|
for index, entry in enumerate(_list(command.get("pairedChanges", []), "command.pairedChanges"))
|
|
137
176
|
)
|
|
138
|
-
|
|
177
|
+
read_only = tuple(
|
|
178
|
+
_read_only(entry, index)
|
|
179
|
+
for index, entry in enumerate(_list(worktree.get("readOnlyCommands", []), "worktree.readOnlyCommands"))
|
|
180
|
+
)
|
|
181
|
+
return Config(trusted, variables, reason, paired, read_only)
|
|
139
182
|
|
|
140
183
|
|
|
141
184
|
def load(environ: Mapping[str, str] | None = None) -> Config:
|
|
@@ -22,6 +22,7 @@ import importlib.util
|
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
24
|
import re
|
|
25
|
+
import subprocess
|
|
25
26
|
import sys
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
from types import ModuleType
|
|
@@ -401,10 +402,200 @@ def denial(target: Path, root: Path, primary: Path) -> str:
|
|
|
401
402
|
return message
|
|
402
403
|
|
|
403
404
|
|
|
405
|
+
# A push in progress (0086 P-06). A pre-push hook that runs kavach's
|
|
406
|
+
# `push-marker write <git-dir> <pid>` (`src/rule-checks/live-runs.ts`) leaves
|
|
407
|
+
# one marker per push in the worktree's own git folder and clears it on exit.
|
|
408
|
+
# While one names a live push, the push is checking this tree: an edit now
|
|
409
|
+
# makes it fail at its end ("inputs changed") or check what was not pushed.
|
|
410
|
+
# This reads the markers exactly as `live-runs.ts` does: the same name, the
|
|
411
|
+
# same JSON, and the same rule for an ended push (`isGone`), so a marker a
|
|
412
|
+
# killed push left behind, or one whose pid was reused, blocks nothing. A
|
|
413
|
+
# marker that cannot be read names no push, as there: it guards against a
|
|
414
|
+
# wasted push, not against the agent, whom the boundary above already holds.
|
|
415
|
+
PUSH_MARKER = re.compile(r"kavach-push-in-progress\.\d+\.json")
|
|
416
|
+
START_IDENTITY_FORMAT = "lstart-utc-c/1"
|
|
417
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class PushRun:
|
|
421
|
+
"""A push a marker records: its pid, start identity and start time."""
|
|
422
|
+
|
|
423
|
+
def __init__(self, pid: int, start_identity: str | None, started_at: str | None):
|
|
424
|
+
self.pid = pid
|
|
425
|
+
self.start_identity = start_identity
|
|
426
|
+
self.started_at = started_at
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def parse_push_run(raw: str) -> PushRun | None:
|
|
430
|
+
"""A marker's run, as `parseRun` reads it; ``None`` for anything else."""
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
value = json.loads(raw)
|
|
434
|
+
except ValueError:
|
|
435
|
+
return None
|
|
436
|
+
if not isinstance(value, dict):
|
|
437
|
+
return None
|
|
438
|
+
pid = value.get("pid")
|
|
439
|
+
if isinstance(pid, float) and pid.is_integer():
|
|
440
|
+
pid = int(pid)
|
|
441
|
+
if isinstance(pid, bool) or not isinstance(pid, int):
|
|
442
|
+
return None
|
|
443
|
+
if pid <= 1 or pid > MAX_SAFE_INTEGER:
|
|
444
|
+
return None
|
|
445
|
+
# A missing key is not JSON's null: `parseRun` refuses it, so this does.
|
|
446
|
+
if "startIdentity" not in value:
|
|
447
|
+
return None
|
|
448
|
+
identity = value["startIdentity"]
|
|
449
|
+
if identity is not None and not isinstance(identity, str):
|
|
450
|
+
return None
|
|
451
|
+
started = value.get("startedAt")
|
|
452
|
+
# Only a plain timestamp is quoted back in a refusal.
|
|
453
|
+
if not isinstance(started, str) or not re.fullmatch(r"[0-9T:.+Z-]{1,40}", started):
|
|
454
|
+
started = None
|
|
455
|
+
return PushRun(pid, identity, started)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def ps_field(pid: int, field: str, environment: dict[str, str]) -> str | None:
|
|
459
|
+
"""``ps -o <field>= -p <pid>``, stripped; ``None`` when ps fails."""
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
result = subprocess.run(
|
|
463
|
+
["ps", "-o", f"{field}=", "-p", str(pid)],
|
|
464
|
+
stdin=subprocess.DEVNULL,
|
|
465
|
+
capture_output=True,
|
|
466
|
+
text=True,
|
|
467
|
+
check=False,
|
|
468
|
+
env=environment,
|
|
469
|
+
)
|
|
470
|
+
except OSError:
|
|
471
|
+
return None
|
|
472
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def process_alive(pid: int) -> bool:
|
|
476
|
+
"""As `processProbe.isAlive`: only ESRCH or a zombie is dead."""
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
os.kill(pid, 0)
|
|
480
|
+
except (ProcessLookupError, OverflowError):
|
|
481
|
+
# OverflowError: a safe integer past the system's pid range names no
|
|
482
|
+
# process.
|
|
483
|
+
return False
|
|
484
|
+
except OSError:
|
|
485
|
+
# EPERM (someone else's process) or anything else is no evidence.
|
|
486
|
+
pass
|
|
487
|
+
state = ps_field(pid, "stat", {**os.environ, "LC_ALL": "C"})
|
|
488
|
+
return state is None or not state.startswith("Z")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def start_identity_of(pid: int) -> str | None:
|
|
492
|
+
"""As `processProbe.startIdentityOf`: `lstart` read in UTC and the C locale."""
|
|
493
|
+
|
|
494
|
+
started = ps_field(pid, "lstart", {**os.environ, "TZ": "UTC", "LC_ALL": "C"})
|
|
495
|
+
return f"{START_IDENTITY_FORMAT} {started}" if started else None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def is_current_identity(identity: str | None) -> bool:
|
|
499
|
+
return identity is not None and identity.startswith(f"{START_IDENTITY_FORMAT} ")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def push_is_gone(run: PushRun) -> bool:
|
|
503
|
+
"""As `isGone`: the pid is dead, or now a process that started later."""
|
|
504
|
+
|
|
505
|
+
if not process_alive(run.pid):
|
|
506
|
+
return True
|
|
507
|
+
if not is_current_identity(run.start_identity):
|
|
508
|
+
return False
|
|
509
|
+
current = start_identity_of(run.pid)
|
|
510
|
+
if current is None:
|
|
511
|
+
return not process_alive(run.pid)
|
|
512
|
+
return is_current_identity(current) and current != run.start_identity
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def git_dir_of(root: Path) -> Path | None:
|
|
516
|
+
"""The worktree's own git folder, from its files first, then from git."""
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
found = boundary.checkout_from_files(root)
|
|
520
|
+
except boundary.GitMustAnswer:
|
|
521
|
+
found = None
|
|
522
|
+
if found is not None:
|
|
523
|
+
return found[1]
|
|
524
|
+
answer = boundary.run_git(root, "rev-parse", "--absolute-git-dir")
|
|
525
|
+
return Path(answer) if answer else None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def live_push(root: Path) -> PushRun | None:
|
|
529
|
+
"""The first live push a marker in the worktree's git folder records."""
|
|
530
|
+
|
|
531
|
+
git_dir = git_dir_of(root)
|
|
532
|
+
if git_dir is None:
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
names = sorted(name for name in os.listdir(git_dir) if PUSH_MARKER.fullmatch(name))
|
|
536
|
+
except OSError:
|
|
537
|
+
return None
|
|
538
|
+
for name in names:
|
|
539
|
+
try:
|
|
540
|
+
raw = (git_dir / name).read_text(encoding="utf-8")
|
|
541
|
+
except (OSError, ValueError):
|
|
542
|
+
continue
|
|
543
|
+
run = parse_push_run(raw)
|
|
544
|
+
if run is not None and not push_is_gone(run):
|
|
545
|
+
return run
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def git_ignores(root: Path, target: Path) -> bool:
|
|
550
|
+
"""Whether git ignores ``target`` in the checkout at ``root``.
|
|
551
|
+
|
|
552
|
+
A tracked file is never ignored, whatever the patterns say; an answer git
|
|
553
|
+
cannot give counts as not ignored.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
# `./` keeps a name such as `:(top)x` from being read as pathspec magic.
|
|
557
|
+
relative = f"./{os.path.relpath(target, root)}"
|
|
558
|
+
result = boundary.run_git_result(root, "check-ignore", "-q", "--", relative)
|
|
559
|
+
return result.returncode == 0
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def refuse_during_push(tool: object, targets: list[Path], root: Path) -> None:
|
|
563
|
+
"""Refuse a write into the worktree while its push runs.
|
|
564
|
+
|
|
565
|
+
A Bash command that reached here is one the boundary could not prove
|
|
566
|
+
read-only, and its targets always include the directory it runs in, so
|
|
567
|
+
any such command run in the worktree waits: what it writes there cannot
|
|
568
|
+
be told. An Edit, Write or NotebookEdit names its one path, and a path git
|
|
569
|
+
ignores (a draft, a scratch file) changes nothing a push reads.
|
|
570
|
+
"""
|
|
571
|
+
|
|
572
|
+
checkout = root.resolve()
|
|
573
|
+
inside = [target for target in targets if boundary.is_inside(target.resolve(), checkout)]
|
|
574
|
+
if not inside:
|
|
575
|
+
return
|
|
576
|
+
run = live_push(root)
|
|
577
|
+
if run is None:
|
|
578
|
+
return
|
|
579
|
+
if tool == "Bash":
|
|
580
|
+
what = "this command may write in it"
|
|
581
|
+
else:
|
|
582
|
+
changed = [target for target in inside if not git_ignores(checkout, target.resolve())]
|
|
583
|
+
if not changed:
|
|
584
|
+
return
|
|
585
|
+
what = f"this edits {os.path.relpath(changed[0].resolve(), checkout)}, which git does not ignore"
|
|
586
|
+
started = f", started {run.started_at}" if run.started_at else ""
|
|
587
|
+
boundary.deny(
|
|
588
|
+
f"this worktree's push (pid {run.pid}{started}) is still running, and {what}. "
|
|
589
|
+
"Wait for the push to end, then retry. Read-only commands still run, "
|
|
590
|
+
"and files git ignores can still be written with Edit or Write."
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
404
594
|
def main() -> None:
|
|
405
595
|
# First, so a misread settings file refuses every event, a read-only one
|
|
406
596
|
# included.
|
|
407
|
-
|
|
597
|
+
loaded = settings()
|
|
598
|
+
trusted = loaded.trusted_commands
|
|
408
599
|
data = boundary.read_input()
|
|
409
600
|
cwd = Path(data.get("cwd") or Path.cwd()).resolve()
|
|
410
601
|
state = boundary.checkout_state(cwd)
|
|
@@ -426,6 +617,13 @@ def main() -> None:
|
|
|
426
617
|
command = boundary.tool_command(values)
|
|
427
618
|
if command and boundary.shell_is_read_only(command):
|
|
428
619
|
return
|
|
620
|
+
# A read the consumer reviewed (`worktree.readOnlyCommands`), from the
|
|
621
|
+
# checkout's root and only in the environment its entry names: like a
|
|
622
|
+
# proven read, it needs no owned worktree and waits for no push.
|
|
623
|
+
if command and boundary.read_only_request(
|
|
624
|
+
command, cwd, root, loaded.read_only_commands, os.environ
|
|
625
|
+
):
|
|
626
|
+
return
|
|
429
627
|
# Only the shell parser reads these (a file tool is never given them), and
|
|
430
628
|
# finding them costs git starts, so an Edit or Write does not pay for them.
|
|
431
629
|
read_namespaces = harness_state_roots(root, primary) if tool == "Bash" else ()
|
|
@@ -449,7 +647,9 @@ def main() -> None:
|
|
|
449
647
|
boundary.tool_command(values), cwd, root, trusted
|
|
450
648
|
):
|
|
451
649
|
# A script the consumer reviewed and trusts, reached only after
|
|
452
|
-
# ownership above.
|
|
650
|
+
# ownership above. What it writes is unproven, so it counts as
|
|
651
|
+
# writing in the checkout while a push runs.
|
|
652
|
+
refuse_during_push(tool, [root], root)
|
|
453
653
|
return
|
|
454
654
|
if targets is None:
|
|
455
655
|
boundary.deny("mutation targets could not be resolved")
|
|
@@ -459,6 +659,7 @@ def main() -> None:
|
|
|
459
659
|
# operand outside both still fails on that operand.
|
|
460
660
|
if not (boundary.is_session_target(target, root, primary) or in_memory(target)):
|
|
461
661
|
boundary.deny(denial(target, root, primary))
|
|
662
|
+
refuse_during_push(tool, targets, root)
|
|
462
663
|
|
|
463
664
|
|
|
464
665
|
def run() -> None:
|