dploydb 0.1.0__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.
@@ -0,0 +1,641 @@
1
+ """Bounded, redacted execution of untrusted external commands.
2
+
3
+ The runner deliberately returns structured outcomes rather than user-facing
4
+ ``DployDBError`` instances. Only an orchestration layer knows whether a command
5
+ ran before or after production changed, so it must attach those safety facts when
6
+ it converts an unsuccessful result into a CLI failure.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import errno
12
+ import math
13
+ import os
14
+ import signal
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable, Mapping, Sequence
19
+ from dataclasses import dataclass
20
+ from enum import StrEnum
21
+ from pathlib import Path
22
+ from typing import BinaryIO, Final, cast
23
+
24
+ from dploydb.redaction import REDACTION_MARKER, JsonValue, SecretRegistry, is_sensitive_key
25
+
26
+ DEFAULT_MAX_OUTPUT_BYTES: Final = 1024 * 1024
27
+ DEFAULT_TERMINATION_GRACE_SECONDS: Final = 2.0
28
+ DEFAULT_POLL_INTERVAL_SECONDS: Final = 0.05
29
+ _READ_CHUNK_BYTES: Final = 64 * 1024
30
+ _TRUNCATION_MARKER: Final = b"\n...[output truncated]...\n"
31
+
32
+
33
+ class CommandOutcome(StrEnum):
34
+ """Terminal outcome of one bounded command invocation."""
35
+
36
+ SUCCEEDED = "succeeded"
37
+ NONZERO_EXIT = "nonzero_exit"
38
+ START_FAILED = "start_failed"
39
+ TIMED_OUT = "timed_out"
40
+ CANCELLED = "cancelled"
41
+ CLEANUP_FAILED = "cleanup_failed"
42
+
43
+
44
+ class TerminationReason(StrEnum):
45
+ """Reason DployDB attempted to terminate a command process group."""
46
+
47
+ TIMEOUT = "timeout"
48
+ CANCELLATION = "cancellation"
49
+ INTERRUPTION = "interruption"
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class CapturedOutput:
54
+ """One redacted, memory-bounded output stream."""
55
+
56
+ text: str
57
+ total_bytes: int
58
+ retained_bytes: int
59
+ truncated: bool
60
+
61
+ def __post_init__(self) -> None:
62
+ if self.total_bytes < 0 or self.retained_bytes < 0:
63
+ raise ValueError("captured output byte counts must not be negative")
64
+ if self.retained_bytes > self.total_bytes:
65
+ raise ValueError("retained output bytes must not exceed total bytes")
66
+ if self.truncated != (self.retained_bytes < self.total_bytes):
67
+ raise ValueError("captured output truncation metadata is contradictory")
68
+
69
+ def as_evidence(self) -> dict[str, JsonValue]:
70
+ """Return the stable JSON-compatible evidence representation."""
71
+ return {
72
+ "text": self.text,
73
+ "total_bytes": self.total_bytes,
74
+ "retained_bytes": self.retained_bytes,
75
+ "truncated": self.truncated,
76
+ }
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class CommandResult:
81
+ """Redacted terminal evidence for one attempted external command."""
82
+
83
+ command: tuple[str, ...]
84
+ working_directory: str
85
+ environment_keys: tuple[str, ...]
86
+ outcome: CommandOutcome
87
+ exit_code: int | None
88
+ stdout: CapturedOutput
89
+ stderr: CapturedOutput
90
+ duration_seconds: float
91
+ termination_reason: TerminationReason | None = None
92
+ termination_attempted: bool = False
93
+ forced_kill: bool = False
94
+ start_error: str | None = None
95
+ cleanup_error: str | None = None
96
+
97
+ def __post_init__(self) -> None:
98
+ if not self.command:
99
+ raise ValueError("command result requires at least one argument")
100
+ if not math.isfinite(self.duration_seconds) or self.duration_seconds < 0:
101
+ raise ValueError("command duration must be finite and non-negative")
102
+ if self.outcome is CommandOutcome.SUCCEEDED and self.exit_code != 0:
103
+ raise ValueError("successful command requires exit code zero")
104
+ if self.outcome is CommandOutcome.NONZERO_EXIT and self.exit_code in {None, 0}:
105
+ raise ValueError("non-zero command outcome requires a non-zero exit code")
106
+ if self.outcome is CommandOutcome.START_FAILED:
107
+ if self.exit_code is not None or self.start_error is None:
108
+ raise ValueError("start failure requires start_error and no exit code")
109
+ elif self.start_error is not None:
110
+ raise ValueError("start_error is valid only for a start failure")
111
+ if self.outcome is CommandOutcome.CLEANUP_FAILED:
112
+ if self.cleanup_error is None:
113
+ raise ValueError("cleanup failure requires cleanup_error")
114
+ elif self.cleanup_error is not None:
115
+ raise ValueError("cleanup_error is valid only for a cleanup failure")
116
+ if self.outcome in {CommandOutcome.TIMED_OUT, CommandOutcome.CANCELLED}:
117
+ if self.termination_reason is None:
118
+ raise ValueError("terminated command requires a reason")
119
+ if self.outcome is CommandOutcome.TIMED_OUT and not self.termination_attempted:
120
+ raise ValueError("timed-out command requires attempted cleanup")
121
+ if (
122
+ self.outcome is CommandOutcome.CANCELLED
123
+ and self.exit_code is not None
124
+ and not self.termination_attempted
125
+ ):
126
+ raise ValueError("started cancelled command requires attempted cleanup")
127
+ if self.outcome is CommandOutcome.CLEANUP_FAILED and self.termination_reason is None:
128
+ raise ValueError("cleanup failure requires its initiating termination reason")
129
+ if self.forced_kill and not self.termination_attempted:
130
+ raise ValueError("forced kill requires an attempted termination")
131
+
132
+ @property
133
+ def succeeded(self) -> bool:
134
+ """Return whether the command completed with exit code zero."""
135
+ return self.outcome is CommandOutcome.SUCCEEDED
136
+
137
+ def as_evidence(self) -> dict[str, JsonValue]:
138
+ """Return redacted JSON-compatible evidence for persistence or display."""
139
+ return {
140
+ "command": list(self.command),
141
+ "working_directory": self.working_directory,
142
+ "environment_keys": list(self.environment_keys),
143
+ "outcome": self.outcome.value,
144
+ "exit_code": self.exit_code,
145
+ "stdout": self.stdout.as_evidence(),
146
+ "stderr": self.stderr.as_evidence(),
147
+ "duration_seconds": self.duration_seconds,
148
+ "termination_reason": (
149
+ None if self.termination_reason is None else self.termination_reason.value
150
+ ),
151
+ "termination_attempted": self.termination_attempted,
152
+ "forced_kill": self.forced_kill,
153
+ "start_error": self.start_error,
154
+ "cleanup_error": self.cleanup_error,
155
+ }
156
+
157
+
158
+ class _BoundedCapture:
159
+ """Drain a byte stream while retaining only a bounded head and tail."""
160
+
161
+ def __init__(self, limit: int) -> None:
162
+ self._head_limit = limit // 2
163
+ self._tail_limit = limit - self._head_limit
164
+ self._head = bytearray()
165
+ self._tail = bytearray()
166
+ self._total_bytes = 0
167
+ self._lock = threading.Lock()
168
+
169
+ def append(self, data: bytes) -> None:
170
+ with self._lock:
171
+ self._total_bytes += len(data)
172
+ head_missing = self._head_limit - len(self._head)
173
+ if head_missing > 0:
174
+ self._head.extend(data[:head_missing])
175
+ data = data[head_missing:]
176
+ if not data or self._tail_limit == 0:
177
+ return
178
+ if len(data) >= self._tail_limit:
179
+ self._tail[:] = data[-self._tail_limit :]
180
+ return
181
+ self._tail.extend(data)
182
+ overflow = len(self._tail) - self._tail_limit
183
+ if overflow > 0:
184
+ del self._tail[:overflow]
185
+
186
+ def snapshot(self, secrets: SecretRegistry) -> CapturedOutput:
187
+ with self._lock:
188
+ head = bytes(self._head)
189
+ tail = bytes(self._tail)
190
+ total_bytes = self._total_bytes
191
+ retained_bytes = len(head) + len(tail)
192
+ truncated = retained_bytes < total_bytes
193
+ raw = head + (_TRUNCATION_MARKER if truncated else b"") + tail
194
+ text = raw.decode("utf-8", errors="replace")
195
+ return CapturedOutput(
196
+ text=secrets.redact_text(text),
197
+ total_bytes=total_bytes,
198
+ retained_bytes=retained_bytes,
199
+ truncated=truncated,
200
+ )
201
+
202
+
203
+ @dataclass(slots=True)
204
+ class _Reader:
205
+ pipe: BinaryIO
206
+ capture: _BoundedCapture
207
+ thread: threading.Thread | None = None
208
+ error: BaseException | None = None
209
+
210
+ def start(self, name: str) -> None:
211
+ self.thread = threading.Thread(target=self._drain, name=name, daemon=True)
212
+ self.thread.start()
213
+
214
+ def _drain(self) -> None:
215
+ try:
216
+ while True:
217
+ chunk = self.pipe.read(_READ_CHUNK_BYTES)
218
+ if not chunk:
219
+ return
220
+ self.capture.append(chunk)
221
+ except BaseException as exc: # preserved for the controlling thread
222
+ self.error = exc
223
+ finally:
224
+ try:
225
+ self.pipe.close()
226
+ except OSError:
227
+ pass
228
+
229
+ @property
230
+ def alive(self) -> bool:
231
+ return self.thread is not None and self.thread.is_alive()
232
+
233
+ def join(self, timeout: float) -> None:
234
+ if self.thread is not None:
235
+ self.thread.join(timeout=max(0.0, timeout))
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class _CleanupResult:
240
+ forced_kill: bool
241
+ error: str | None
242
+
243
+
244
+ class SubprocessRunner:
245
+ """Execute commands with mandatory bounds, redaction, and group cleanup."""
246
+
247
+ def __init__(
248
+ self,
249
+ *,
250
+ secrets: SecretRegistry,
251
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
252
+ termination_grace_seconds: float = DEFAULT_TERMINATION_GRACE_SECONDS,
253
+ poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
254
+ clock: Callable[[], float] = time.monotonic,
255
+ ) -> None:
256
+ self.max_output_bytes = _positive_integer(max_output_bytes, "max_output_bytes")
257
+ self.termination_grace_seconds = _positive_seconds(
258
+ termination_grace_seconds, "termination_grace_seconds"
259
+ )
260
+ self.poll_interval_seconds = _positive_seconds(
261
+ poll_interval_seconds, "poll_interval_seconds"
262
+ )
263
+ self.secrets = secrets
264
+ self._clock = clock
265
+
266
+ def __repr__(self) -> str:
267
+ return (
268
+ "SubprocessRunner("
269
+ f"max_output_bytes={self.max_output_bytes}, "
270
+ f"termination_grace_seconds={self.termination_grace_seconds}, "
271
+ f"poll_interval_seconds={self.poll_interval_seconds}, "
272
+ f"secrets={self.secrets!r})"
273
+ )
274
+
275
+ def run(
276
+ self,
277
+ command: Sequence[str],
278
+ *,
279
+ timeout_seconds: float,
280
+ environment: Mapping[str, str],
281
+ working_directory: Path | None = None,
282
+ cancellation_event: threading.Event | None = None,
283
+ ) -> CommandResult:
284
+ """Run one command and return redacted evidence for every expected outcome."""
285
+ arguments = _validate_command(command)
286
+ timeout = _positive_seconds(timeout_seconds, "timeout_seconds")
287
+ child_environment = _validate_environment(environment)
288
+ directory = _working_directory(working_directory)
289
+
290
+ for name, value in child_environment.items():
291
+ if is_sensitive_key(name):
292
+ self.secrets.register(value)
293
+
294
+ safe_command = _redacted_command(arguments, self.secrets)
295
+ safe_directory = self.secrets.redact_text(str(directory))
296
+ safe_environment_keys = tuple(
297
+ sorted(self.secrets.redact_text(name) for name in child_environment)
298
+ )
299
+ empty = CapturedOutput(text="", total_bytes=0, retained_bytes=0, truncated=False)
300
+ started_at = self._clock()
301
+
302
+ if cancellation_event is not None and cancellation_event.is_set():
303
+ return CommandResult(
304
+ command=safe_command,
305
+ working_directory=safe_directory,
306
+ environment_keys=safe_environment_keys,
307
+ outcome=CommandOutcome.CANCELLED,
308
+ exit_code=None,
309
+ stdout=empty,
310
+ stderr=empty,
311
+ duration_seconds=self._duration(started_at),
312
+ termination_reason=TerminationReason.CANCELLATION,
313
+ )
314
+
315
+ try:
316
+ process = subprocess.Popen(
317
+ arguments,
318
+ cwd=directory,
319
+ env=child_environment,
320
+ stdin=subprocess.DEVNULL,
321
+ stdout=subprocess.PIPE,
322
+ stderr=subprocess.PIPE,
323
+ shell=False,
324
+ start_new_session=True,
325
+ close_fds=True,
326
+ )
327
+ except OSError as exc:
328
+ return CommandResult(
329
+ command=safe_command,
330
+ working_directory=safe_directory,
331
+ environment_keys=safe_environment_keys,
332
+ outcome=CommandOutcome.START_FAILED,
333
+ exit_code=None,
334
+ stdout=empty,
335
+ stderr=empty,
336
+ duration_seconds=self._duration(started_at),
337
+ start_error=self._safe_exception(exc),
338
+ )
339
+
340
+ assert process.stdout is not None
341
+ assert process.stderr is not None
342
+ stdout_reader = _Reader(
343
+ cast(BinaryIO, process.stdout), _BoundedCapture(self.max_output_bytes)
344
+ )
345
+ stderr_reader = _Reader(
346
+ cast(BinaryIO, process.stderr), _BoundedCapture(self.max_output_bytes)
347
+ )
348
+ readers = (stdout_reader, stderr_reader)
349
+ stdout_reader.start(f"dploydb-stdout-{process.pid}")
350
+ stderr_reader.start(f"dploydb-stderr-{process.pid}")
351
+ deadline = started_at + timeout
352
+
353
+ try:
354
+ reason = self._wait_for_completion(
355
+ process,
356
+ readers,
357
+ deadline=deadline,
358
+ cancellation_event=cancellation_event,
359
+ )
360
+ if reason is None:
361
+ reader_error = self._reader_error(readers)
362
+ if reader_error is not None:
363
+ cleanup = self._terminate_group(process, readers)
364
+ errors = [reader_error]
365
+ if cleanup.error is not None:
366
+ errors.append(cleanup.error)
367
+ return self._result(
368
+ safe_command=safe_command,
369
+ safe_directory=safe_directory,
370
+ safe_environment_keys=safe_environment_keys,
371
+ process=process,
372
+ readers=readers,
373
+ started_at=started_at,
374
+ outcome=CommandOutcome.CLEANUP_FAILED,
375
+ termination_reason=TerminationReason.INTERRUPTION,
376
+ termination_attempted=True,
377
+ forced_kill=cleanup.forced_kill,
378
+ cleanup_error="; ".join(errors),
379
+ )
380
+ outcome = (
381
+ CommandOutcome.SUCCEEDED
382
+ if process.returncode == 0
383
+ else CommandOutcome.NONZERO_EXIT
384
+ )
385
+ return self._result(
386
+ safe_command=safe_command,
387
+ safe_directory=safe_directory,
388
+ safe_environment_keys=safe_environment_keys,
389
+ process=process,
390
+ readers=readers,
391
+ started_at=started_at,
392
+ outcome=outcome,
393
+ )
394
+
395
+ cleanup = self._terminate_group(process, readers)
396
+ outcome = (
397
+ CommandOutcome.CLEANUP_FAILED
398
+ if cleanup.error is not None
399
+ else (
400
+ CommandOutcome.TIMED_OUT
401
+ if reason is TerminationReason.TIMEOUT
402
+ else CommandOutcome.CANCELLED
403
+ )
404
+ )
405
+ return self._result(
406
+ safe_command=safe_command,
407
+ safe_directory=safe_directory,
408
+ safe_environment_keys=safe_environment_keys,
409
+ process=process,
410
+ readers=readers,
411
+ started_at=started_at,
412
+ outcome=outcome,
413
+ termination_reason=reason,
414
+ termination_attempted=True,
415
+ forced_kill=cleanup.forced_kill,
416
+ cleanup_error=cleanup.error,
417
+ )
418
+ except BaseException as exc:
419
+ cleanup = self._terminate_group(process, readers)
420
+ if cleanup.error is not None:
421
+ exc.add_note(
422
+ "DployDB subprocess cleanup could not be proven: "
423
+ + self.secrets.redact_text(cleanup.error)
424
+ )
425
+ raise
426
+
427
+ def _wait_for_completion(
428
+ self,
429
+ process: subprocess.Popen[bytes],
430
+ readers: tuple[_Reader, _Reader],
431
+ *,
432
+ deadline: float,
433
+ cancellation_event: threading.Event | None,
434
+ ) -> TerminationReason | None:
435
+ while True:
436
+ process.poll()
437
+ if process.returncode is not None and not any(reader.alive for reader in readers):
438
+ return None
439
+ if cancellation_event is not None and cancellation_event.is_set():
440
+ return TerminationReason.CANCELLATION
441
+ remaining = deadline - self._clock()
442
+ if remaining <= 0:
443
+ return TerminationReason.TIMEOUT
444
+ time.sleep(min(self.poll_interval_seconds, remaining))
445
+
446
+ def _terminate_group(
447
+ self,
448
+ process: subprocess.Popen[bytes],
449
+ readers: tuple[_Reader, _Reader],
450
+ ) -> _CleanupResult:
451
+ errors: list[str] = []
452
+ forced_kill = False
453
+ group_id = process.pid
454
+
455
+ term_error = self._signal_group(group_id, signal.SIGTERM)
456
+ if term_error is not None:
457
+ errors.append(term_error)
458
+ if not self._wait_for_group_cleanup(process, readers, self.termination_grace_seconds):
459
+ forced_kill = True
460
+ kill_error = self._signal_group(group_id, signal.SIGKILL)
461
+ if kill_error is not None:
462
+ errors.append(kill_error)
463
+ if not self._wait_for_group_cleanup(process, readers, self.termination_grace_seconds):
464
+ errors.append("process group remained after forced termination")
465
+
466
+ reader_error = self._reader_error(readers)
467
+ if reader_error is not None:
468
+ errors.append(reader_error)
469
+ return _CleanupResult(
470
+ forced_kill=forced_kill,
471
+ error=None if not errors else self.secrets.redact_text("; ".join(errors)),
472
+ )
473
+
474
+ def _wait_for_group_cleanup(
475
+ self,
476
+ process: subprocess.Popen[bytes],
477
+ readers: tuple[_Reader, _Reader],
478
+ timeout: float,
479
+ ) -> bool:
480
+ deadline = self._clock() + timeout
481
+ while True:
482
+ process.poll()
483
+ group_exists = _process_group_exists(process.pid)
484
+ readers_alive = any(reader.alive for reader in readers)
485
+ if not group_exists and process.returncode is not None and not readers_alive:
486
+ return True
487
+ remaining = deadline - self._clock()
488
+ if remaining <= 0:
489
+ break
490
+ for reader in readers:
491
+ reader.join(min(self.poll_interval_seconds, remaining))
492
+ if process.returncode is None:
493
+ try:
494
+ process.wait(timeout=min(self.poll_interval_seconds, remaining))
495
+ except subprocess.TimeoutExpired:
496
+ pass
497
+
498
+ process.poll()
499
+ return (
500
+ not _process_group_exists(process.pid)
501
+ and process.returncode is not None
502
+ and not any(reader.alive for reader in readers)
503
+ )
504
+
505
+ @staticmethod
506
+ def _signal_group(group_id: int, sig: signal.Signals) -> str | None:
507
+ try:
508
+ os.killpg(group_id, sig)
509
+ except ProcessLookupError:
510
+ return None
511
+ except OSError as exc:
512
+ if exc.errno == errno.ESRCH:
513
+ return None
514
+ return f"{sig.name} could not be sent to process group: {exc}"
515
+ return None
516
+
517
+ def _reader_error(self, readers: tuple[_Reader, _Reader]) -> str | None:
518
+ details = [self._safe_exception(reader.error) for reader in readers if reader.error]
519
+ return None if not details else "output capture failed: " + "; ".join(details)
520
+
521
+ def _result(
522
+ self,
523
+ *,
524
+ safe_command: tuple[str, ...],
525
+ safe_directory: str,
526
+ safe_environment_keys: tuple[str, ...],
527
+ process: subprocess.Popen[bytes],
528
+ readers: tuple[_Reader, _Reader],
529
+ started_at: float,
530
+ outcome: CommandOutcome,
531
+ termination_reason: TerminationReason | None = None,
532
+ termination_attempted: bool = False,
533
+ forced_kill: bool = False,
534
+ cleanup_error: str | None = None,
535
+ ) -> CommandResult:
536
+ return CommandResult(
537
+ command=safe_command,
538
+ working_directory=safe_directory,
539
+ environment_keys=safe_environment_keys,
540
+ outcome=outcome,
541
+ exit_code=process.returncode,
542
+ stdout=readers[0].capture.snapshot(self.secrets),
543
+ stderr=readers[1].capture.snapshot(self.secrets),
544
+ duration_seconds=self._duration(started_at),
545
+ termination_reason=termination_reason,
546
+ termination_attempted=termination_attempted,
547
+ forced_kill=forced_kill,
548
+ cleanup_error=cleanup_error,
549
+ )
550
+
551
+ def _duration(self, started_at: float) -> float:
552
+ return max(0.0, self._clock() - started_at)
553
+
554
+ def _safe_exception(self, exc: BaseException) -> str:
555
+ return self.secrets.redact_text(f"{type(exc).__name__}: {exc}")
556
+
557
+
558
+ def _validate_command(command: Sequence[str]) -> tuple[str, ...]:
559
+ if isinstance(command, str | bytes) or not isinstance(command, Sequence):
560
+ raise TypeError("command must be an argument sequence")
561
+ if not command:
562
+ raise ValueError("command must contain at least one argument")
563
+ arguments: list[str] = []
564
+ for argument in command:
565
+ if not isinstance(argument, str):
566
+ raise TypeError("command arguments must be strings")
567
+ if not argument:
568
+ raise ValueError("command arguments must not be empty")
569
+ if "\x00" in argument:
570
+ raise ValueError("command arguments must not contain NUL bytes")
571
+ arguments.append(argument)
572
+ return tuple(arguments)
573
+
574
+
575
+ def _validate_environment(environment: Mapping[str, str]) -> dict[str, str]:
576
+ if not isinstance(environment, Mapping):
577
+ raise TypeError("environment must be a string mapping")
578
+ result: dict[str, str] = {}
579
+ for name, value in environment.items():
580
+ if not isinstance(name, str) or not isinstance(value, str):
581
+ raise TypeError("environment names and values must be strings")
582
+ if not name:
583
+ raise ValueError("environment names must not be empty")
584
+ if "=" in name or "\x00" in name or "\x00" in value:
585
+ raise ValueError("environment contains an invalid name or value")
586
+ result[name] = value
587
+ return result
588
+
589
+
590
+ def _working_directory(value: Path | None) -> Path:
591
+ if value is None:
592
+ return Path.cwd()
593
+ if not isinstance(value, Path):
594
+ raise TypeError("working_directory must be a pathlib.Path")
595
+ return value if value.is_absolute() else Path.cwd() / value
596
+
597
+
598
+ def _positive_seconds(value: float, name: str) -> float:
599
+ if isinstance(value, bool) or not isinstance(value, int | float):
600
+ raise TypeError(f"{name} must be a number")
601
+ result = float(value)
602
+ if not math.isfinite(result) or result <= 0:
603
+ raise ValueError(f"{name} must be finite and greater than zero")
604
+ return result
605
+
606
+
607
+ def _positive_integer(value: int, name: str) -> int:
608
+ if isinstance(value, bool) or not isinstance(value, int):
609
+ raise TypeError(f"{name} must be an integer")
610
+ if value <= 0:
611
+ raise ValueError(f"{name} must be greater than zero")
612
+ return value
613
+
614
+
615
+ def _redacted_command(command: tuple[str, ...], secrets: SecretRegistry) -> tuple[str, ...]:
616
+ redacted: list[str] = []
617
+ redact_next = False
618
+ for argument in command:
619
+ if redact_next:
620
+ redacted.append(REDACTION_MARKER)
621
+ redact_next = False
622
+ continue
623
+ redacted.append(secrets.redact_text(argument))
624
+ option = argument.removeprefix("--")
625
+ if argument.startswith("--") and "=" not in option and is_sensitive_key(option):
626
+ redact_next = True
627
+ return tuple(redacted)
628
+
629
+
630
+ def _process_group_exists(group_id: int) -> bool:
631
+ try:
632
+ os.killpg(group_id, 0)
633
+ except ProcessLookupError:
634
+ return False
635
+ except PermissionError:
636
+ return True
637
+ except OSError as exc:
638
+ if exc.errno == errno.ESRCH:
639
+ return False
640
+ return True
641
+ return True