ptn 0.1.4__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 (73) hide show
  1. porterminal/__init__.py +288 -0
  2. porterminal/__main__.py +8 -0
  3. porterminal/app.py +381 -0
  4. porterminal/application/__init__.py +1 -0
  5. porterminal/application/ports/__init__.py +7 -0
  6. porterminal/application/ports/connection_port.py +34 -0
  7. porterminal/application/services/__init__.py +13 -0
  8. porterminal/application/services/management_service.py +279 -0
  9. porterminal/application/services/session_service.py +249 -0
  10. porterminal/application/services/tab_service.py +286 -0
  11. porterminal/application/services/terminal_service.py +426 -0
  12. porterminal/asgi.py +38 -0
  13. porterminal/cli/__init__.py +19 -0
  14. porterminal/cli/args.py +91 -0
  15. porterminal/cli/display.py +157 -0
  16. porterminal/composition.py +208 -0
  17. porterminal/config.py +195 -0
  18. porterminal/container.py +65 -0
  19. porterminal/domain/__init__.py +91 -0
  20. porterminal/domain/entities/__init__.py +16 -0
  21. porterminal/domain/entities/output_buffer.py +73 -0
  22. porterminal/domain/entities/session.py +86 -0
  23. porterminal/domain/entities/tab.py +71 -0
  24. porterminal/domain/ports/__init__.py +12 -0
  25. porterminal/domain/ports/pty_port.py +80 -0
  26. porterminal/domain/ports/session_repository.py +58 -0
  27. porterminal/domain/ports/tab_repository.py +75 -0
  28. porterminal/domain/services/__init__.py +18 -0
  29. porterminal/domain/services/environment_sanitizer.py +61 -0
  30. porterminal/domain/services/rate_limiter.py +63 -0
  31. porterminal/domain/services/session_limits.py +104 -0
  32. porterminal/domain/services/tab_limits.py +54 -0
  33. porterminal/domain/values/__init__.py +25 -0
  34. porterminal/domain/values/environment_rules.py +156 -0
  35. porterminal/domain/values/rate_limit_config.py +21 -0
  36. porterminal/domain/values/session_id.py +20 -0
  37. porterminal/domain/values/shell_command.py +37 -0
  38. porterminal/domain/values/tab_id.py +24 -0
  39. porterminal/domain/values/terminal_dimensions.py +45 -0
  40. porterminal/domain/values/user_id.py +25 -0
  41. porterminal/infrastructure/__init__.py +20 -0
  42. porterminal/infrastructure/cloudflared.py +295 -0
  43. porterminal/infrastructure/config/__init__.py +9 -0
  44. porterminal/infrastructure/config/shell_detector.py +84 -0
  45. porterminal/infrastructure/config/yaml_loader.py +34 -0
  46. porterminal/infrastructure/network.py +43 -0
  47. porterminal/infrastructure/registry/__init__.py +5 -0
  48. porterminal/infrastructure/registry/user_connection_registry.py +104 -0
  49. porterminal/infrastructure/repositories/__init__.py +9 -0
  50. porterminal/infrastructure/repositories/in_memory_session.py +70 -0
  51. porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
  52. porterminal/infrastructure/server.py +161 -0
  53. porterminal/infrastructure/web/__init__.py +7 -0
  54. porterminal/infrastructure/web/websocket_adapter.py +78 -0
  55. porterminal/logging_setup.py +48 -0
  56. porterminal/pty/__init__.py +46 -0
  57. porterminal/pty/env.py +97 -0
  58. porterminal/pty/manager.py +163 -0
  59. porterminal/pty/protocol.py +84 -0
  60. porterminal/pty/unix.py +162 -0
  61. porterminal/pty/windows.py +131 -0
  62. porterminal/static/assets/app-BQiuUo6Q.css +32 -0
  63. porterminal/static/assets/app-YNN_jEhv.js +71 -0
  64. porterminal/static/icon.svg +34 -0
  65. porterminal/static/index.html +139 -0
  66. porterminal/static/manifest.json +31 -0
  67. porterminal/static/sw.js +66 -0
  68. porterminal/updater.py +257 -0
  69. ptn-0.1.4.dist-info/METADATA +191 -0
  70. ptn-0.1.4.dist-info/RECORD +73 -0
  71. ptn-0.1.4.dist-info/WHEEL +4 -0
  72. ptn-0.1.4.dist-info/entry_points.txt +2 -0
  73. ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,58 @@
1
+ """Session repository port - interface for session storage."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TypeVar
5
+
6
+ from ..entities.session import Session
7
+ from ..values.session_id import SessionId
8
+ from ..values.user_id import UserId
9
+
10
+ PTYHandle = TypeVar("PTYHandle")
11
+
12
+
13
+ class SessionRepository[PTYHandle](ABC):
14
+ """Abstract interface for session storage.
15
+
16
+ Infrastructure layer provides concrete implementation.
17
+ Methods are synchronous - async wrapping is infrastructure concern.
18
+ """
19
+
20
+ @abstractmethod
21
+ def get(self, session_id: SessionId) -> Session[PTYHandle] | None:
22
+ """Get session by ID."""
23
+ ...
24
+
25
+ @abstractmethod
26
+ def get_by_id_str(self, session_id: str) -> Session[PTYHandle] | None:
27
+ """Get session by ID string (convenience method)."""
28
+ ...
29
+
30
+ @abstractmethod
31
+ def get_by_user(self, user_id: UserId) -> list[Session[PTYHandle]]:
32
+ """Get all sessions for a user."""
33
+ ...
34
+
35
+ @abstractmethod
36
+ def add(self, session: Session[PTYHandle]) -> None:
37
+ """Add a new session."""
38
+ ...
39
+
40
+ @abstractmethod
41
+ def remove(self, session_id: SessionId) -> Session[PTYHandle] | None:
42
+ """Remove and return a session."""
43
+ ...
44
+
45
+ @abstractmethod
46
+ def count(self) -> int:
47
+ """Get total session count."""
48
+ ...
49
+
50
+ @abstractmethod
51
+ def count_for_user(self, user_id: UserId) -> int:
52
+ """Get session count for a user."""
53
+ ...
54
+
55
+ @abstractmethod
56
+ def all_sessions(self) -> list[Session[PTYHandle]]:
57
+ """Get all sessions (for cleanup iteration)."""
58
+ ...
@@ -0,0 +1,75 @@
1
+ """Tab repository port - interface for tab storage."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ..entities.tab import Tab
6
+ from ..values.session_id import SessionId
7
+ from ..values.tab_id import TabId
8
+ from ..values.user_id import UserId
9
+
10
+
11
+ class TabRepository(ABC):
12
+ """Abstract interface for tab storage.
13
+
14
+ Infrastructure layer provides concrete implementation.
15
+ Methods are synchronous - async wrapping is infrastructure concern.
16
+ """
17
+
18
+ @abstractmethod
19
+ def get(self, tab_id: TabId) -> Tab | None:
20
+ """Get tab by ID."""
21
+ ...
22
+
23
+ @abstractmethod
24
+ def get_by_id_str(self, tab_id: str) -> Tab | None:
25
+ """Get tab by ID string (convenience method)."""
26
+ ...
27
+
28
+ @abstractmethod
29
+ def get_by_user(self, user_id: UserId) -> list[Tab]:
30
+ """Get all tabs for a user (ordered by last_accessed DESC)."""
31
+ ...
32
+
33
+ @abstractmethod
34
+ def get_by_session(self, session_id: SessionId) -> list[Tab]:
35
+ """Get all tabs referencing a specific session."""
36
+ ...
37
+
38
+ @abstractmethod
39
+ def add(self, tab: Tab) -> None:
40
+ """Add a new tab."""
41
+ ...
42
+
43
+ @abstractmethod
44
+ def update(self, tab: Tab) -> None:
45
+ """Update an existing tab (name, last_accessed)."""
46
+ ...
47
+
48
+ @abstractmethod
49
+ def remove(self, tab_id: TabId) -> Tab | None:
50
+ """Remove and return a tab."""
51
+ ...
52
+
53
+ @abstractmethod
54
+ def remove_by_session(self, session_id: SessionId) -> list[Tab]:
55
+ """Remove all tabs referencing a session (cascade).
56
+
57
+ Returns:
58
+ List of removed tabs.
59
+ """
60
+ ...
61
+
62
+ @abstractmethod
63
+ def count(self) -> int:
64
+ """Get total tab count."""
65
+ ...
66
+
67
+ @abstractmethod
68
+ def count_for_user(self, user_id: UserId) -> int:
69
+ """Get tab count for a user."""
70
+ ...
71
+
72
+ @abstractmethod
73
+ def all_tabs(self) -> list[Tab]:
74
+ """Get all tabs (for cleanup iteration)."""
75
+ ...
@@ -0,0 +1,18 @@
1
+ """Domain services - pure business logic operations."""
2
+
3
+ from .environment_sanitizer import EnvironmentSanitizer
4
+ from .rate_limiter import Clock, TokenBucketRateLimiter
5
+ from .session_limits import SessionLimitChecker, SessionLimitConfig, SessionLimitResult
6
+ from .tab_limits import TabLimitChecker, TabLimitConfig, TabLimitResult
7
+
8
+ __all__ = [
9
+ "TokenBucketRateLimiter",
10
+ "Clock",
11
+ "EnvironmentSanitizer",
12
+ "SessionLimitChecker",
13
+ "SessionLimitConfig",
14
+ "SessionLimitResult",
15
+ "TabLimitChecker",
16
+ "TabLimitConfig",
17
+ "TabLimitResult",
18
+ ]
@@ -0,0 +1,61 @@
1
+ """Pure environment sanitization service."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ..values.environment_rules import EnvironmentRules
6
+
7
+
8
+ @dataclass
9
+ class EnvironmentSanitizer:
10
+ """Pure environment sanitization service.
11
+
12
+ Takes environment dict as input (does NOT access os.environ).
13
+ Infrastructure layer is responsible for providing the input.
14
+ """
15
+
16
+ rules: EnvironmentRules
17
+
18
+ def sanitize(self, source_env: dict[str, str]) -> dict[str, str]:
19
+ """Sanitize environment variables according to rules.
20
+
21
+ Args:
22
+ source_env: Source environment (e.g., from os.environ).
23
+
24
+ Returns:
25
+ Sanitized environment dict.
26
+ """
27
+ result: dict[str, str] = {}
28
+
29
+ # Copy only allowed variables
30
+ for var in self.rules.allowed_vars:
31
+ if var in source_env:
32
+ result[var] = source_env[var]
33
+
34
+ # Defense in depth: remove any blocked vars that might have slipped through
35
+ for var in self.rules.blocked_vars:
36
+ result.pop(var, None)
37
+
38
+ # Also remove any vars that match blocked patterns
39
+ blocked_suffixes = ("_KEY", "_SECRET", "_TOKEN", "_PASSWORD", "_CREDENTIAL")
40
+ for var in list(result.keys()):
41
+ if any(var.endswith(suffix) for suffix in blocked_suffixes):
42
+ del result[var]
43
+
44
+ # Apply forced variables
45
+ result.update(self.rules.get_forced_vars_dict())
46
+
47
+ return result
48
+
49
+ def is_var_allowed(self, var_name: str) -> bool:
50
+ """Check if a variable name is allowed."""
51
+ if var_name in self.rules.blocked_vars:
52
+ return False
53
+ return var_name in self.rules.allowed_vars
54
+
55
+ def is_var_blocked(self, var_name: str) -> bool:
56
+ """Check if a variable name is blocked."""
57
+ if var_name in self.rules.blocked_vars:
58
+ return True
59
+
60
+ blocked_suffixes = ("_KEY", "_SECRET", "_TOKEN", "_PASSWORD", "_CREDENTIAL")
61
+ return any(var_name.endswith(suffix) for suffix in blocked_suffixes)
@@ -0,0 +1,63 @@
1
+ """Pure token bucket rate limiter - synchronous, no asyncio dependency."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+
6
+ from ..values.rate_limit_config import RateLimitConfig
7
+
8
+
9
+ class Clock(Protocol):
10
+ """Protocol for time source (dependency injection)."""
11
+
12
+ def now(self) -> float:
13
+ """Return current time in seconds (monotonic)."""
14
+ ...
15
+
16
+
17
+ @dataclass
18
+ class TokenBucketRateLimiter:
19
+ """Pure token bucket rate limiter.
20
+
21
+ Synchronous, no asyncio dependency.
22
+ Time source is injected for testability.
23
+ """
24
+
25
+ config: RateLimitConfig
26
+ clock: Clock
27
+ _tokens: float = field(init=False)
28
+ _last_update: float = field(init=False)
29
+
30
+ def __post_init__(self) -> None:
31
+ self._tokens = float(self.config.burst)
32
+ self._last_update = self.clock.now()
33
+
34
+ def try_acquire(self, tokens: int = 1) -> bool:
35
+ """Try to acquire tokens.
36
+
37
+ Args:
38
+ tokens: Number of tokens to acquire.
39
+
40
+ Returns:
41
+ True if tokens were acquired, False if rate limited.
42
+ """
43
+ now = self.clock.now()
44
+ elapsed = now - self._last_update
45
+
46
+ # Refill bucket
47
+ self._tokens = min(self.config.burst, self._tokens + elapsed * self.config.rate)
48
+ self._last_update = now
49
+
50
+ if self._tokens >= tokens:
51
+ self._tokens -= tokens
52
+ return True
53
+ return False
54
+
55
+ @property
56
+ def available_tokens(self) -> float:
57
+ """Get current available tokens (for monitoring)."""
58
+ return self._tokens
59
+
60
+ def reset(self) -> None:
61
+ """Reset the rate limiter to full capacity."""
62
+ self._tokens = float(self.config.burst)
63
+ self._last_update = self.clock.now()
@@ -0,0 +1,104 @@
1
+ """Session limit checking service - pure business logic."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import TypeVar
6
+
7
+ from ..entities.session import (
8
+ MAX_SESSIONS_PER_USER,
9
+ MAX_TOTAL_SESSIONS,
10
+ RECONNECT_WINDOW_SECONDS,
11
+ SESSION_MAX_DURATION_SECONDS,
12
+ Session,
13
+ )
14
+ from ..values.user_id import UserId
15
+
16
+ PTYHandle = TypeVar("PTYHandle")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SessionLimitConfig:
21
+ """Session limit configuration."""
22
+
23
+ max_per_user: int = MAX_SESSIONS_PER_USER
24
+ max_total: int = MAX_TOTAL_SESSIONS
25
+ max_duration_seconds: int = SESSION_MAX_DURATION_SECONDS
26
+ reconnect_window_seconds: int = RECONNECT_WINDOW_SECONDS
27
+
28
+
29
+ @dataclass
30
+ class SessionLimitResult:
31
+ """Result of session limit check."""
32
+
33
+ allowed: bool
34
+ reason: str | None = None
35
+
36
+
37
+ class SessionLimitChecker:
38
+ """Check session limits (pure business logic)."""
39
+
40
+ def __init__(self, config: SessionLimitConfig | None = None) -> None:
41
+ self.config = config or SessionLimitConfig()
42
+
43
+ def can_create_session(
44
+ self,
45
+ user_id: UserId,
46
+ user_session_count: int,
47
+ total_session_count: int,
48
+ ) -> SessionLimitResult:
49
+ """Check if a new session can be created."""
50
+ if user_session_count >= self.config.max_per_user:
51
+ return SessionLimitResult(
52
+ allowed=False,
53
+ reason=f"Maximum sessions ({self.config.max_per_user}) reached for user",
54
+ )
55
+
56
+ if total_session_count >= self.config.max_total:
57
+ return SessionLimitResult(
58
+ allowed=False,
59
+ reason="Server session limit reached",
60
+ )
61
+
62
+ return SessionLimitResult(allowed=True)
63
+
64
+ def can_reconnect(
65
+ self,
66
+ session: Session,
67
+ requesting_user: UserId,
68
+ ) -> SessionLimitResult:
69
+ """Check if user can reconnect to session."""
70
+ if session.user_id != requesting_user:
71
+ return SessionLimitResult(
72
+ allowed=False,
73
+ reason="Session belongs to another user",
74
+ )
75
+ return SessionLimitResult(allowed=True)
76
+
77
+ def should_cleanup_session(
78
+ self,
79
+ session: Session,
80
+ now: datetime,
81
+ is_pty_alive: bool,
82
+ ) -> tuple[bool, str | None]:
83
+ """Check if a session should be cleaned up.
84
+
85
+ Returns:
86
+ Tuple of (should_cleanup, reason).
87
+ """
88
+ # Primary check: PTY dead = session dead
89
+ if not is_pty_alive:
90
+ return True, "PTY died"
91
+
92
+ # Check max duration (0 = no limit)
93
+ if self.config.max_duration_seconds > 0:
94
+ age = (now - session.created_at).total_seconds()
95
+ if age > self.config.max_duration_seconds:
96
+ return True, "Exceeded max duration"
97
+
98
+ # Check reconnection window (0 = no limit, only for disconnected sessions)
99
+ if self.config.reconnect_window_seconds > 0 and not session.is_connected:
100
+ idle = (now - session.last_activity).total_seconds()
101
+ if idle > self.config.reconnect_window_seconds:
102
+ return True, "Reconnection window expired"
103
+
104
+ return False, None
@@ -0,0 +1,54 @@
1
+ """Tab limit checking service - pure business logic."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ..entities.tab import MAX_TABS_PER_USER, Tab
6
+ from ..values.user_id import UserId
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class TabLimitConfig:
11
+ """Tab limit configuration."""
12
+
13
+ max_per_user: int = MAX_TABS_PER_USER
14
+
15
+
16
+ @dataclass
17
+ class TabLimitResult:
18
+ """Result of tab limit check."""
19
+
20
+ allowed: bool
21
+ reason: str | None = None
22
+
23
+
24
+ class TabLimitChecker:
25
+ """Check tab limits (pure business logic)."""
26
+
27
+ def __init__(self, config: TabLimitConfig | None = None) -> None:
28
+ self.config = config or TabLimitConfig()
29
+
30
+ def can_create_tab(
31
+ self,
32
+ user_id: UserId,
33
+ user_tab_count: int,
34
+ ) -> TabLimitResult:
35
+ """Check if a new tab can be created for user."""
36
+ if user_tab_count >= self.config.max_per_user:
37
+ return TabLimitResult(
38
+ allowed=False,
39
+ reason=f"Maximum tabs ({self.config.max_per_user}) reached",
40
+ )
41
+ return TabLimitResult(allowed=True)
42
+
43
+ def can_access_tab(
44
+ self,
45
+ tab: Tab,
46
+ requesting_user: UserId,
47
+ ) -> TabLimitResult:
48
+ """Check if user can access a tab."""
49
+ if tab.user_id != requesting_user:
50
+ return TabLimitResult(
51
+ allowed=False,
52
+ reason="Tab belongs to another user",
53
+ )
54
+ return TabLimitResult(allowed=True)
@@ -0,0 +1,25 @@
1
+ """Domain value objects - immutable data structures."""
2
+
3
+ from .environment_rules import DEFAULT_BLOCKED_VARS, DEFAULT_SAFE_VARS, EnvironmentRules
4
+ from .rate_limit_config import RateLimitConfig
5
+ from .session_id import SessionId
6
+ from .shell_command import ShellCommand
7
+ from .tab_id import TabId
8
+ from .terminal_dimensions import MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS, TerminalDimensions
9
+ from .user_id import UserId
10
+
11
+ __all__ = [
12
+ "TerminalDimensions",
13
+ "MIN_COLS",
14
+ "MAX_COLS",
15
+ "MIN_ROWS",
16
+ "MAX_ROWS",
17
+ "SessionId",
18
+ "UserId",
19
+ "TabId",
20
+ "ShellCommand",
21
+ "RateLimitConfig",
22
+ "EnvironmentRules",
23
+ "DEFAULT_SAFE_VARS",
24
+ "DEFAULT_BLOCKED_VARS",
25
+ ]
@@ -0,0 +1,156 @@
1
+ """Environment sanitization rules value object."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ # These are the business rules for environment sanitization
6
+ # Extracted from pty/env.py - pure data, no I/O
7
+
8
+ DEFAULT_SAFE_VARS: frozenset[str] = frozenset(
9
+ {
10
+ # System paths
11
+ "PATH",
12
+ "PATHEXT",
13
+ "SYSTEMROOT",
14
+ "WINDIR",
15
+ "TEMP",
16
+ "TMP",
17
+ "COMSPEC",
18
+ # User directories
19
+ "HOME",
20
+ "USERPROFILE",
21
+ "HOMEDRIVE",
22
+ "HOMEPATH",
23
+ "LOCALAPPDATA",
24
+ "APPDATA",
25
+ "PROGRAMFILES",
26
+ "PROGRAMFILES(X86)",
27
+ "COMMONPROGRAMFILES",
28
+ # System info
29
+ "COMPUTERNAME",
30
+ "USERNAME",
31
+ "USERDOMAIN",
32
+ "OS",
33
+ "PROCESSOR_ARCHITECTURE",
34
+ "NUMBER_OF_PROCESSORS",
35
+ # Terminal
36
+ "TERM",
37
+ "COLORTERM",
38
+ "TERM_PROGRAM",
39
+ # Locale
40
+ "LANG",
41
+ "LC_ALL",
42
+ "LC_CTYPE",
43
+ # Shell-specific
44
+ "SHELL",
45
+ "SHLVL",
46
+ "PWD",
47
+ "OLDPWD",
48
+ # Editor
49
+ "EDITOR",
50
+ "VISUAL",
51
+ # Display (for X11 forwarding if needed)
52
+ "DISPLAY",
53
+ # SSH (non-sensitive)
54
+ "SSH_TTY",
55
+ "SSH_CONNECTION",
56
+ # XDG directories
57
+ "XDG_CONFIG_HOME",
58
+ "XDG_DATA_HOME",
59
+ "XDG_CACHE_HOME",
60
+ "XDG_RUNTIME_DIR",
61
+ # Python
62
+ "PYTHONPATH",
63
+ "VIRTUAL_ENV",
64
+ # Node
65
+ "NODE_PATH",
66
+ # Go
67
+ "GOPATH",
68
+ # Rust
69
+ "CARGO_HOME",
70
+ "RUSTUP_HOME",
71
+ }
72
+ )
73
+
74
+ DEFAULT_BLOCKED_VARS: frozenset[str] = frozenset(
75
+ {
76
+ # AWS
77
+ "AWS_ACCESS_KEY_ID",
78
+ "AWS_SECRET_ACCESS_KEY",
79
+ "AWS_SESSION_TOKEN",
80
+ "AWS_SECURITY_TOKEN",
81
+ # Azure
82
+ "AZURE_CLIENT_SECRET",
83
+ "AZURE_CLIENT_ID",
84
+ "AZURE_TENANT_ID",
85
+ # GCP
86
+ "GOOGLE_APPLICATION_CREDENTIALS",
87
+ "CLOUDSDK_AUTH_ACCESS_TOKEN",
88
+ # Git/GitHub/GitLab
89
+ "GH_TOKEN",
90
+ "GITHUB_TOKEN",
91
+ "GITLAB_TOKEN",
92
+ "GIT_ASKPASS",
93
+ "GIT_CREDENTIALS",
94
+ # Package managers
95
+ "NPM_TOKEN",
96
+ "PYPI_TOKEN",
97
+ "RUBYGEMS_API_KEY",
98
+ # AI APIs
99
+ "ANTHROPIC_API_KEY",
100
+ "OPENAI_API_KEY",
101
+ "GOOGLE_API_KEY",
102
+ "COHERE_API_KEY",
103
+ # Payment
104
+ "STRIPE_SECRET_KEY",
105
+ "STRIPE_API_KEY",
106
+ # Database
107
+ "DATABASE_URL",
108
+ "DB_PASSWORD",
109
+ "MYSQL_PASSWORD",
110
+ "POSTGRES_PASSWORD",
111
+ "REDIS_PASSWORD",
112
+ "MONGODB_URI",
113
+ # Generic secrets
114
+ "SECRET_KEY",
115
+ "API_KEY",
116
+ "API_SECRET",
117
+ "PRIVATE_KEY",
118
+ "ACCESS_TOKEN",
119
+ "REFRESH_TOKEN",
120
+ "AUTH_TOKEN",
121
+ "JWT_SECRET",
122
+ "ENCRYPTION_KEY",
123
+ # SSH keys (if exposed via env)
124
+ "SSH_PRIVATE_KEY",
125
+ "SSH_KEY",
126
+ # Misc services
127
+ "SLACK_TOKEN",
128
+ "DISCORD_TOKEN",
129
+ "TELEGRAM_TOKEN",
130
+ "TWILIO_AUTH_TOKEN",
131
+ "SENDGRID_API_KEY",
132
+ "MAILGUN_API_KEY",
133
+ }
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class EnvironmentRules:
139
+ """Rules for environment variable sanitization."""
140
+
141
+ allowed_vars: frozenset[str] = field(default_factory=lambda: DEFAULT_SAFE_VARS)
142
+ blocked_vars: frozenset[str] = field(default_factory=lambda: DEFAULT_BLOCKED_VARS)
143
+ forced_vars: tuple[tuple[str, str], ...] = (
144
+ ("TERM", "xterm-256color"),
145
+ ("TERM_SESSION_TYPE", "remote-web"),
146
+ )
147
+
148
+ def __post_init__(self) -> None:
149
+ # Ensure blocked vars are not in allowed vars
150
+ overlap = self.allowed_vars & self.blocked_vars
151
+ if overlap:
152
+ raise ValueError(f"Vars cannot be both allowed and blocked: {overlap}")
153
+
154
+ def get_forced_vars_dict(self) -> dict[str, str]:
155
+ """Get forced variables as a dictionary."""
156
+ return dict(self.forced_vars)
@@ -0,0 +1,21 @@
1
+ """Rate limit configuration value object."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ # Default business rules
6
+ DEFAULT_RATE = 100.0 # tokens per second
7
+ DEFAULT_BURST = 500
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class RateLimitConfig:
12
+ """Rate limiting configuration (value object)."""
13
+
14
+ rate: float = DEFAULT_RATE
15
+ burst: int = DEFAULT_BURST
16
+
17
+ def __post_init__(self) -> None:
18
+ if self.rate <= 0:
19
+ raise ValueError("Rate must be positive")
20
+ if self.burst <= 0:
21
+ raise ValueError("Burst must be positive")
@@ -0,0 +1,20 @@
1
+ """Session ID value object."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class SessionId:
8
+ """Strongly-typed session identifier."""
9
+
10
+ value: str
11
+
12
+ def __post_init__(self) -> None:
13
+ if not self.value or not isinstance(self.value, str):
14
+ raise ValueError("SessionId cannot be empty")
15
+
16
+ def __str__(self) -> str:
17
+ return self.value
18
+
19
+ def __hash__(self) -> int:
20
+ return hash(self.value)