ptn 0.2.5__py3-none-any.whl → 0.4.2__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.
Files changed (39) hide show
  1. porterminal/__init__.py +63 -11
  2. porterminal/_version.py +2 -2
  3. porterminal/app.py +25 -1
  4. porterminal/application/ports/__init__.py +2 -0
  5. porterminal/application/ports/connection_registry_port.py +46 -0
  6. porterminal/application/services/management_service.py +30 -55
  7. porterminal/application/services/session_service.py +3 -11
  8. porterminal/application/services/terminal_service.py +97 -56
  9. porterminal/cli/args.py +91 -30
  10. porterminal/cli/display.py +18 -16
  11. porterminal/cli/script_discovery.py +112 -0
  12. porterminal/composition.py +8 -7
  13. porterminal/config.py +12 -2
  14. porterminal/container.py +4 -0
  15. porterminal/domain/__init__.py +0 -9
  16. porterminal/domain/entities/output_buffer.py +56 -1
  17. porterminal/domain/entities/tab.py +11 -10
  18. porterminal/domain/services/__init__.py +0 -2
  19. porterminal/domain/values/__init__.py +0 -4
  20. porterminal/domain/values/environment_rules.py +3 -0
  21. porterminal/infrastructure/auth.py +131 -0
  22. porterminal/infrastructure/cloudflared.py +18 -12
  23. porterminal/infrastructure/config/shell_detector.py +407 -1
  24. porterminal/infrastructure/repositories/in_memory_session.py +1 -4
  25. porterminal/infrastructure/repositories/in_memory_tab.py +2 -10
  26. porterminal/infrastructure/server.py +28 -3
  27. porterminal/pty/env.py +16 -78
  28. porterminal/pty/manager.py +6 -4
  29. porterminal/static/assets/app-DlWNJWFE.js +87 -0
  30. porterminal/static/assets/app-xPAM7YhQ.css +1 -0
  31. porterminal/static/index.html +14 -2
  32. porterminal/updater.py +13 -5
  33. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/METADATA +84 -23
  34. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/RECORD +37 -34
  35. porterminal/static/assets/app-By4EXMHC.js +0 -72
  36. porterminal/static/assets/app-DQePboVd.css +0 -32
  37. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/WHEEL +0 -0
  38. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/entry_points.txt +0 -0
  39. {ptn-0.2.5.dist-info → ptn-0.4.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,19 @@
1
1
  """Shell detection for available shells on the system."""
2
2
 
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import shlex
3
8
  import shutil
9
+ import subprocess
4
10
  import sys
5
11
  from pathlib import Path
6
12
 
7
13
  from porterminal.domain import ShellCommand
8
14
 
15
+ logger = logging.getLogger(__name__)
16
+
9
17
 
10
18
  class ShellDetector:
11
19
  """Detect available shells on the current platform."""
@@ -13,6 +21,9 @@ class ShellDetector:
13
21
  def detect_shells(self) -> list[ShellCommand]:
14
22
  """Auto-detect available shells.
15
23
 
24
+ Detects platform-specific shells and includes the user's $SHELL
25
+ if it's not already in the list (supports any shell).
26
+
16
27
  Returns:
17
28
  List of detected shell configurations.
18
29
  """
@@ -31,6 +42,12 @@ class ShellDetector:
31
42
  )
32
43
  )
33
44
 
45
+ # Include user's $SHELL if not already detected (supports unknown shells)
46
+ user_shell = self._create_shell_from_env()
47
+ if user_shell and not any(s.id == user_shell.id for s in shells):
48
+ # Insert at beginning so user's preferred shell is first
49
+ shells.insert(0, user_shell)
50
+
34
51
  return shells
35
52
 
36
53
  def get_default_shell_id(self) -> str:
@@ -48,13 +65,20 @@ class ShellDetector:
48
65
  List of (name, id, command, args) tuples.
49
66
  """
50
67
  if sys.platform == "win32":
51
- return [
68
+ # Get Windows Terminal profiles first, then hardcoded defaults
69
+ wt_profiles = self._get_windows_terminal_profiles()
70
+ hardcoded = [
52
71
  ("PS 7", "pwsh", "pwsh.exe", ["-NoLogo"]),
53
72
  ("PS", "powershell", "powershell.exe", ["-NoLogo"]),
54
73
  ("CMD", "cmd", "cmd.exe", []),
55
74
  ("WSL", "wsl", "wsl.exe", []),
56
75
  ("Git Bash", "gitbash", r"C:\Program Files\Git\bin\bash.exe", ["--login"]),
57
76
  ]
77
+ # Merge WT profiles with hardcoded (dedupe by command)
78
+ merged = self._merge_candidates(wt_profiles, hardcoded)
79
+ # Add VS dev shells (no deduplication - they're unique due to args)
80
+ vs_shells = self._get_visual_studio_shells()
81
+ return merged + vs_shells
58
82
  return [
59
83
  ("Bash", "bash", "bash", ["--login"]),
60
84
  ("Zsh", "zsh", "zsh", ["--login"]),
@@ -62,6 +86,307 @@ class ShellDetector:
62
86
  ("Sh", "sh", "sh", []),
63
87
  ]
64
88
 
89
+ def _get_windows_terminal_profiles(self) -> list[tuple[str, str, str, list[str]]]:
90
+ """Read shell profiles from Windows Terminal settings.json.
91
+
92
+ Returns:
93
+ List of (name, id, command, args) tuples from Windows Terminal.
94
+ """
95
+ settings_path = Path(
96
+ os.environ.get("LOCALAPPDATA", ""),
97
+ "Packages",
98
+ "Microsoft.WindowsTerminal_8wekyb3d8bbwe",
99
+ "LocalState",
100
+ "settings.json",
101
+ )
102
+
103
+ if not settings_path.exists():
104
+ return []
105
+
106
+ try:
107
+ # Read and parse JSON (WT uses JSON with comments, strip them)
108
+ content = settings_path.read_text(encoding="utf-8")
109
+ content = self._strip_json_comments(content)
110
+ data = json.loads(content)
111
+ except (OSError, json.JSONDecodeError) as e:
112
+ logger.warning("Failed to read Windows Terminal settings: %s", e)
113
+ return []
114
+
115
+ profiles = []
116
+ profile_list = data.get("profiles", {}).get("list", [])
117
+
118
+ for profile in profile_list:
119
+ name = profile.get("name", "")
120
+ commandline = profile.get("commandline", "")
121
+
122
+ if not name or not commandline:
123
+ continue
124
+
125
+ # Parse commandline into command and args
126
+ command, args = self._parse_commandline(commandline)
127
+ if not command:
128
+ continue
129
+
130
+ # Expand environment variables in command path
131
+ command = os.path.expandvars(command)
132
+
133
+ # Create a slug ID from the name
134
+ shell_id = self._slugify(name)
135
+
136
+ # Shorten display name
137
+ short_name = self._abbreviate_name(name)
138
+
139
+ profiles.append((short_name, shell_id, command, args))
140
+
141
+ return profiles
142
+
143
+ def _get_visual_studio_shells(self) -> list[tuple[str, str, str, list[str]]]:
144
+ """Detect Visual Studio Developer Command Prompts and PowerShells.
145
+
146
+ Returns:
147
+ List of (name, id, command, args) tuples for VS dev shells.
148
+ """
149
+ vswhere = Path(
150
+ os.environ.get("ProgramFiles(x86)", ""),
151
+ "Microsoft Visual Studio",
152
+ "Installer",
153
+ "vswhere.exe",
154
+ )
155
+
156
+ if not vswhere.exists():
157
+ return []
158
+
159
+ try:
160
+ result = subprocess.run(
161
+ [str(vswhere), "-all", "-prerelease", "-format", "json"],
162
+ capture_output=True,
163
+ text=True,
164
+ timeout=5,
165
+ )
166
+ vs_installs = json.loads(result.stdout) if result.stdout.strip() else []
167
+ except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError) as e:
168
+ logger.warning("Failed to run vswhere: %s", e)
169
+ return []
170
+
171
+ shells = []
172
+ for vs_info in vs_installs:
173
+ vs_path = Path(vs_info.get("installationPath", ""))
174
+ instance_id = vs_info.get("instanceId", "")
175
+ # Extract VS version and edition from path
176
+ # e.g., "C:\Program Files\Microsoft Visual Studio\2022\Community"
177
+ edition = vs_path.name # Community, Professional, Enterprise
178
+ year = vs_path.parent.name # 2022, 2019, etc.
179
+
180
+ # Developer Command Prompt (CMD)
181
+ vsdevcmd = vs_path / "Common7" / "Tools" / "VsDevCmd.bat"
182
+ if vsdevcmd.exists():
183
+ name = f"Dev CMD {year}"
184
+ shell_id = f"devcmd-{year}-{edition.lower()}"
185
+ # cmd.exe /k "path\to\VsDevCmd.bat"
186
+ shells.append(
187
+ (
188
+ name,
189
+ shell_id,
190
+ "cmd.exe",
191
+ ["/k", str(vsdevcmd)],
192
+ )
193
+ )
194
+
195
+ # Developer PowerShell - find DevShell.dll (location varies by VS version)
196
+ devshell_dll = None
197
+ for dll_path in [
198
+ vs_path / "Common7" / "Tools" / "Microsoft.VisualStudio.DevShell.dll",
199
+ vs_path
200
+ / "Common7"
201
+ / "Tools"
202
+ / "vsdevshell"
203
+ / "Microsoft.VisualStudio.DevShell.dll",
204
+ ]:
205
+ if dll_path.exists():
206
+ devshell_dll = dll_path
207
+ break
208
+
209
+ if devshell_dll and instance_id:
210
+ name = f"Dev PS {year}"
211
+ shell_id = f"devps-{year}-{edition.lower()}"
212
+ # Use forward slashes to avoid backslash escape issues (PowerShell accepts both)
213
+ dll_str = str(devshell_dll).replace("\\", "/")
214
+ cmd = f"Import-Module '{dll_str}'; Enter-VsDevShell {instance_id} -SkipAutomaticLocation"
215
+ shells.append(
216
+ (
217
+ name,
218
+ shell_id,
219
+ "powershell.exe",
220
+ ["-NoExit", "-Command", cmd],
221
+ )
222
+ )
223
+
224
+ return shells
225
+
226
+ def _strip_json_comments(self, content: str) -> str:
227
+ """Strip comments from JSON content while preserving strings.
228
+
229
+ Handles:
230
+ - Single-line comments: // comment
231
+ - Multi-line comments: /* comment */
232
+ - Preserves // inside quoted strings (e.g., URLs)
233
+
234
+ Args:
235
+ content: JSON content with possible comments
236
+
237
+ Returns:
238
+ JSON content without comments
239
+ """
240
+ result = []
241
+ i = 0
242
+ in_string = False
243
+ escape_next = False
244
+
245
+ while i < len(content):
246
+ char = content[i]
247
+
248
+ if escape_next:
249
+ result.append(char)
250
+ escape_next = False
251
+ i += 1
252
+ continue
253
+
254
+ if char == "\\" and in_string:
255
+ result.append(char)
256
+ escape_next = True
257
+ i += 1
258
+ continue
259
+
260
+ if char == '"' and not escape_next:
261
+ in_string = not in_string
262
+ result.append(char)
263
+ i += 1
264
+ continue
265
+
266
+ if not in_string:
267
+ # Check for single-line comment
268
+ if content[i : i + 2] == "//":
269
+ # Skip to end of line
270
+ while i < len(content) and content[i] != "\n":
271
+ i += 1
272
+ continue
273
+ # Check for multi-line comment
274
+ if content[i : i + 2] == "/*":
275
+ i += 2
276
+ while i < len(content) - 1 and content[i : i + 2] != "*/":
277
+ i += 1
278
+ i += 2 # Skip */
279
+ continue
280
+
281
+ result.append(char)
282
+ i += 1
283
+
284
+ return "".join(result)
285
+
286
+ def _parse_commandline(self, commandline: str) -> tuple[str, list[str]]:
287
+ """Parse a commandline string into command and args.
288
+
289
+ Args:
290
+ commandline: The command line string (e.g., 'cmd.exe /k "vcvars64.bat"')
291
+
292
+ Returns:
293
+ Tuple of (command, args_list)
294
+ """
295
+ try:
296
+ # Use shlex to properly handle quoted arguments
297
+ parts = shlex.split(commandline, posix=False)
298
+ if not parts:
299
+ return "", []
300
+ return parts[0], parts[1:]
301
+ except ValueError:
302
+ # Fallback: simple split on first space
303
+ parts = commandline.split(None, 1)
304
+ if not parts:
305
+ return "", []
306
+ return parts[0], parts[1:] if len(parts) > 1 else []
307
+
308
+ def _abbreviate_name(self, name: str) -> str:
309
+ """Abbreviate a shell name for display.
310
+
311
+ Args:
312
+ name: Full shell name (e.g., "Windows PowerShell")
313
+
314
+ Returns:
315
+ Abbreviated name (e.g., "WinPS")
316
+ """
317
+ # Common abbreviations
318
+ abbrevs = {
319
+ "Windows PowerShell": "WinPS",
320
+ "Command Prompt": "CMD",
321
+ "PowerShell": "PS",
322
+ "Developer Command Prompt": "DevCMD",
323
+ "Developer PowerShell": "DevPS",
324
+ "Azure Cloud Shell": "Azure",
325
+ "Git Bash": "GitBash",
326
+ }
327
+
328
+ # Check for exact or prefix match
329
+ for full, short in abbrevs.items():
330
+ if name == full or name.startswith(full):
331
+ # Append suffix if there's more (e.g., "for VS 2022")
332
+ suffix = name[len(full) :].strip()
333
+ if suffix:
334
+ # Extract version/year if present
335
+ parts = suffix.split()
336
+ for part in parts:
337
+ if part.isdigit() and len(part) == 4: # Year like 2022
338
+ return f"{short} {part}"
339
+ return short
340
+
341
+ # Fallback: first 8 chars if name is long
342
+ if len(name) > 10:
343
+ return name[:8].strip()
344
+ return name
345
+
346
+ def _slugify(self, name: str) -> str:
347
+ """Convert a profile name to a slug ID.
348
+
349
+ Args:
350
+ name: Profile name (e.g., "Developer PowerShell for VS 2022")
351
+
352
+ Returns:
353
+ Slug ID (e.g., "developer-powershell-for-vs-2022")
354
+ """
355
+ # Lowercase, replace non-alphanumeric with hyphens, collapse multiple hyphens
356
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower())
357
+ return slug.strip("-")
358
+
359
+ def _merge_candidates(
360
+ self,
361
+ primary: list[tuple[str, str, str, list[str]]],
362
+ secondary: list[tuple[str, str, str, list[str]]],
363
+ ) -> list[tuple[str, str, str, list[str]]]:
364
+ """Merge two candidate lists, deduplicating by command executable.
365
+
366
+ Args:
367
+ primary: First list (takes priority)
368
+ secondary: Second list (skipped if command already in primary)
369
+
370
+ Returns:
371
+ Merged and deduplicated list
372
+ """
373
+ result = list(primary)
374
+ seen_commands = set()
375
+
376
+ # Track commands from primary (normalize to lowercase basename)
377
+ for _, _, command, _ in primary:
378
+ cmd_name = Path(command).name.lower()
379
+ seen_commands.add(cmd_name)
380
+
381
+ # Add secondary items if command not already seen
382
+ for item in secondary:
383
+ cmd_name = Path(item[2]).name.lower()
384
+ if cmd_name not in seen_commands:
385
+ result.append(item)
386
+ seen_commands.add(cmd_name)
387
+
388
+ return result
389
+
65
390
  def _get_windows_default(self) -> str:
66
391
  """Get default shell ID for Windows."""
67
392
  if shutil.which("pwsh.exe"):
@@ -72,14 +397,95 @@ class ShellDetector:
72
397
 
73
398
  def _get_macos_default(self) -> str:
74
399
  """Get default shell ID for macOS."""
400
+ # Check user's configured shell from $SHELL
401
+ user_shell = self._get_user_shell_id()
402
+ if user_shell:
403
+ return user_shell
404
+ # Fallback to zsh (macOS default since Catalina)
75
405
  if shutil.which("zsh"):
76
406
  return "zsh"
77
407
  return "bash"
78
408
 
79
409
  def _get_linux_default(self) -> str:
80
410
  """Get default shell ID for Linux."""
411
+ # Check user's configured shell from $SHELL
412
+ user_shell = self._get_user_shell_id()
413
+ if user_shell:
414
+ return user_shell
415
+ # Fallback
81
416
  if shutil.which("bash"):
82
417
  return "bash"
83
418
  if shutil.which("zsh"):
84
419
  return "zsh"
85
420
  return "sh"
421
+
422
+ def _get_user_shell_id(self) -> str | None:
423
+ """Get shell ID from user's $SHELL environment variable.
424
+
425
+ Returns:
426
+ Shell ID if $SHELL is set and valid, None otherwise.
427
+ For unknown shells, returns the executable name as the ID.
428
+ """
429
+ shell_path = os.environ.get("SHELL", "")
430
+ if not shell_path:
431
+ return None
432
+
433
+ path = Path(shell_path)
434
+
435
+ # Validate shell exists
436
+ if not path.exists() and not shutil.which(shell_path):
437
+ return None
438
+
439
+ # Extract shell name from path (e.g., /usr/bin/fish -> fish)
440
+ shell_name = path.name.lower()
441
+
442
+ # Map common shell names to canonical IDs (for consistency)
443
+ shell_map = {
444
+ "bash": "bash",
445
+ "zsh": "zsh",
446
+ "fish": "fish",
447
+ "sh": "sh",
448
+ }
449
+
450
+ # Return known ID or use executable name for unknown shells
451
+ return shell_map.get(shell_name, shell_name)
452
+
453
+ def _create_shell_from_env(self) -> ShellCommand | None:
454
+ """Create a ShellCommand from user's $SHELL environment variable.
455
+
456
+ Returns:
457
+ ShellCommand if $SHELL is set and valid, None otherwise.
458
+ """
459
+ shell_path = os.environ.get("SHELL", "")
460
+ if not shell_path:
461
+ return None
462
+
463
+ path = Path(shell_path)
464
+
465
+ # Validate shell exists
466
+ if not path.exists() and not shutil.which(shell_path):
467
+ return None
468
+
469
+ shell_name = path.name.lower()
470
+
471
+ # Known shells with their display names and args
472
+ known_shells = {
473
+ "bash": ("Bash", ["--login"]),
474
+ "zsh": ("Zsh", ["--login"]),
475
+ "fish": ("Fish", []),
476
+ "sh": ("Sh", []),
477
+ }
478
+
479
+ if shell_name in known_shells:
480
+ display_name, args = known_shells[shell_name]
481
+ else:
482
+ # Unknown shell - use capitalized name, no special args
483
+ display_name = shell_name.capitalize()
484
+ args = []
485
+
486
+ return ShellCommand(
487
+ id=shell_name,
488
+ name=display_name,
489
+ command=shell_path,
490
+ args=tuple(args),
491
+ )
@@ -38,10 +38,7 @@ class InMemorySessionRepository(SessionRepository[PTYHandle]):
38
38
  user_id = str(session.user_id)
39
39
 
40
40
  self._sessions[session_id] = session
41
-
42
- if user_id not in self._user_sessions:
43
- self._user_sessions[user_id] = set()
44
- self._user_sessions[user_id].add(session_id)
41
+ self._user_sessions.setdefault(user_id, set()).add(session_id)
45
42
 
46
43
  def remove(self, session_id: SessionId) -> Session[PTYHandle] | None:
47
44
  """Remove and return a session."""
@@ -44,16 +44,8 @@ class InMemoryTabRepository(TabRepository):
44
44
  session_id = str(tab.session_id)
45
45
 
46
46
  self._tabs[tab_id] = tab
47
-
48
- # Index by user
49
- if user_id not in self._user_tabs:
50
- self._user_tabs[user_id] = set()
51
- self._user_tabs[user_id].add(tab_id)
52
-
53
- # Index by session
54
- if session_id not in self._session_tabs:
55
- self._session_tabs[session_id] = set()
56
- self._session_tabs[session_id].add(tab_id)
47
+ self._user_tabs.setdefault(user_id, set()).add(tab_id)
48
+ self._session_tabs.setdefault(session_id, set()).add(tab_id)
57
49
 
58
50
  def update(self, tab: Tab) -> None:
59
51
  """Update an existing tab (name, last_accessed)."""
@@ -76,15 +76,22 @@ def start_server(host: str, port: int, *, verbose: bool = False) -> subprocess.P
76
76
  "--no-access-log", # Disable access logging
77
77
  ]
78
78
 
79
+ # On Windows, use CREATE_NEW_PROCESS_GROUP to prevent Ctrl+C from propagating
80
+ # to the child process - we handle cleanup ourselves
81
+ kwargs = {}
82
+ if sys.platform == "win32":
83
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
84
+
79
85
  if verbose:
80
86
  # Let output go directly to console
81
- process = subprocess.Popen(cmd)
87
+ process = subprocess.Popen(cmd, **kwargs)
82
88
  else:
83
89
  process = subprocess.Popen(
84
90
  cmd,
85
91
  stdout=subprocess.PIPE,
86
92
  stderr=subprocess.STDOUT,
87
93
  text=True,
94
+ **kwargs,
88
95
  )
89
96
 
90
97
  return process
@@ -120,6 +127,11 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
120
127
  # Point to a non-existent config to force quick tunnel mode
121
128
  env["TUNNEL_CONFIG"] = os.devnull
122
129
 
130
+ # On Windows, use CREATE_NEW_PROCESS_GROUP to prevent Ctrl+C from propagating
131
+ kwargs = {}
132
+ if sys.platform == "win32":
133
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
134
+
123
135
  process = subprocess.Popen(
124
136
  cmd,
125
137
  stdout=subprocess.PIPE,
@@ -127,6 +139,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
127
139
  text=True,
128
140
  bufsize=1,
129
141
  env=env,
142
+ **kwargs,
130
143
  )
131
144
 
132
145
  # Parse URL from cloudflared output (flexible pattern for different Cloudflare domains)
@@ -151,7 +164,7 @@ def start_cloudflared(port: int) -> tuple[subprocess.Popen, str | None]:
151
164
 
152
165
 
153
166
  def drain_process_output(process: subprocess.Popen) -> None:
154
- """Drain process output silently (only print errors).
167
+ """Drain process output silently (only print errors and security warnings).
155
168
 
156
169
  Args:
157
170
  process: Subprocess to drain output from.
@@ -161,8 +174,20 @@ def drain_process_output(process: subprocess.Popen) -> None:
161
174
  if not line:
162
175
  break
163
176
  line = line.strip()
177
+ if not line:
178
+ continue
179
+ # Always print security warnings and related messages
180
+ if any(
181
+ kw in line.lower()
182
+ for kw in (
183
+ "security warning",
184
+ "authentication attempts",
185
+ "url may have been leaked",
186
+ )
187
+ ):
188
+ console.print(f"[bold red]{line}[/bold red]")
164
189
  # Print errors, but ignore harmless ICMP/ping warnings
165
- if line and "error" in line.lower() and not _is_icmp_warning(line):
190
+ elif "error" in line.lower() and not _is_icmp_warning(line):
166
191
  console.print(f"[red]{line}[/red]")
167
192
  except (OSError, ValueError):
168
193
  pass
porterminal/pty/env.py CHANGED
@@ -1,94 +1,32 @@
1
- """Environment variable sanitization for PTY processes."""
1
+ """Environment variable sanitization for PTY processes.
2
+
3
+ This module re-exports the environment rules from the domain layer
4
+ and provides the build_safe_environment() function for PTY spawning.
5
+ """
2
6
 
3
7
  import os
4
8
 
5
- # Environment variables to allowlist (safe to pass to shell)
6
- SAFE_ENV_VARS: frozenset[str] = frozenset(
7
- {
8
- # System paths
9
- "PATH",
10
- "PATHEXT",
11
- "SYSTEMROOT",
12
- "WINDIR",
13
- "TEMP",
14
- "TMP",
15
- "COMSPEC",
16
- # User directories
17
- "HOME",
18
- "USERPROFILE",
19
- "HOMEDRIVE",
20
- "HOMEPATH",
21
- "LOCALAPPDATA",
22
- "APPDATA",
23
- "PROGRAMFILES",
24
- "PROGRAMFILES(X86)",
25
- "COMMONPROGRAMFILES",
26
- # System info
27
- "COMPUTERNAME",
28
- "USERNAME",
29
- "USERDOMAIN",
30
- "OS",
31
- "PROCESSOR_ARCHITECTURE",
32
- "NUMBER_OF_PROCESSORS",
33
- # Terminal
34
- "TERM",
35
- # Locale settings for proper text rendering
36
- "LANG",
37
- "LC_ALL",
38
- "LC_CTYPE",
39
- }
9
+ from porterminal.domain.values.environment_rules import (
10
+ DEFAULT_BLOCKED_VARS as BLOCKED_ENV_VARS,
40
11
  )
41
-
42
- # Environment variables to explicitly block (secrets)
43
- BLOCKED_ENV_VARS: frozenset[str] = frozenset(
44
- {
45
- # AWS
46
- "AWS_ACCESS_KEY_ID",
47
- "AWS_SECRET_ACCESS_KEY",
48
- "AWS_SESSION_TOKEN",
49
- # Azure
50
- "AZURE_CLIENT_SECRET",
51
- "AZURE_CLIENT_ID",
52
- # Git/GitHub/GitLab
53
- "GH_TOKEN",
54
- "GITHUB_TOKEN",
55
- "GITLAB_TOKEN",
56
- # Package managers
57
- "NPM_TOKEN",
58
- # AI APIs
59
- "ANTHROPIC_API_KEY",
60
- "OPENAI_API_KEY",
61
- "GOOGLE_API_KEY",
62
- # Payment
63
- "STRIPE_SECRET_KEY",
64
- # Database
65
- "DATABASE_URL",
66
- "DB_PASSWORD",
67
- # Generic secrets
68
- "SECRET_KEY",
69
- "API_KEY",
70
- "API_SECRET",
71
- "PRIVATE_KEY",
72
- }
12
+ from porterminal.domain.values.environment_rules import (
13
+ DEFAULT_SAFE_VARS as SAFE_ENV_VARS,
73
14
  )
74
15
 
16
+ # Re-export for backward compatibility
17
+ __all__ = ["SAFE_ENV_VARS", "BLOCKED_ENV_VARS", "build_safe_environment"]
18
+
75
19
 
76
20
  def build_safe_environment() -> dict[str, str]:
77
21
  """Build a sanitized environment for the PTY.
78
22
 
23
+ Uses allowlist approach - only SAFE_ENV_VARS are copied,
24
+ so BLOCKED_ENV_VARS can never be included.
25
+
79
26
  Returns:
80
27
  Dictionary of safe environment variables.
81
28
  """
82
- safe_env: dict[str, str] = {}
83
-
84
- # Copy only allowed variables
85
- for var in SAFE_ENV_VARS:
86
- if var in os.environ:
87
- safe_env[var] = os.environ[var]
88
-
89
- # Ensure blocked vars are not included (defense in depth)
90
- for var in BLOCKED_ENV_VARS:
91
- safe_env.pop(var, None)
29
+ safe_env = {var: os.environ[var] for var in SAFE_ENV_VARS if var in os.environ}
92
30
 
93
31
  # Set custom variables for audit trail
94
32
  safe_env["TERM"] = "xterm-256color"