remote-exec-grpc 0.2.1__py3-none-any.whl
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.
- remote_exec/__init__.py +10 -0
- remote_exec/claude_skill.md +118 -0
- remote_exec/client.py +242 -0
- remote_exec/client_cli.py +226 -0
- remote_exec/cmdlog.py +159 -0
- remote_exec/config.py +202 -0
- remote_exec/executor.py +287 -0
- remote_exec/policy.py +420 -0
- remote_exec/security.py +178 -0
- remote_exec/server.py +292 -0
- remote_exec/server_cli.py +219 -0
- remote_exec/skill.py +39 -0
- remote_exec/v1/__init__.py +1 -0
- remote_exec/v1/remote_exec_pb2.py +57 -0
- remote_exec/v1/remote_exec_pb2.pyi +108 -0
- remote_exec/v1/remote_exec_pb2_grpc.py +142 -0
- remote_exec_grpc-0.2.1.dist-info/METADATA +539 -0
- remote_exec_grpc-0.2.1.dist-info/RECORD +20 -0
- remote_exec_grpc-0.2.1.dist-info/WHEEL +4 -0
- remote_exec_grpc-0.2.1.dist-info/entry_points.txt +3 -0
remote_exec/policy.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Server-side allow/deny policy for the programs a command may run (tools.yaml).
|
|
2
|
+
|
|
3
|
+
``tools.yaml``::
|
|
4
|
+
|
|
5
|
+
allow: ["*"] # "*" = every program (glob patterns are supported)
|
|
6
|
+
deny: [ssh] # deny wins over allow
|
|
7
|
+
|
|
8
|
+
Defaults (no file, or a key missing/empty): ``allow: ["*"]``, ``deny: ["ssh"]``.
|
|
9
|
+
``deny: []`` explicitly denies nothing (e.g. to permit ssh). All values are
|
|
10
|
+
read as strings, so names like ``true`` or ``yes`` stay program names.
|
|
11
|
+
|
|
12
|
+
Commands are shell strings, so the policy looks at every program the command
|
|
13
|
+
line would start: each simple command in pipelines/lists (``|``, ``&&``,
|
|
14
|
+
``;``, newlines), subshells, ``$(...)`` / backticks / ``<(...)``, the target
|
|
15
|
+
of wrappers such as ``sudo``, ``env``, ``timeout``, ``nohup``, and scripts
|
|
16
|
+
passed to ``bash -c`` / ``eval``. Patterns without "/" match the program's
|
|
17
|
+
base name (``ssh`` matches ``/usr/bin/ssh``).
|
|
18
|
+
|
|
19
|
+
This is a guardrail against mistakes, not a sandbox: programs started
|
|
20
|
+
indirectly (a script or ``python -c`` calling ssh, ``X=ssh; $X``) cannot be
|
|
21
|
+
seen in the command line. Use OS-level controls for real isolation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from fnmatch import fnmatchcase
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
DEFAULT_TOOLS_FILE = Path("~/.config/remote-exec/tools.yaml")
|
|
38
|
+
DEFAULT_ALLOW = ("*",)
|
|
39
|
+
DEFAULT_DENY = ("ssh",)
|
|
40
|
+
|
|
41
|
+
# Shell builtins that do not start another program: never need to be allowed
|
|
42
|
+
# (they can still be denied explicitly).
|
|
43
|
+
BUILTINS = frozenset({
|
|
44
|
+
"cd", "echo", "printf", "pwd", "export", "unset", "set", "true", "false", "test", "[", "[[",
|
|
45
|
+
"read", "exit", "return", "shift", "local", "declare", "typeset", "wait", ":", "let", "umask",
|
|
46
|
+
"ulimit", "trap", "type", "hash", "shopt", "alias", "unalias", "pushd", "popd", "dirs",
|
|
47
|
+
"break", "continue", "getopts", "readonly", "jobs",
|
|
48
|
+
})
|
|
49
|
+
# Reserved words that may precede a command.
|
|
50
|
+
_KEYWORD_PREFIXES = frozenset({"!", "{", "}", "if", "then", "else", "elif", "do", "while", "until", "time"})
|
|
51
|
+
# Words starting a construct whose remaining words are not a program.
|
|
52
|
+
_NON_COMMANDS = frozenset({"for", "select", "case", "esac", "fi", "done", "in", "function", "((", "]]"})
|
|
53
|
+
# Commands that run another command given as their arguments.
|
|
54
|
+
WRAPPERS = frozenset({
|
|
55
|
+
"sudo", "doas", "env", "nohup", "time", "exec", "nice", "timeout", "command", "builtin",
|
|
56
|
+
"xargs", "stdbuf", "setsid", "ionice", "chrt", "unbuffer", "watch", "strace", "ltrace",
|
|
57
|
+
"numactl", "srun", "mpirun", "mpiexec",
|
|
58
|
+
})
|
|
59
|
+
# Wrapper options that consume the following word (e.g. ``sudo -u user``).
|
|
60
|
+
_WRAPPER_OPTS_WITH_ARG = {
|
|
61
|
+
"sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T", "--user", "--group", "--host",
|
|
62
|
+
"--prompt", "--close-from", "--chdir", "--role", "--type", "--other-user", "--command-timeout"},
|
|
63
|
+
"doas": {"-u", "-C"},
|
|
64
|
+
"env": {"-u", "-C", "-S", "--unset", "--chdir", "--split-string"},
|
|
65
|
+
"nice": {"-n", "--adjustment"},
|
|
66
|
+
"timeout": {"-s", "-k", "--signal", "--kill-after"},
|
|
67
|
+
"ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata"},
|
|
68
|
+
"xargs": {"-I", "-i", "-n", "-P", "-L", "-l", "-d", "-E", "-e", "-s", "-a", "--arg-file", "--delimiter",
|
|
69
|
+
"--max-args", "--max-procs", "--max-lines", "--max-chars", "--replace"},
|
|
70
|
+
"watch": {"-n", "--interval", "-d"},
|
|
71
|
+
"numactl": {"-N", "-m", "-C", "-p", "-i", "--cpunodebind", "--membind", "--physcpubind",
|
|
72
|
+
"--preferred", "--interleave"},
|
|
73
|
+
"srun": {"-n", "-N", "-p", "-c", "-t", "-J", "-A", "-w", "--partition", "--account", "--time"},
|
|
74
|
+
"mpirun": {"-n", "-np", "-N", "-H", "--host", "--hostfile", "-hostfile", "-machinefile", "--map-by"},
|
|
75
|
+
"mpiexec": {"-n", "-np", "-N", "-H", "--host", "--hostfile", "-hostfile", "-machinefile", "--map-by"},
|
|
76
|
+
"strace": {"-o", "-e", "-p", "-s", "-u", "-E"},
|
|
77
|
+
"ltrace": {"-o", "-e", "-p", "-s", "-u", "-E"},
|
|
78
|
+
"chrt": {"-p"},
|
|
79
|
+
}
|
|
80
|
+
SHELLS = frozenset({"sh", "bash", "zsh", "dash", "ksh", "fish"})
|
|
81
|
+
|
|
82
|
+
_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*\+?=")
|
|
83
|
+
_WRAPPER_ARG_RE = re.compile(r"^(\d+(\.\d+)?[smhd]?|0x[0-9a-fA-F]+)$") # durations, priorities, masks
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PolicyError(Exception):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------- shell parsing
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Parser:
|
|
94
|
+
"""Small bash-like lexer that collects simple commands (lists of words).
|
|
95
|
+
|
|
96
|
+
Expansions it cannot evaluate are kept as a literal ``$`` in the word.
|
|
97
|
+
It is deliberately lenient: malformed input (e.g. an unterminated quote)
|
|
98
|
+
is parsed as far as possible; bash will reject it anyway.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, text: str):
|
|
102
|
+
self.s = text
|
|
103
|
+
self.i = 0
|
|
104
|
+
self.commands: list[list[str]] = []
|
|
105
|
+
|
|
106
|
+
def parse(self, until: str | None = None) -> None:
|
|
107
|
+
s = self.s
|
|
108
|
+
words: list[str] = []
|
|
109
|
+
word: list[str] | None = None
|
|
110
|
+
skip_next_word = False # redirection target
|
|
111
|
+
heredocs: list[tuple[str, bool]] = [] # (delimiter, strip tabs)
|
|
112
|
+
pending_heredoc: bool | None = None # next word is a heredoc delimiter
|
|
113
|
+
depth = 0
|
|
114
|
+
|
|
115
|
+
def end_word() -> None:
|
|
116
|
+
nonlocal word, skip_next_word, pending_heredoc
|
|
117
|
+
if word is None:
|
|
118
|
+
return
|
|
119
|
+
value = "".join(word)
|
|
120
|
+
word = None
|
|
121
|
+
if pending_heredoc is not None:
|
|
122
|
+
heredocs.append((value, pending_heredoc))
|
|
123
|
+
pending_heredoc = None
|
|
124
|
+
if skip_next_word:
|
|
125
|
+
skip_next_word = False
|
|
126
|
+
else:
|
|
127
|
+
words.append(value)
|
|
128
|
+
|
|
129
|
+
def end_command() -> None:
|
|
130
|
+
end_word()
|
|
131
|
+
if words:
|
|
132
|
+
self.commands.append(list(words))
|
|
133
|
+
words.clear()
|
|
134
|
+
|
|
135
|
+
def start_word() -> list[str]:
|
|
136
|
+
nonlocal word
|
|
137
|
+
if word is None:
|
|
138
|
+
word = []
|
|
139
|
+
return word
|
|
140
|
+
|
|
141
|
+
while self.i < len(s):
|
|
142
|
+
c = s[self.i]
|
|
143
|
+
if until == "`" and c == "`":
|
|
144
|
+
self.i += 1
|
|
145
|
+
end_command()
|
|
146
|
+
return
|
|
147
|
+
if c in " \t\r":
|
|
148
|
+
end_word()
|
|
149
|
+
self.i += 1
|
|
150
|
+
elif c == "\n":
|
|
151
|
+
end_command()
|
|
152
|
+
self.i += 1
|
|
153
|
+
for delim, strip in heredocs:
|
|
154
|
+
self._skip_heredoc(delim, strip)
|
|
155
|
+
heredocs.clear()
|
|
156
|
+
elif c in ";&|":
|
|
157
|
+
end_command()
|
|
158
|
+
self.i += 1
|
|
159
|
+
elif c == "(":
|
|
160
|
+
end_command()
|
|
161
|
+
depth += 1
|
|
162
|
+
self.i += 1
|
|
163
|
+
elif c == ")":
|
|
164
|
+
end_command()
|
|
165
|
+
self.i += 1
|
|
166
|
+
if until == ")" and depth == 0:
|
|
167
|
+
return
|
|
168
|
+
depth = max(0, depth - 1)
|
|
169
|
+
elif c in "<>":
|
|
170
|
+
if word is not None and "".join(word).isdigit():
|
|
171
|
+
word = None # file descriptor number, e.g. 2>&1
|
|
172
|
+
else:
|
|
173
|
+
end_word()
|
|
174
|
+
if self.s.startswith("(", self.i + 1): # process substitution <(...) >(...)
|
|
175
|
+
self.i += 2
|
|
176
|
+
self.parse(until=")")
|
|
177
|
+
continue
|
|
178
|
+
op_start = self.i
|
|
179
|
+
self.i += 1
|
|
180
|
+
while self.i < len(s) and s[self.i] in "<>&|-":
|
|
181
|
+
self.i += 1
|
|
182
|
+
op = s[op_start:self.i]
|
|
183
|
+
if op in ("<<", "<<-"):
|
|
184
|
+
pending_heredoc = op == "<<-"
|
|
185
|
+
skip_next_word = True
|
|
186
|
+
elif c == "#" and word is None:
|
|
187
|
+
while self.i < len(s) and s[self.i] != "\n":
|
|
188
|
+
self.i += 1
|
|
189
|
+
elif c == "\\":
|
|
190
|
+
if self.s.startswith("\n", self.i + 1):
|
|
191
|
+
self.i += 2 # line continuation
|
|
192
|
+
else:
|
|
193
|
+
start_word().append(s[self.i + 1:self.i + 2])
|
|
194
|
+
self.i += 2
|
|
195
|
+
elif c == "'":
|
|
196
|
+
end = s.find("'", self.i + 1)
|
|
197
|
+
end = len(s) if end < 0 else end
|
|
198
|
+
start_word().extend(s[self.i + 1:end])
|
|
199
|
+
self.i = end + 1
|
|
200
|
+
elif c == '"':
|
|
201
|
+
self.i += 1
|
|
202
|
+
self._double_quoted(start_word())
|
|
203
|
+
elif c == "`":
|
|
204
|
+
self.i += 1
|
|
205
|
+
self.parse(until="`")
|
|
206
|
+
start_word().append("$")
|
|
207
|
+
elif c == "$":
|
|
208
|
+
self._dollar(start_word())
|
|
209
|
+
else:
|
|
210
|
+
start_word().append(c)
|
|
211
|
+
self.i += 1
|
|
212
|
+
end_command()
|
|
213
|
+
|
|
214
|
+
def _double_quoted(self, word: list[str]) -> None:
|
|
215
|
+
s = self.s
|
|
216
|
+
while self.i < len(s):
|
|
217
|
+
c = s[self.i]
|
|
218
|
+
if c == '"':
|
|
219
|
+
self.i += 1
|
|
220
|
+
return
|
|
221
|
+
if c == "\\" and self.i + 1 < len(s):
|
|
222
|
+
word.append(s[self.i + 1])
|
|
223
|
+
self.i += 2
|
|
224
|
+
elif c == "`":
|
|
225
|
+
self.i += 1
|
|
226
|
+
self.parse(until="`")
|
|
227
|
+
word.append("$")
|
|
228
|
+
elif c == "$":
|
|
229
|
+
self._dollar(word)
|
|
230
|
+
else:
|
|
231
|
+
word.append(c)
|
|
232
|
+
self.i += 1
|
|
233
|
+
|
|
234
|
+
def _dollar(self, word: list[str]) -> None:
|
|
235
|
+
s = self.s
|
|
236
|
+
word.append("$")
|
|
237
|
+
if s.startswith("$((", self.i): # arithmetic: no commands inside
|
|
238
|
+
self.i = self._skip_balanced(self.i + 1, "(", ")")
|
|
239
|
+
elif s.startswith("$(", self.i):
|
|
240
|
+
self.i += 2
|
|
241
|
+
self.parse(until=")")
|
|
242
|
+
elif s.startswith("${", self.i):
|
|
243
|
+
self.i = self._skip_balanced(self.i + 1, "{", "}")
|
|
244
|
+
else:
|
|
245
|
+
self.i += 1
|
|
246
|
+
|
|
247
|
+
def _skip_balanced(self, start: int, open_: str, close: str) -> int:
|
|
248
|
+
depth, i = 0, start
|
|
249
|
+
while i < len(self.s):
|
|
250
|
+
if self.s[i] == open_:
|
|
251
|
+
depth += 1
|
|
252
|
+
elif self.s[i] == close:
|
|
253
|
+
depth -= 1
|
|
254
|
+
if depth == 0:
|
|
255
|
+
return i + 1
|
|
256
|
+
i += 1
|
|
257
|
+
return i
|
|
258
|
+
|
|
259
|
+
def _skip_heredoc(self, delim: str, strip_tabs: bool) -> None:
|
|
260
|
+
s = self.s
|
|
261
|
+
while self.i < len(s):
|
|
262
|
+
end = s.find("\n", self.i)
|
|
263
|
+
end = len(s) if end < 0 else end
|
|
264
|
+
line = s[self.i:end]
|
|
265
|
+
self.i = end + 1
|
|
266
|
+
if (line.lstrip("\t") if strip_tabs else line) == delim:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _programs_in_words(words: list[str], out: list[str], depth: int) -> None:
|
|
271
|
+
"""Find the program of one simple command, following wrappers/shells."""
|
|
272
|
+
i = 0
|
|
273
|
+
while i < len(words) and (words[i] in _KEYWORD_PREFIXES or _ASSIGNMENT_RE.match(words[i])):
|
|
274
|
+
i += 1
|
|
275
|
+
if i >= len(words) or words[i] in _NON_COMMANDS:
|
|
276
|
+
return
|
|
277
|
+
program, args = words[i], words[i + 1:]
|
|
278
|
+
out.append(program)
|
|
279
|
+
name = os.path.basename(program)
|
|
280
|
+
|
|
281
|
+
if name in SHELLS:
|
|
282
|
+
for j, arg in enumerate(args):
|
|
283
|
+
if arg.startswith("-") and not arg.startswith("--") and "c" in arg[1:]:
|
|
284
|
+
if j + 1 < len(args):
|
|
285
|
+
_collect(args[j + 1], out, depth + 1)
|
|
286
|
+
return
|
|
287
|
+
return
|
|
288
|
+
if name == "eval":
|
|
289
|
+
_collect(" ".join(args), out, depth + 1)
|
|
290
|
+
return
|
|
291
|
+
if name in WRAPPERS:
|
|
292
|
+
if name == "command" and any(a in ("-v", "-V") for a in args):
|
|
293
|
+
return # only looks the name up
|
|
294
|
+
takes_arg = _WRAPPER_OPTS_WITH_ARG.get(name, set())
|
|
295
|
+
j = 0
|
|
296
|
+
while j < len(args) and (
|
|
297
|
+
args[j].startswith("-") or _ASSIGNMENT_RE.match(args[j]) or _WRAPPER_ARG_RE.match(args[j])
|
|
298
|
+
):
|
|
299
|
+
j += 2 if args[j] in takes_arg else 1
|
|
300
|
+
if j < len(args):
|
|
301
|
+
_programs_in_words(args[j:], out, depth)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _collect(command: str, out: list[str], depth: int) -> None:
|
|
305
|
+
if depth > 20:
|
|
306
|
+
raise PolicyError("command is nested too deeply to analyse")
|
|
307
|
+
parser = _Parser(command)
|
|
308
|
+
parser.parse()
|
|
309
|
+
for words in parser.commands:
|
|
310
|
+
_programs_in_words(words, out, depth)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def programs_in(command: str) -> list[str]:
|
|
314
|
+
"""Programs (as written, e.g. ``ssh`` or ``/usr/bin/ssh``) that ``command`` would run."""
|
|
315
|
+
out: list[str] = []
|
|
316
|
+
_collect(command, out, 0)
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------- policy
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _matches(program: str, patterns: tuple[str, ...]) -> str | None:
|
|
324
|
+
base = os.path.basename(program)
|
|
325
|
+
for pattern in patterns:
|
|
326
|
+
target = os.path.normpath(program) if "/" in pattern else base
|
|
327
|
+
if fnmatchcase(target, pattern):
|
|
328
|
+
return pattern
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class ToolPolicy:
|
|
334
|
+
allow: tuple[str, ...] = DEFAULT_ALLOW
|
|
335
|
+
deny: tuple[str, ...] = DEFAULT_DENY
|
|
336
|
+
source: str = "built-in default"
|
|
337
|
+
|
|
338
|
+
def check(self, command: str) -> str | None:
|
|
339
|
+
"""Return a reason if ``command`` is not allowed, else None."""
|
|
340
|
+
try:
|
|
341
|
+
programs = programs_in(command)
|
|
342
|
+
except PolicyError as exc:
|
|
343
|
+
return str(exc)
|
|
344
|
+
for program in programs:
|
|
345
|
+
if pattern := _matches(program, self.deny):
|
|
346
|
+
return f"program '{program}' is denied (deny pattern '{pattern}')"
|
|
347
|
+
if os.path.basename(program) in BUILTINS:
|
|
348
|
+
continue
|
|
349
|
+
if not _matches(program, self.allow):
|
|
350
|
+
return f"program '{program}' is not in the allow list"
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
def describe(self) -> str:
|
|
354
|
+
return f"allow={list(self.allow)} deny={list(self.deny)} ({self.source})"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _pattern_list(value, key: str, path: Path) -> tuple[str, ...]:
|
|
358
|
+
if isinstance(value, str):
|
|
359
|
+
value = [value]
|
|
360
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
|
|
361
|
+
raise PolicyError(f"{path}: '{key}' must be a list of program names or patterns")
|
|
362
|
+
return tuple(v.strip() for v in value)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def load_policy(path: str | os.PathLike) -> ToolPolicy:
|
|
366
|
+
p = Path(path).expanduser()
|
|
367
|
+
try:
|
|
368
|
+
# BaseLoader keeps every scalar a string: program names such as
|
|
369
|
+
# "true", "yes" or "7z" must not turn into booleans/numbers.
|
|
370
|
+
data = yaml.load(p.read_text(), Loader=yaml.BaseLoader)
|
|
371
|
+
except OSError as exc:
|
|
372
|
+
raise PolicyError(f"cannot read tools file {p}: {exc.strerror or exc}") from exc
|
|
373
|
+
except yaml.YAMLError as exc:
|
|
374
|
+
raise PolicyError(f"invalid YAML in {p}: {exc}") from exc
|
|
375
|
+
data = data or {}
|
|
376
|
+
if not isinstance(data, dict):
|
|
377
|
+
raise PolicyError(f"{p}: top level must be a mapping with 'allow' and/or 'deny'")
|
|
378
|
+
unknown = set(data) - {"allow", "deny"}
|
|
379
|
+
if unknown:
|
|
380
|
+
raise PolicyError(f"{p}: unknown key(s): {', '.join(sorted(unknown))}")
|
|
381
|
+
# A missing or empty key keeps its default; an explicit empty list
|
|
382
|
+
# ("deny: []") means "nothing" (e.g. to allow ssh).
|
|
383
|
+
allow = _pattern_list(data["allow"], "allow", p) if data.get("allow") not in (None, "") else DEFAULT_ALLOW
|
|
384
|
+
deny = _pattern_list(data["deny"], "deny", p) if data.get("deny") not in (None, "") else DEFAULT_DENY
|
|
385
|
+
return ToolPolicy(allow=allow, deny=deny, source=str(p))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class PolicyProvider:
|
|
389
|
+
"""Serves the current policy, reloading tools.yaml when it changes.
|
|
390
|
+
|
|
391
|
+
If a reload fails (invalid YAML), the last good policy stays active.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
def __init__(self, path: str | os.PathLike | None = None, required: bool = False):
|
|
395
|
+
self.path = Path(path or DEFAULT_TOOLS_FILE).expanduser()
|
|
396
|
+
self._mtime: float | None = None
|
|
397
|
+
self._policy = ToolPolicy()
|
|
398
|
+
if self.path.exists():
|
|
399
|
+
# Fail fast at startup on an invalid file (later reloads keep the last good policy).
|
|
400
|
+
self._mtime = self.path.stat().st_mtime
|
|
401
|
+
self._policy = load_policy(self.path)
|
|
402
|
+
elif required:
|
|
403
|
+
raise PolicyError(f"tools file not found: {self.path}")
|
|
404
|
+
|
|
405
|
+
def current(self) -> ToolPolicy:
|
|
406
|
+
try:
|
|
407
|
+
mtime = self.path.stat().st_mtime
|
|
408
|
+
except FileNotFoundError:
|
|
409
|
+
if self._mtime is not None:
|
|
410
|
+
log.warning("tools file %s removed; using built-in default policy", self.path)
|
|
411
|
+
self._mtime, self._policy = None, ToolPolicy()
|
|
412
|
+
return self._policy
|
|
413
|
+
if mtime != self._mtime:
|
|
414
|
+
self._mtime = mtime
|
|
415
|
+
try:
|
|
416
|
+
self._policy = load_policy(self.path)
|
|
417
|
+
log.info("tools policy loaded: %s", self._policy.describe())
|
|
418
|
+
except PolicyError as exc:
|
|
419
|
+
log.error("%s; keeping previous policy: %s", exc, self._policy.describe())
|
|
420
|
+
return self._policy
|
remote_exec/security.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Authentication and transport security helpers shared by server and client.
|
|
2
|
+
|
|
3
|
+
Two independent mechanisms, which can be combined:
|
|
4
|
+
|
|
5
|
+
* Bearer token: a shared secret read from a file, sent by the client as
|
|
6
|
+
``authorization: Bearer <token>`` metadata and checked in constant time.
|
|
7
|
+
* TLS / mTLS: server certificate, optionally requiring client certificates
|
|
8
|
+
signed by a given CA.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hmac
|
|
14
|
+
import ipaddress
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import stat
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import grpc
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
audit = logging.getLogger("remote_exec.audit")
|
|
26
|
+
|
|
27
|
+
AUTH_METADATA_KEY = "authorization"
|
|
28
|
+
BEARER_PREFIX = "Bearer "
|
|
29
|
+
DEFAULT_TOKEN_FILE = Path("~/.config/remote-exec/token")
|
|
30
|
+
TOKEN_BYTES = 32
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SecurityError(Exception):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generate_token_file(path: str | os.PathLike | None = None, force: bool = False) -> Path:
|
|
38
|
+
"""Write a new random token (hex, 256 bit) to ``path`` with mode 600.
|
|
39
|
+
|
|
40
|
+
The parent directory is created with mode 700 if missing. An existing file
|
|
41
|
+
is only replaced when ``force`` is true, because replacing it locks out
|
|
42
|
+
every client that still has the old token.
|
|
43
|
+
"""
|
|
44
|
+
target = Path(path or DEFAULT_TOKEN_FILE).expanduser()
|
|
45
|
+
if target.exists() and not force:
|
|
46
|
+
raise SecurityError(
|
|
47
|
+
f"token file {target} already exists; use --force to replace it "
|
|
48
|
+
"(clients using the old token will be rejected)"
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
52
|
+
# Write to a private temp file and rename it into place, so the token is
|
|
53
|
+
# never readable by others and an existing file is replaced atomically.
|
|
54
|
+
fd, tmp = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
|
|
55
|
+
try:
|
|
56
|
+
with os.fdopen(fd, "w") as f:
|
|
57
|
+
f.write(secrets.token_hex(TOKEN_BYTES) + "\n")
|
|
58
|
+
os.chmod(tmp, 0o600)
|
|
59
|
+
os.replace(tmp, target)
|
|
60
|
+
except BaseException:
|
|
61
|
+
os.unlink(tmp)
|
|
62
|
+
raise
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise SecurityError(f"cannot write token file {target}: {exc.strerror or exc}") from exc
|
|
65
|
+
return target
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_secret_file(path: str | os.PathLike, what: str = "token") -> str:
|
|
69
|
+
p = Path(path).expanduser()
|
|
70
|
+
try:
|
|
71
|
+
st = p.stat()
|
|
72
|
+
value = p.read_text().strip()
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise SecurityError(f"cannot read {what} file {p}: {exc.strerror or exc}") from exc
|
|
75
|
+
if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
76
|
+
log.warning("%s file %s is accessible by group/others; consider chmod 600", what, p)
|
|
77
|
+
if not value:
|
|
78
|
+
raise SecurityError(f"{what} file {p} is empty")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_bytes(path: str | os.PathLike, what: str) -> bytes:
|
|
83
|
+
p = Path(path).expanduser()
|
|
84
|
+
try:
|
|
85
|
+
return p.read_bytes()
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise SecurityError(f"cannot read {what} {p}: {exc.strerror or exc}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_loopback_host(host: str) -> bool:
|
|
91
|
+
if host in ("localhost", "localhost.localdomain"):
|
|
92
|
+
return True
|
|
93
|
+
try:
|
|
94
|
+
return ipaddress.ip_address(host.strip("[]")).is_loopback
|
|
95
|
+
except ValueError:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------- server side
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def server_credentials(cert: str, key: str, client_ca: str | None) -> grpc.ServerCredentials:
|
|
103
|
+
"""TLS credentials; with ``client_ca`` client certificates are required (mTLS)."""
|
|
104
|
+
return grpc.ssl_server_credentials(
|
|
105
|
+
[(read_bytes(key, "TLS key"), read_bytes(cert, "TLS certificate"))],
|
|
106
|
+
root_certificates=read_bytes(client_ca, "client CA") if client_ca else None,
|
|
107
|
+
require_client_auth=bool(client_ca),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TokenAuthInterceptor(grpc.aio.ServerInterceptor):
|
|
112
|
+
"""Rejects calls that do not carry the expected bearer token."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, token: str):
|
|
115
|
+
self._expected = (BEARER_PREFIX + token).encode()
|
|
116
|
+
|
|
117
|
+
def _authorized(self, metadata) -> bool:
|
|
118
|
+
for key, value in metadata or ():
|
|
119
|
+
if key == AUTH_METADATA_KEY:
|
|
120
|
+
got = value.encode() if isinstance(value, str) else value
|
|
121
|
+
return hmac.compare_digest(got, self._expected)
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
async def intercept_service(self, continuation, handler_call_details):
|
|
125
|
+
handler = await continuation(handler_call_details)
|
|
126
|
+
if handler is None or self._authorized(handler_call_details.invocation_metadata):
|
|
127
|
+
return handler
|
|
128
|
+
audit.warning("auth_failed method=%s", handler_call_details.method)
|
|
129
|
+
return _deny_handler(handler)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _deny_handler(handler: grpc.RpcMethodHandler) -> grpc.RpcMethodHandler:
|
|
133
|
+
async def deny(request_or_iterator, context):
|
|
134
|
+
await context.abort(grpc.StatusCode.UNAUTHENTICATED, "missing or invalid authentication token")
|
|
135
|
+
|
|
136
|
+
kwargs = dict(
|
|
137
|
+
request_deserializer=handler.request_deserializer,
|
|
138
|
+
response_serializer=handler.response_serializer,
|
|
139
|
+
)
|
|
140
|
+
if handler.unary_unary:
|
|
141
|
+
return grpc.unary_unary_rpc_method_handler(deny, **kwargs)
|
|
142
|
+
if handler.unary_stream:
|
|
143
|
+
return grpc.unary_stream_rpc_method_handler(deny, **kwargs)
|
|
144
|
+
if handler.stream_unary:
|
|
145
|
+
return grpc.stream_unary_rpc_method_handler(deny, **kwargs)
|
|
146
|
+
return grpc.stream_stream_rpc_method_handler(deny, **kwargs)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def peer_identity(context: grpc.aio.ServicerContext) -> str:
|
|
150
|
+
"""Best-effort description of the caller for audit logs."""
|
|
151
|
+
peer = context.peer() or "unknown"
|
|
152
|
+
try:
|
|
153
|
+
auth = context.auth_context() or {}
|
|
154
|
+
except Exception: # pragma: no cover - defensive
|
|
155
|
+
auth = {}
|
|
156
|
+
cn = auth.get("x509_common_name")
|
|
157
|
+
if cn:
|
|
158
|
+
return f"{peer} cn={cn[0].decode(errors='replace')}"
|
|
159
|
+
return peer
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------- client side
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def channel_credentials(
|
|
166
|
+
ca_cert: str | None, client_cert: str | None, client_key: str | None
|
|
167
|
+
) -> grpc.ChannelCredentials:
|
|
168
|
+
if bool(client_cert) != bool(client_key):
|
|
169
|
+
raise SecurityError("client_cert and client_key must be given together")
|
|
170
|
+
return grpc.ssl_channel_credentials(
|
|
171
|
+
root_certificates=read_bytes(ca_cert, "CA certificate") if ca_cert else None,
|
|
172
|
+
private_key=read_bytes(client_key, "client key") if client_key else None,
|
|
173
|
+
certificate_chain=read_bytes(client_cert, "client certificate") if client_cert else None,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def auth_metadata(token: str | None) -> tuple[tuple[str, str], ...]:
|
|
178
|
+
return ((AUTH_METADATA_KEY, BEARER_PREFIX + token),) if token else ()
|