obvyr-cli 2.5.0__tar.gz → 2.7.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: obvyr-cli
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Command-line wrapper to allow Obvyr to capture evidence from any machine.
5
5
  License: Proprietary
6
6
  Author: Wyrd Technology Ltd
@@ -8,12 +8,13 @@ Requires-Python: >=3.14,<4.0
8
8
  Classifier: License :: Other/Proprietary License
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.14
11
- Requires-Dist: click (==8.3.3)
11
+ Classifier: Programming Language :: Python :: 3.15
12
+ Requires-Dist: click (==8.5.0)
12
13
  Requires-Dist: httpx (==0.28.1)
13
- Requires-Dist: orjson (==3.11.8)
14
- Requires-Dist: pydantic (==2.13.3)
15
- Requires-Dist: pydantic-settings (==2.14.0)
16
- Requires-Dist: python-dotenv (==1.2.2)
14
+ Requires-Dist: orjson (==3.12.0)
15
+ Requires-Dist: pydantic (==2.13.5)
16
+ Requires-Dist: pydantic-settings (==2.15.0)
17
+ Requires-Dist: python-dotenv (==1.2.4)
17
18
  Requires-Dist: tenacity (==9.1.4)
18
19
  Requires-Dist: toml (==0.10.2)
19
20
  Requires-Dist: zstandard (==0.25.0)
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["poetry-core==2.3.2"]
2
+ requires = ["poetry-core==2.5.0"]
3
3
  build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "obvyr-cli"
7
- version = "2.5.0"
7
+ version = "2.7.0"
8
8
  description = "Command-line wrapper to allow Obvyr to capture evidence from any machine."
9
9
  authors = ["Wyrd Technology Ltd"]
10
10
  license = "Proprietary"
@@ -28,26 +28,26 @@ include = "obvyr_cli"
28
28
 
29
29
  [tool.poetry.dependencies]
30
30
  python = ">=3.14,<4.0"
31
- python-dotenv = "1.2.2"
32
- click = "8.3.3"
33
- pydantic = "2.13.3"
34
- pydantic-settings = "2.14.0"
31
+ python-dotenv = "1.2.4"
32
+ click = "8.5.0"
33
+ pydantic = "2.13.5"
34
+ pydantic-settings = "2.15.0"
35
35
  httpx = "0.28.1"
36
36
  tenacity = "9.1.4"
37
37
  toml = "0.10.2"
38
- orjson = "3.11.8"
38
+ orjson = "3.12.0"
39
39
  zstandard = "0.25.0"
40
40
 
41
41
  [tool.poetry.group.dev.dependencies]
42
- ruff = "0.15.12"
42
+ ruff = "0.16.10"
43
43
  mypy = "1.20.2"
44
- pytest = "9.0.3"
44
+ pytest = "9.1.1"
45
45
  pytest-cov = "7.1.0"
46
- black = "26.3.1"
47
- pyproject-fmt = "2.21.1"
48
- pytest-subprocess = "1.5.4"
46
+ black = "26.5.1"
47
+ pyproject-fmt = "2.29.4"
48
+ pytest-subprocess = "1.6.0"
49
49
  types-toml = "^0.10.8.20240310"
50
- poethepoet = "0.44.0"
50
+ poethepoet = "0.48.0"
51
51
 
52
52
  [tool.poetry.scripts]
53
53
  obvyr = "obvyr_cli.cli:cli_run_process"
@@ -9,7 +9,6 @@ import io
9
9
  import json
10
10
  import logging
11
11
  import mimetypes
12
- import os
13
12
  import pathlib
14
13
  import tarfile
15
14
  import tempfile
@@ -204,6 +203,7 @@ def build_artifacts_tar_zst(
204
203
  attachment_paths: Optional[list[pathlib.Path]] = None,
205
204
  tmp_dir: Optional[pathlib.Path] = None,
206
205
  tags: Optional[list[str]] = None,
206
+ env: Optional[Dict[str, str]] = None,
207
207
  ) -> pathlib.Path:
208
208
  """
209
209
  Build artifacts.tar.zst archive from command execution data.
@@ -218,6 +218,7 @@ def build_artifacts_tar_zst(
218
218
  attachment_paths: Optional list of attachment files to include
219
219
  tmp_dir: Optional temporary directory for output file
220
220
  tags: Optional list of tags to include in command.json
221
+ env: Environment variables to include in command.json (default: none)
221
222
 
222
223
  Returns:
223
224
  Path to the created artifacts.tar.zst file
@@ -246,7 +247,7 @@ def build_artifacts_tar_zst(
246
247
  "return_code": run_command_response.returncode,
247
248
  "execution_time_ms": round(run_command_response.execution_time * 1000),
248
249
  "executed": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
249
- "env": dict(os.environ),
250
+ "env": env or {},
250
251
  "tags": tags or [],
251
252
  }
252
253
 
@@ -329,3 +330,45 @@ def summarize_archive(archive_path: pathlib.Path) -> ArchiveSummary:
329
330
  members[member.name] = {"bytes": member.size}
330
331
 
331
332
  return ArchiveSummary(archive_bytes=archive_bytes, members=members)
333
+
334
+
335
+ def render_archive_preview(archive_path: pathlib.Path) -> str:
336
+ """
337
+ Render the contents of an archive as text for a dry run.
338
+
339
+ Reads the archive that would be uploaded, so the preview is exactly what
340
+ would be sent. command.json is pretty-printed and output.txt is shown in
341
+ full. Attachments are listed with their sizes only.
342
+
343
+ Args:
344
+ archive_path: Path to the artifacts.tar.zst file
345
+
346
+ Returns:
347
+ Human-readable preview of the archive contents
348
+ """
349
+ sections: list[str] = []
350
+ decompressor = zstd.ZstdDecompressor()
351
+
352
+ with open(archive_path, "rb") as archive_file:
353
+ with decompressor.stream_reader(archive_file) as reader:
354
+ with tarfile.open(
355
+ fileobj=reader, mode="r|"
356
+ ) as tar: # pragma: no branch
357
+ for member in tar:
358
+ content = tar.extractfile(member)
359
+ assert content is not None # nosec: members are files
360
+ data = content.read()
361
+ if member.name == "command.json":
362
+ body = json.dumps(json.loads(data), indent=2)
363
+ sections.append(f"--- command.json ---\n{body}")
364
+ elif member.name == "output.txt":
365
+ sections.append(
366
+ f"--- output.txt ---\n{data.decode('utf-8')}"
367
+ )
368
+ else:
369
+ sections.append(
370
+ f"--- {member.name} ({member.size} bytes, "
371
+ "content not shown) ---"
372
+ )
373
+
374
+ return "\n\n".join(sections)
@@ -1,16 +1,28 @@
1
1
  import glob
2
+ import logging
3
+ import math
4
+ import os
2
5
  import pathlib
3
6
  import time
7
+ from datetime import datetime, timezone
4
8
  from typing import List
5
9
 
6
10
  import click
7
11
 
8
12
  from obvyr_cli.api_client import ObvyrAPIClient
9
- from obvyr_cli.archive_builder import build_artifacts_tar_zst
13
+ from obvyr_cli.archive_builder import (
14
+ build_artifacts_tar_zst,
15
+ render_archive_preview,
16
+ )
10
17
  from obvyr_cli.command_wrapper import run_command
11
18
  from obvyr_cli.config import ProfileSettings, Settings, get_settings
12
- from obvyr_cli.error_handling import handle_archive_error
19
+ from obvyr_cli.error_handling import (
20
+ handle_archive_error,
21
+ log_customer_friendly_error,
22
+ )
23
+ from obvyr_cli.jwt_utils import get_jwt_expiry
13
24
  from obvyr_cli.logging_config import configure_logging, get_logger
25
+ from obvyr_cli.redaction import filter_environment, redact_response
14
26
  from obvyr_cli.schemas import (
15
27
  CommandExecutionConfig,
16
28
  OutputMode,
@@ -19,6 +31,8 @@ from obvyr_cli.schemas import (
19
31
 
20
32
  logger = get_logger("cli")
21
33
 
34
+ TOKEN_EXPIRY_WARNING_DAYS = 14
35
+
22
36
  # ===================================
23
37
  # CLI Support Functions
24
38
  # ===================================
@@ -173,6 +187,31 @@ def fetch_active_profile(
173
187
  return active_profile
174
188
 
175
189
 
190
+ def check_token_expiry(active_profile: ProfileSettings) -> None:
191
+ """Warn when the active profile's API token is near or past expiry."""
192
+ expiry = get_jwt_expiry(active_profile.API_KEY)
193
+ if expiry is None:
194
+ return
195
+
196
+ seconds_remaining = (expiry - datetime.now(timezone.utc)).total_seconds()
197
+ days_remaining = math.ceil(seconds_remaining / 86400)
198
+
199
+ if days_remaining < 0:
200
+ log_customer_friendly_error(
201
+ f"Token expired {abs(days_remaining)} day(s) ago.",
202
+ "Your Obvyr agent token has expired — nothing is being "
203
+ "reported. Generate a new one in Project > Agents.",
204
+ level=logging.WARNING,
205
+ )
206
+ elif days_remaining <= TOKEN_EXPIRY_WARNING_DAYS:
207
+ log_customer_friendly_error(
208
+ f"Token expires in {days_remaining} day(s).",
209
+ f"Your Obvyr agent token expires in {days_remaining} day(s). "
210
+ "Regenerate it in Project > Agents.",
211
+ level=logging.WARNING,
212
+ )
213
+
214
+
176
215
  def display_execution_summary(response: RunCommandResponse) -> None:
177
216
  """Display the execution summary after streaming output."""
178
217
  output = (
@@ -189,6 +228,51 @@ def display_output(response: RunCommandResponse) -> None:
189
228
  display_execution_summary(response)
190
229
 
191
230
 
231
+ def prepare_archive(
232
+ active_profile: ProfileSettings, data: RunCommandResponse
233
+ ) -> pathlib.Path:
234
+ """
235
+ Build the archive to upload, applying the profile's capture controls.
236
+
237
+ Only allow-listed environment variables are included, and secrets are
238
+ redacted from the environment, command arguments and output.
239
+
240
+ :param active_profile: Profile configuration controlling what is captured.
241
+ :param data: Command execution result to be archived.
242
+ :return: Path to the temporary archive file.
243
+ """
244
+ attachment_paths = None
245
+ if active_profile.ATTACHMENT_PATHS:
246
+ resolved_paths = resolve_attachment_globs(
247
+ active_profile.ATTACHMENT_PATHS
248
+ )
249
+ fresh_paths = []
250
+ for path in resolved_paths:
251
+ if is_attachment_fresh(
252
+ path, active_profile.ATTACHMENT_MAX_AGE_SECONDS
253
+ ):
254
+ fresh_paths.append(path)
255
+ logger.debug(f"Including fresh attachment: {path}")
256
+ else:
257
+ logger.debug(f"Skipping stale attachment: {path}")
258
+
259
+ # Only set attachment_paths if we have fresh files
260
+ if fresh_paths:
261
+ attachment_paths = fresh_paths
262
+
263
+ return build_artifacts_tar_zst(
264
+ redact_response(data, active_profile.REDACT_PATTERNS),
265
+ attachment_paths=attachment_paths,
266
+ tags=active_profile.TAGS,
267
+ env=filter_environment(
268
+ os.environ,
269
+ mode=active_profile.ENV_MODE,
270
+ include=active_profile.ENV_INCLUDE,
271
+ extra_patterns=active_profile.REDACT_PATTERNS,
272
+ ),
273
+ )
274
+
275
+
192
276
  def send_to_api(
193
277
  active_profile: ProfileSettings, data: RunCommandResponse
194
278
  ) -> None:
@@ -205,30 +289,7 @@ def send_to_api(
205
289
 
206
290
  archive_path = None
207
291
  try:
208
- # Create archive from command execution data
209
- attachment_paths = None
210
- if active_profile.ATTACHMENT_PATHS:
211
- resolved_paths = resolve_attachment_globs(
212
- active_profile.ATTACHMENT_PATHS
213
- )
214
- fresh_paths = []
215
- for path in resolved_paths:
216
- if is_attachment_fresh(
217
- path, active_profile.ATTACHMENT_MAX_AGE_SECONDS
218
- ):
219
- fresh_paths.append(path)
220
- logger.debug(f"Including fresh attachment: {path}")
221
- else:
222
- logger.debug(f"Skipping stale attachment: {path}")
223
-
224
- # Only set attachment_paths if we have fresh files
225
- if fresh_paths:
226
- attachment_paths = fresh_paths
227
-
228
- # Build archive with or without attachments
229
- archive_path = build_artifacts_tar_zst(
230
- data, attachment_paths=attachment_paths, tags=active_profile.TAGS
231
- )
292
+ archive_path = prepare_archive(active_profile, data)
232
293
 
233
294
  with ObvyrAPIClient(
234
295
  api_key=active_profile.API_KEY,
@@ -260,6 +321,23 @@ def send_to_api(
260
321
  archive_path.unlink()
261
322
 
262
323
 
324
+ def show_dry_run_payload(
325
+ active_profile: ProfileSettings, data: RunCommandResponse
326
+ ) -> None:
327
+ """
328
+ Print exactly what would be sent to the Obvyr API, without sending it.
329
+
330
+ :param active_profile: Profile configuration controlling what is captured.
331
+ :param data: Command execution result that would have been sent.
332
+ """
333
+ archive_path = prepare_archive(active_profile, data)
334
+ try:
335
+ click.echo("\nDry run: nothing was sent. Payload that would be sent:\n")
336
+ click.echo(render_archive_preview(archive_path))
337
+ finally:
338
+ archive_path.unlink(missing_ok=True)
339
+
340
+
263
341
  # ===================================
264
342
  # Click CLI
265
343
  # ===================================
@@ -317,6 +395,11 @@ def send_to_api(
317
395
  is_flag=True,
318
396
  help="Disable color output (don't set FORCE_COLOR environment variable).",
319
397
  )
398
+ @click.option(
399
+ "--dry-run",
400
+ is_flag=True,
401
+ help="Run the command, then print exactly what would be sent instead of sending it.",
402
+ )
320
403
  def cli_run_process(
321
404
  command: List[str],
322
405
  list_profiles: bool,
@@ -328,6 +411,7 @@ def cli_run_process(
328
411
  no_stream: bool,
329
412
  force_stream: bool,
330
413
  no_color: bool,
414
+ dry_run: bool,
331
415
  ) -> None:
332
416
  """
333
417
  Executes a system command while using the Obvyr CLI profile configuration.
@@ -357,6 +441,7 @@ def cli_run_process(
357
441
  return
358
442
 
359
443
  active_profile = fetch_active_profile(settings, profile)
444
+ check_token_expiry(active_profile)
360
445
 
361
446
  # Create streaming callback for real-time output display
362
447
  def stream_output(line: str) -> None:
@@ -369,7 +454,9 @@ def cli_run_process(
369
454
  config=execution_config,
370
455
  )
371
456
 
372
- if active_profile.API_URL and active_profile.API_KEY:
457
+ if dry_run:
458
+ show_dry_run_payload(active_profile, response)
459
+ elif active_profile.API_URL and active_profile.API_KEY:
373
460
  send_to_api(active_profile, response)
374
461
 
375
462
  display_execution_summary(response)
@@ -6,8 +6,9 @@ from environment variables, allowing environmental overrides.
6
6
  """
7
7
 
8
8
  import logging
9
+ import re
9
10
  import secrets
10
- from typing import Dict
11
+ from typing import Dict, Literal
11
12
 
12
13
  from dotenv import load_dotenv
13
14
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -29,22 +30,36 @@ class ProfileSettings(BaseModel):
29
30
  TIMEOUT: float = 10.0
30
31
  VERIFY_SSL: bool = True
31
32
  TAGS: list[str] = Field(default_factory=list)
32
-
33
- @field_validator("TAGS", mode="before")
33
+ ENV_MODE: Literal["allowlist", "none"] = "allowlist"
34
+ ENV_INCLUDE: list[str] = Field(default_factory=list)
35
+ REDACT_PATTERNS: list[str] = Field(default_factory=list)
36
+
37
+ @field_validator(
38
+ "TAGS",
39
+ "ATTACHMENT_PATHS",
40
+ "ENV_INCLUDE",
41
+ "REDACT_PATTERNS",
42
+ mode="before",
43
+ )
34
44
  @classmethod
35
- def parse_tags(cls, value: str | list[str]) -> list[str]:
36
- """Parse tags from comma-separated string or return list as-is."""
45
+ def parse_comma_separated(cls, value: str | list[str]) -> list[str]:
46
+ """Parse a comma-separated string into a list, or return a list as-is."""
37
47
  if isinstance(value, str):
38
- return [tag.strip() for tag in value.split(",") if tag.strip()]
48
+ return [item.strip() for item in value.split(",") if item.strip()]
39
49
  return value or []
40
50
 
41
- @field_validator("ATTACHMENT_PATHS", mode="before")
51
+ @field_validator("REDACT_PATTERNS")
42
52
  @classmethod
43
- def parse_attachment_paths(cls, value: str | list[str]) -> list[str]:
44
- """Parse attachment paths from comma-separated string or return list as-is."""
45
- if isinstance(value, str):
46
- return [path.strip() for path in value.split(",") if path.strip()]
47
- return value or []
53
+ def validate_redact_patterns(cls, value: list[str]) -> list[str]:
54
+ """Fail fast on patterns that are not valid regular expressions."""
55
+ for pattern in value:
56
+ try:
57
+ re.compile(pattern)
58
+ except re.error as error:
59
+ raise ValueError(
60
+ f"Invalid REDACT_PATTERNS entry '{pattern}': {error}"
61
+ ) from error
62
+ return value
48
63
 
49
64
  @model_validator(mode="after")
50
65
  def migrate_attachment_path(self) -> "ProfileSettings":
@@ -114,6 +129,6 @@ class Settings(BaseSettings):
114
129
 
115
130
  def get_settings() -> Settings:
116
131
  """Return the current settings."""
117
- load_dotenv(".env", override=True)
132
+ load_dotenv(".env", override=False)
118
133
 
119
134
  return Settings()
@@ -44,7 +44,9 @@ def handle_api_error(error: httpx.HTTPStatusError) -> Optional[dict]:
44
44
  if status_code == 401:
45
45
  log_customer_friendly_error(
46
46
  f"Authentication failed: {error.response.text}",
47
- "Authentication failed. Please check your API token configuration.",
47
+ "Authentication failed. Your token may be invalid, "
48
+ "revoked, or expired — agent tokens expire after 6 "
49
+ "months, generate a new one in Project > Agents.",
48
50
  )
49
51
  elif status_code == 403:
50
52
  log_customer_friendly_error(
@@ -0,0 +1,17 @@
1
+ """Unverified local decoding of JWT expiry claims."""
2
+
3
+ import base64
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from typing import Optional
7
+
8
+
9
+ def get_jwt_expiry(token: str) -> Optional[datetime]:
10
+ """Return the expiry datetime encoded in a JWT's unverified payload, if present."""
11
+ try:
12
+ _, payload_segment, _ = token.split(".")
13
+ padded = payload_segment + "=" * (-len(payload_segment) % 4)
14
+ payload = json.loads(base64.urlsafe_b64decode(padded))
15
+ return datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
16
+ except (ValueError, KeyError, TypeError, OverflowError, UnicodeDecodeError):
17
+ return None
@@ -0,0 +1,135 @@
1
+ """
2
+ Controls what the CLI sends: environment filtering and secret redaction.
3
+
4
+ All functions are pure. Callers pass in the environment and patterns
5
+ explicitly so behaviour is predictable and testable.
6
+ """
7
+
8
+ import re
9
+ from typing import Mapping, Sequence
10
+
11
+ from obvyr_cli.schemas import RunCommandResponse
12
+
13
+ # Exact names only. Prefix matching is deliberately avoided: CI_JOB_TOKEN
14
+ # and similar secrets share prefixes with harmless CI identifiers.
15
+ ENV_ALLOWLIST = frozenset(
16
+ {
17
+ "CI",
18
+ "CONTINUOUS_INTEGRATION",
19
+ # GitHub Actions
20
+ "GITHUB_ACTIONS",
21
+ "GITHUB_RUN_ID",
22
+ "GITHUB_RUN_NUMBER",
23
+ "GITHUB_WORKFLOW",
24
+ "GITHUB_REPOSITORY",
25
+ "GITHUB_SHA",
26
+ "GITHUB_REF_NAME",
27
+ # GitLab CI
28
+ "GITLAB_CI",
29
+ "CI_PIPELINE_ID",
30
+ "CI_JOB_ID",
31
+ "CI_COMMIT_SHA",
32
+ "CI_COMMIT_REF_NAME",
33
+ # Jenkins
34
+ "JENKINS_URL",
35
+ "BUILD_NUMBER",
36
+ # Buildkite
37
+ "BUILDKITE",
38
+ "BUILDKITE_BUILD_NUMBER",
39
+ # CircleCI
40
+ "CIRCLECI",
41
+ "CIRCLE_BUILD_NUM",
42
+ "CIRCLE_SHA1",
43
+ }
44
+ )
45
+
46
+
47
+ # The CLI's own settings (including its API key) are never sent.
48
+ ALWAYS_EXCLUDED_PREFIX = "OBVYR_"
49
+
50
+
51
+ REDACTED = "[REDACTED]"
52
+
53
+ # Variable and flag names that suggest the value is a secret.
54
+ SECRET_NAME_PATTERN = re.compile(
55
+ r"token|key|password|passwd|secret|credential", re.IGNORECASE
56
+ )
57
+
58
+ # Value shapes that are secrets wherever they appear.
59
+ SECRET_VALUE_PATTERNS = (
60
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
61
+ re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+"),
62
+ # name=value or name: value; the prefix group keeps the name visible.
63
+ re.compile(
64
+ r"(?i)(?P<prefix>\b[\w-]*(?:token|key|password|passwd|secret|credential)"
65
+ r"[\w-]*\s*[=:]\s*)\S+"
66
+ ),
67
+ )
68
+
69
+
70
+ def keep_prefix_and_redact(match: re.Match[str]) -> str:
71
+ """Replace a match with the redaction marker, keeping a named prefix group."""
72
+ return (match.groupdict().get("prefix") or "") + REDACTED
73
+
74
+
75
+ def redact_text(text: str, extra_patterns: Sequence[str] = ()) -> str:
76
+ """Replace anything in the text that matches a built-in or extra pattern."""
77
+ patterns = SECRET_VALUE_PATTERNS + tuple(
78
+ re.compile(pattern) for pattern in extra_patterns
79
+ )
80
+ for pattern in patterns:
81
+ text = pattern.sub(keep_prefix_and_redact, text)
82
+ return text
83
+
84
+
85
+ def redact_command(
86
+ command: Sequence[str], extra_patterns: Sequence[str] = ()
87
+ ) -> list[str]:
88
+ """Redact secrets in command arguments, including a flag's separate value."""
89
+ redacted: list[str] = []
90
+ previous_was_secret_flag = False
91
+ for argument in command:
92
+ if previous_was_secret_flag:
93
+ redacted.append(REDACTED)
94
+ else:
95
+ redacted.append(redact_text(argument, extra_patterns))
96
+ previous_was_secret_flag = (
97
+ argument.startswith("-")
98
+ and "=" not in argument
99
+ and SECRET_NAME_PATTERN.search(argument) is not None
100
+ )
101
+ return redacted
102
+
103
+
104
+ def redact_response(
105
+ response: RunCommandResponse, extra_patterns: Sequence[str] = ()
106
+ ) -> RunCommandResponse:
107
+ """Return a copy of the response with secrets removed from what is uploaded."""
108
+ output = response.output
109
+ return response.model_copy(
110
+ update={
111
+ "command": redact_command(response.command, extra_patterns),
112
+ "output": redact_text(output, extra_patterns) if output else output,
113
+ }
114
+ )
115
+
116
+
117
+ def filter_environment(
118
+ environ: Mapping[str, str],
119
+ mode: str,
120
+ include: Sequence[str] = (),
121
+ extra_patterns: Sequence[str] = (),
122
+ ) -> dict[str, str]:
123
+ """Return the subset of the environment that is safe to send."""
124
+ if mode == "none":
125
+ return {}
126
+ permitted = ENV_ALLOWLIST | set(include)
127
+ return {
128
+ k: (
129
+ REDACTED
130
+ if SECRET_NAME_PATTERN.search(k)
131
+ else redact_text(v, extra_patterns)
132
+ )
133
+ for k, v in environ.items()
134
+ if k in permitted and not k.startswith(ALWAYS_EXCLUDED_PREFIX)
135
+ }
File without changes