tywrap 0.5.1 → 0.6.0

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 (155) hide show
  1. package/dist/cli.js +8 -6
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +236 -147
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts.map +1 -1
  7. package/dist/core/annotation-parser.js +93 -83
  8. package/dist/core/annotation-parser.js.map +1 -1
  9. package/dist/core/discovery.d.ts.map +1 -1
  10. package/dist/core/discovery.js +26 -0
  11. package/dist/core/discovery.js.map +1 -1
  12. package/dist/core/emit-call.d.ts +58 -0
  13. package/dist/core/emit-call.d.ts.map +1 -0
  14. package/dist/core/emit-call.js +103 -0
  15. package/dist/core/emit-call.js.map +1 -0
  16. package/dist/core/generator.d.ts +2 -0
  17. package/dist/core/generator.d.ts.map +1 -1
  18. package/dist/core/generator.js +54 -211
  19. package/dist/core/generator.js.map +1 -1
  20. package/dist/core/mapper.d.ts.map +1 -1
  21. package/dist/core/mapper.js +118 -104
  22. package/dist/core/mapper.js.map +1 -1
  23. package/dist/index.d.ts +6 -25
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -31
  26. package/dist/index.js.map +1 -1
  27. package/dist/runtime/bounded-context.d.ts +2 -2
  28. package/dist/runtime/bounded-context.js +2 -2
  29. package/dist/runtime/{safe-codec.d.ts → bridge-codec.d.ts} +11 -11
  30. package/dist/runtime/bridge-codec.d.ts.map +1 -0
  31. package/dist/runtime/{safe-codec.js → bridge-codec.js} +26 -26
  32. package/dist/runtime/bridge-codec.js.map +1 -0
  33. package/dist/runtime/{http-io.d.ts → http-transport.d.ts} +6 -6
  34. package/dist/runtime/http-transport.d.ts.map +1 -0
  35. package/dist/runtime/{http-io.js → http-transport.js} +3 -3
  36. package/dist/runtime/http-transport.js.map +1 -0
  37. package/dist/runtime/http.d.ts +3 -3
  38. package/dist/runtime/http.d.ts.map +1 -1
  39. package/dist/runtime/http.js +4 -4
  40. package/dist/runtime/http.js.map +1 -1
  41. package/dist/runtime/index.d.ts +9 -1
  42. package/dist/runtime/index.d.ts.map +1 -1
  43. package/dist/runtime/index.js +9 -1
  44. package/dist/runtime/index.js.map +1 -1
  45. package/dist/runtime/node.d.ts +6 -6
  46. package/dist/runtime/node.d.ts.map +1 -1
  47. package/dist/runtime/node.js +7 -7
  48. package/dist/runtime/node.js.map +1 -1
  49. package/dist/runtime/pooled-transport.d.ts +11 -11
  50. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  51. package/dist/runtime/pooled-transport.js +10 -10
  52. package/dist/runtime/pooled-transport.js.map +1 -1
  53. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  56. package/dist/runtime/{pyodide-io.d.ts → pyodide-transport.d.ts} +8 -8
  57. package/dist/runtime/pyodide-transport.d.ts.map +1 -0
  58. package/dist/runtime/{pyodide-io.js → pyodide-transport.js} +5 -5
  59. package/dist/runtime/pyodide-transport.js.map +1 -0
  60. package/dist/runtime/pyodide.d.ts +3 -3
  61. package/dist/runtime/pyodide.d.ts.map +1 -1
  62. package/dist/runtime/pyodide.js +5 -5
  63. package/dist/runtime/pyodide.js.map +1 -1
  64. package/dist/runtime/rpc-client.d.ts +5 -5
  65. package/dist/runtime/rpc-client.d.ts.map +1 -1
  66. package/dist/runtime/rpc-client.js +6 -7
  67. package/dist/runtime/rpc-client.js.map +1 -1
  68. package/dist/runtime/{process-io.d.ts → subprocess-transport.d.ts} +9 -9
  69. package/dist/runtime/subprocess-transport.d.ts.map +1 -0
  70. package/dist/runtime/{process-io.js → subprocess-transport.js} +6 -6
  71. package/dist/runtime/subprocess-transport.js.map +1 -0
  72. package/dist/runtime/{worker-pool.d.ts → transport-pool.d.ts} +15 -15
  73. package/dist/runtime/transport-pool.d.ts.map +1 -0
  74. package/dist/runtime/{worker-pool.js → transport-pool.js} +6 -6
  75. package/dist/runtime/transport-pool.js.map +1 -0
  76. package/dist/runtime/transport.d.ts +14 -8
  77. package/dist/runtime/transport.d.ts.map +1 -1
  78. package/dist/runtime/transport.js +10 -4
  79. package/dist/runtime/transport.js.map +1 -1
  80. package/dist/types/index.d.ts +10 -1
  81. package/dist/types/index.d.ts.map +1 -1
  82. package/dist/utils/cache.d.ts +2 -2
  83. package/dist/utils/cache.d.ts.map +1 -1
  84. package/dist/utils/cache.js +2 -2
  85. package/dist/utils/cache.js.map +1 -1
  86. package/dist/utils/codec.d.ts +1 -1
  87. package/dist/utils/codec.d.ts.map +1 -1
  88. package/dist/utils/codec.js +181 -182
  89. package/dist/utils/codec.js.map +1 -1
  90. package/dist/version.d.ts +12 -0
  91. package/dist/version.d.ts.map +1 -0
  92. package/dist/version.js +12 -0
  93. package/dist/version.js.map +1 -0
  94. package/package.json +3 -2
  95. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  96. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  97. package/runtime/python_bridge.py +54 -6
  98. package/runtime/safe_codec.py +8 -8
  99. package/runtime/tywrap_bridge_core.py +159 -23
  100. package/src/cli.ts +8 -12
  101. package/src/config/index.ts +271 -165
  102. package/src/core/annotation-parser.ts +112 -105
  103. package/src/core/discovery.ts +32 -0
  104. package/src/core/emit-call.ts +171 -0
  105. package/src/core/generator.ts +67 -263
  106. package/src/core/mapper.ts +125 -110
  107. package/src/index.ts +11 -68
  108. package/src/runtime/bounded-context.ts +2 -2
  109. package/src/runtime/{safe-codec.ts → bridge-codec.ts} +27 -27
  110. package/src/runtime/{http-io.ts → http-transport.ts} +5 -5
  111. package/src/runtime/http.ts +5 -5
  112. package/src/runtime/index.ts +23 -1
  113. package/src/runtime/node.ts +15 -15
  114. package/src/runtime/pooled-transport.ts +15 -15
  115. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  116. package/src/runtime/{pyodide-io.ts → pyodide-transport.ts} +7 -7
  117. package/src/runtime/pyodide.ts +6 -6
  118. package/src/runtime/rpc-client.ts +12 -8
  119. package/src/runtime/{process-io.ts → subprocess-transport.ts} +8 -8
  120. package/src/runtime/{worker-pool.ts → transport-pool.ts} +26 -26
  121. package/src/runtime/transport.ts +15 -8
  122. package/src/types/index.ts +10 -1
  123. package/src/utils/cache.ts +2 -2
  124. package/src/utils/codec.ts +217 -196
  125. package/src/version.ts +12 -0
  126. package/dist/runtime/http-io.d.ts.map +0 -1
  127. package/dist/runtime/http-io.js.map +0 -1
  128. package/dist/runtime/optimized-node.d.ts +0 -23
  129. package/dist/runtime/optimized-node.d.ts.map +0 -1
  130. package/dist/runtime/optimized-node.js +0 -23
  131. package/dist/runtime/optimized-node.js.map +0 -1
  132. package/dist/runtime/process-io.d.ts.map +0 -1
  133. package/dist/runtime/process-io.js.map +0 -1
  134. package/dist/runtime/protocol.d.ts +0 -3
  135. package/dist/runtime/protocol.d.ts.map +0 -1
  136. package/dist/runtime/protocol.js +0 -3
  137. package/dist/runtime/protocol.js.map +0 -1
  138. package/dist/runtime/pyodide-io.d.ts.map +0 -1
  139. package/dist/runtime/pyodide-io.js.map +0 -1
  140. package/dist/runtime/safe-codec.d.ts.map +0 -1
  141. package/dist/runtime/safe-codec.js.map +0 -1
  142. package/dist/runtime/worker-pool.d.ts.map +0 -1
  143. package/dist/runtime/worker-pool.js.map +0 -1
  144. package/dist/utils/bundle-optimizer.d.ts +0 -182
  145. package/dist/utils/bundle-optimizer.d.ts.map +0 -1
  146. package/dist/utils/bundle-optimizer.js +0 -680
  147. package/dist/utils/bundle-optimizer.js.map +0 -1
  148. package/dist/utils/memory-profiler.d.ts +0 -123
  149. package/dist/utils/memory-profiler.d.ts.map +0 -1
  150. package/dist/utils/memory-profiler.js +0 -426
  151. package/dist/utils/memory-profiler.js.map +0 -1
  152. package/src/runtime/optimized-node.ts +0 -25
  153. package/src/runtime/protocol.ts +0 -2
  154. package/src/utils/bundle-optimizer.ts +0 -876
  155. package/src/utils/memory-profiler.ts +0 -582
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tywrap",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Generate type-safe TypeScript wrappers for any Python library — Node.js, Deno, Bun, and browsers via Pyodide.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -38,7 +38,8 @@
38
38
  "README.md"
39
39
  ],
40
40
  "scripts": {
41
- "build": "npm run build:pyodide-bootstrap && tsc && npm run build:tools",
41
+ "build": "npm run build:version && npm run build:pyodide-bootstrap && tsc && npm run build:tools",
42
+ "build:version": "node scripts/generate-version.mjs",
42
43
  "build:pyodide-bootstrap": "node scripts/generate-pyodide-bootstrap.mjs",
43
44
  "build:tools": "tsc -p tsconfig.tools.json",
44
45
  "dev": "tsc --watch",
@@ -7,10 +7,25 @@ subprocess transport and the HTTP transport:
7
7
  - the stdin/stdout JSONL request/response loop (main())
8
8
  - env-var size guards (TYWRAP_CODEC_MAX_BYTES / TYWRAP_REQUEST_MAX_BYTES)
9
9
  - TYWRAP_CODEC_FALLBACK=json marker mode and TYWRAP_TORCH_ALLOW_COPY
10
+ - the import/attribute allowlist policy (TYWRAP_ALLOWED_MODULES /
11
+ TYWRAP_ALLOW_PRIVATE_ATTRS), threaded into core.dispatch_request
10
12
  - the real OS pid and bridge='python-subprocess' identity
11
- - the final SafeCodec.encode wrapper (NaN rejection + numpy scalar coercion +
13
+ - the final BridgeCodec.encode wrapper (NaN rejection + numpy scalar coercion +
12
14
  the explicit byte-size limit error message)
13
15
 
16
+ SECURITY / TRUST MODEL: the bridge imports the requested module and getattrs the
17
+ requested function/class/method, so call/instantiate/call_method are an arbitrary
18
+ import+getattr+call surface. Two guards (implemented in tywrap_bridge_core) bound
19
+ that surface:
20
+ * TYWRAP_ALLOWED_MODULES (comma/space separated): when set, only those modules
21
+ (plus the stdlib the bridge itself needs) may be imported; anything else fails
22
+ with ImportNotAllowedError. UNSET = no restriction (preserves prior behavior).
23
+ * TYWRAP_ALLOW_PRIVATE_ATTRS=1: opt out of the default block on underscore-prefixed
24
+ (private/dunder) attribute access, which otherwise prevents the classic
25
+ __globals__/__subclasses__/__builtins__ escape chain.
26
+ The bridge does NOT sandbox the called code itself; only trusted Python should be
27
+ exposed. See SECURITY.md for the full threat model.
28
+
14
29
  The protocol dispatch, request deserialization, and the 6 __tywrap__ marker
15
30
  serializers live in the shared, pure module tywrap_bridge_core (so the in-WASM
16
31
  Pyodide server can run the SAME code). Those names are re-exported below for
@@ -22,7 +37,7 @@ import json
22
37
  import os
23
38
  import importlib # noqa: F401 (re-exported for compat / used by handlers via core)
24
39
 
25
- from safe_codec import SafeCodec, CodecError
40
+ from safe_codec import BridgeCodec, CodecError
26
41
 
27
42
  import tywrap_bridge_core as core
28
43
 
@@ -34,6 +49,10 @@ from tywrap_bridge_core import ( # noqa: F401
34
49
  CODEC_VERSION,
35
50
  ProtocolError,
36
51
  InstanceHandleError,
52
+ ImportNotAllowedError,
53
+ AttributeNotAllowedError,
54
+ import_allowed_module,
55
+ get_allowed_attr,
37
56
  deserialize,
38
57
  arrow_available,
39
58
  module_available,
@@ -77,6 +96,33 @@ TORCH_ALLOW_COPY = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1'
77
96
  BRIDGE_NAME = 'python-subprocess'
78
97
 
79
98
 
99
+ def parse_allowed_modules():
100
+ """
101
+ Parse TYWRAP_ALLOWED_MODULES into an allowlist set, or None when unset.
102
+
103
+ Why: returning None preserves the historical "import anything" behavior so
104
+ existing configurations keep working; supplying the env var (comma- and/or
105
+ whitespace-separated module names) switches the bridge into allowlist mode,
106
+ where only the listed modules plus the stdlib the bridge itself needs may be
107
+ imported. An empty/blank value is treated as unset (no restriction).
108
+ """
109
+ raw = os.environ.get('TYWRAP_ALLOWED_MODULES')
110
+ if raw is None:
111
+ return None
112
+ names = {name.strip() for chunk in raw.split(',') for name in chunk.split()}
113
+ names.discard('')
114
+ if not names:
115
+ return None
116
+ return frozenset(names)
117
+
118
+
119
+ # Why: parse once at startup. None => no allowlist (prior behavior preserved).
120
+ ALLOWED_MODULES = parse_allowed_modules()
121
+ # Why: underscore-prefixed (private/dunder) attribute access is blocked by default
122
+ # to prevent sandbox-escape via __globals__/__subclasses__/__builtins__; this opts out.
123
+ ALLOW_PRIVATE_ATTRS = os.environ.get('TYWRAP_ALLOW_PRIVATE_ATTRS', '').lower() in ('1', 'true', 'yes')
124
+
125
+
80
126
  class CodecConfigError(ValueError):
81
127
  """Codec configuration error."""
82
128
 
@@ -138,12 +184,12 @@ def get_codec_max_bytes():
138
184
  # Why: parse once at startup to avoid per-response env lookups.
139
185
  CODEC_MAX_BYTES = get_codec_max_bytes()
140
186
 
141
- # Why: use SafeCodec for final JSON encoding to reject NaN/Infinity and handle
142
- # edge cases like numpy scalars. We use sys.maxsize for SafeCodec's internal limit
187
+ # Why: use BridgeCodec for final JSON encoding to reject NaN/Infinity and handle
188
+ # edge cases like numpy scalars. We use sys.maxsize for BridgeCodec's internal limit
143
189
  # to preserve the original "no limit unless TYWRAP_CODEC_MAX_BYTES is set" behavior.
144
190
  # The explicit size check in encode_response() provides the specific error message
145
191
  # mentioning the env var name, which is important for debugging.
146
- _response_codec = SafeCodec(
192
+ _response_codec = BridgeCodec(
147
193
  allow_nan=False,
148
194
  max_payload_bytes=sys.maxsize,
149
195
  )
@@ -241,6 +287,8 @@ def dispatch_request(msg):
241
287
  allow_nan=False,
242
288
  python_version=sys.version.split()[0],
243
289
  torch_allow_copy=TORCH_ALLOW_COPY,
290
+ allowed_modules=ALLOWED_MODULES,
291
+ allow_private_attrs=ALLOW_PRIVATE_ATTRS,
244
292
  )
245
293
  return out['id'], out['result']
246
294
 
@@ -250,7 +298,7 @@ def encode_response(out):
250
298
  Serialize the response and enforce size limits.
251
299
 
252
300
  Why: keep payload size checks outside the main loop for clarity and lint compliance.
253
- Uses SafeCodec to reject NaN/Infinity and handle edge cases like numpy scalars.
301
+ Uses BridgeCodec to reject NaN/Infinity and handle edge cases like numpy scalars.
254
302
  """
255
303
  try:
256
304
  payload = _response_codec.encode(out)
@@ -87,7 +87,7 @@ def _has_pydantic_model_dump(obj: Any) -> bool:
87
87
  return callable(model_dump)
88
88
 
89
89
 
90
- class SafeCodec:
90
+ class BridgeCodec:
91
91
  """
92
92
  Safe JSON codec with explicit edge case handling.
93
93
 
@@ -103,7 +103,7 @@ class SafeCodec:
103
103
  max_payload_bytes: Maximum payload size in bytes (default 10MB).
104
104
 
105
105
  Example:
106
- >>> codec = SafeCodec()
106
+ >>> codec = BridgeCodec()
107
107
  >>> codec.encode({"key": "value"})
108
108
  '{"key": "value"}'
109
109
  >>> codec.decode('{"key": "value"}')
@@ -300,19 +300,19 @@ class SafeCodec:
300
300
 
301
301
 
302
302
  # Module-level convenience instance with default settings
303
- _default_codec: Optional[SafeCodec] = None
303
+ _default_codec: Optional[BridgeCodec] = None
304
304
 
305
305
 
306
- def get_default_codec() -> SafeCodec:
306
+ def get_default_codec() -> BridgeCodec:
307
307
  """
308
- Get or create the default SafeCodec instance.
308
+ Get or create the default BridgeCodec instance.
309
309
 
310
310
  Returns:
311
- The default SafeCodec instance with standard settings.
311
+ The default BridgeCodec instance with standard settings.
312
312
  """
313
313
  global _default_codec
314
314
  if _default_codec is None:
315
- _default_codec = SafeCodec()
315
+ _default_codec = BridgeCodec()
316
316
  return _default_codec
317
317
 
318
318
 
@@ -331,7 +331,7 @@ def encode(value: Any, *, allow_nan: bool = False) -> str:
331
331
  CodecError: If encoding fails.
332
332
  """
333
333
  if allow_nan:
334
- codec = SafeCodec(allow_nan=True)
334
+ codec = BridgeCodec(allow_nan=True)
335
335
  return codec.encode(value)
336
336
  return get_default_codec().encode(value)
337
337
 
@@ -6,10 +6,10 @@ protocol. It is imported by:
6
6
 
7
7
  - runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP
8
8
  server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size
9
- guards, the real OS pid, bridge='python-subprocess', and the final SafeCodec
9
+ guards, the real OS pid, bridge='python-subprocess', and the final BridgeCodec
10
10
  encode wrapper.
11
11
 
12
- - the in-WASM Pyodide server (src/runtime/pyodide-io.ts). Pyodide cannot read
12
+ - the in-WASM Pyodide server (src/runtime/pyodide-transport.ts). Pyodide cannot read
13
13
  this file from disk, so it is shipped as a build-time-generated TypeScript
14
14
  string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by
15
15
  scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in
@@ -17,7 +17,7 @@ protocol. It is imported by:
17
17
  asserts the generated constant stays byte-identical to this file.
18
18
 
19
19
  CROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts
20
- and the request encoder in src/runtime/safe-codec.ts):
20
+ and the request encoder in src/runtime/bridge-codec.ts):
21
21
 
22
22
  * Every value-type "marker" envelope carries {'__tywrap__': <type>,
23
23
  'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,
@@ -66,6 +66,123 @@ class InstanceHandleError(ValueError):
66
66
  """Raised when an instance handle is unknown or no longer valid."""
67
67
 
68
68
 
69
+ class ImportNotAllowedError(PermissionError):
70
+ """Raised when a requested module import is not on the active allowlist."""
71
+
72
+ def __init__(self, module_name):
73
+ super().__init__(
74
+ f'Import of module {module_name!r} is not permitted by the tywrap bridge '
75
+ 'allowlist; add it to TYWRAP_ALLOWED_MODULES (subprocess) or the '
76
+ 'allowed_modules parameter to enable it'
77
+ )
78
+
79
+
80
+ class AttributeNotAllowedError(PermissionError):
81
+ """Raised when access to a private/dunder attribute is denied by policy."""
82
+
83
+ def __init__(self, attr_name):
84
+ super().__init__(
85
+ f'Access to attribute {attr_name!r} is not permitted by the tywrap bridge: '
86
+ 'underscore-prefixed (private/dunder) attributes are blocked to prevent '
87
+ 'sandbox-escape via attributes like __globals__/__subclasses__/__builtins__; '
88
+ 'set TYWRAP_ALLOW_PRIVATE_ATTRS=1 (subprocess) or pass allow_private_attrs=True '
89
+ 'to override'
90
+ )
91
+
92
+
93
+ # =============================================================================
94
+ # IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)
95
+ # =============================================================================
96
+ #
97
+ # The bridge dispatches call/instantiate/call_method by importing the requested
98
+ # module and getattr-ing the requested function/class/method. That is an
99
+ # arbitrary import+getattr+call surface, so two complementary guards live here.
100
+ # Both are PURE (no env reads) so the rules behave identically under the
101
+ # subprocess server and the in-WASM Pyodide server; the subprocess server derives
102
+ # the parameters from env vars (TYWRAP_ALLOWED_MODULES / TYWRAP_ALLOW_PRIVATE_ATTRS)
103
+ # and threads them in, exactly like force_json_markers / torch_allow_copy.
104
+ #
105
+ # 1. MODULE ALLOWLIST (opt-in, default = allow all):
106
+ # allowed_modules=None means "no restriction" so existing configurations keep
107
+ # working unchanged. When a caller supplies a set, only those modules (plus the
108
+ # stdlib the bridge itself needs to serialize results, see _BRIDGE_REQUIRED_MODULES)
109
+ # may be imported; submodules of an allowed module are permitted (e.g. allowing
110
+ # 'scipy' also allows 'scipy.sparse'). A non-allowlisted import fails LOUDLY with
111
+ # ImportNotAllowedError rather than silently importing.
112
+ #
113
+ # 2. PRIVATE-ATTRIBUTE BLOCK (default ON):
114
+ # getattr of any name starting with '_' (single-underscore private OR dunder) is
115
+ # rejected. This blocks the classic escape chain (obj.__class__.__subclasses__()
116
+ # /__globals__/__builtins__/__import__) without depending on the module allowlist.
117
+ # tywrap-generated wrappers never reference underscore-prefixed names (the IR
118
+ # analyzer skips them), so this does not regress generated code. Set
119
+ # allow_private_attrs=True to restore unrestricted getattr for trusted callers.
120
+
121
+ # Stdlib modules the bridge's own serialization/handlers may need to import even
122
+ # when a caller-supplied allowlist is active. Optional codec deps (numpy, pandas,
123
+ # scipy, torch, sklearn, pyarrow) are intentionally NOT here: if a caller restricts
124
+ # modules, they must opt those in explicitly. These names cover only what the
125
+ # bridge core itself imports.
126
+ _BRIDGE_REQUIRED_MODULES = frozenset(
127
+ {
128
+ 'base64',
129
+ 'datetime',
130
+ 'decimal',
131
+ 'importlib',
132
+ 'json',
133
+ 'math',
134
+ 'sys',
135
+ 'traceback',
136
+ 'uuid',
137
+ 'pathlib',
138
+ }
139
+ )
140
+
141
+
142
+ def _top_level_package(module_name):
143
+ """Return the top-level package of a dotted module name ('a.b.c' -> 'a')."""
144
+ return module_name.split('.', 1)[0]
145
+
146
+
147
+ def _is_module_allowed(module_name, allowed_modules):
148
+ """
149
+ Return True when module_name may be imported under the active policy.
150
+
151
+ allowed_modules=None disables enforcement (allow all). Otherwise a module is
152
+ allowed when it (or its top-level package) is explicitly listed, or it is one
153
+ of the stdlib modules the bridge itself requires.
154
+ """
155
+ if allowed_modules is None:
156
+ return True
157
+ if module_name in allowed_modules or module_name in _BRIDGE_REQUIRED_MODULES:
158
+ return True
159
+ top = _top_level_package(module_name)
160
+ return top in allowed_modules or top in _BRIDGE_REQUIRED_MODULES
161
+
162
+
163
+ def import_allowed_module(module_name, allowed_modules):
164
+ """
165
+ Import module_name only if permitted by the allowlist, else raise loudly.
166
+
167
+ This is the single chokepoint every handler routes module imports through.
168
+ """
169
+ if not _is_module_allowed(module_name, allowed_modules):
170
+ raise ImportNotAllowedError(module_name)
171
+ return importlib.import_module(module_name)
172
+
173
+
174
+ def get_allowed_attr(obj, attr_name, *, allow_private_attrs):
175
+ """
176
+ getattr(obj, attr_name) with the private/dunder block applied.
177
+
178
+ Rejects any underscore-prefixed name unless allow_private_attrs is True. This
179
+ is the single chokepoint every handler routes attribute access through.
180
+ """
181
+ if not allow_private_attrs and attr_name.startswith('_'):
182
+ raise AttributeNotAllowedError(attr_name)
183
+ return getattr(obj, attr_name)
184
+
185
+
69
186
  class CodecError(Exception):
70
187
  """Raised when value encoding fails (e.g. NaN/Infinity not allowed)."""
71
188
 
@@ -85,10 +202,10 @@ def _deserialize_bytes_envelope(value):
85
202
  Decode base64-encoded bytes envelopes from JS into Python bytes.
86
203
 
87
204
  Supported shapes:
88
- - { "__tywrap_bytes__": true, "b64": "..." } (JS SafeCodec.encodeRequest)
205
+ - { "__tywrap_bytes__": true, "b64": "..." } (JS BridgeCodec.encodeRequest)
89
206
  - { "__type__": "bytes", "encoding": "base64", "data": "..." } (legacy/compat)
90
207
 
91
- Why: TS SafeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but
208
+ Why: TS BridgeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but
92
209
  Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).
93
210
  """
94
211
  if not isinstance(value, dict):
@@ -530,7 +647,7 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
530
647
  """
531
648
  Top-level result serializer. Dispatch order is significant: numpy ndarray ->
532
649
  dataframe -> series -> scipy.sparse -> torch -> sklearn -> Pydantic -> stdlib
533
- -> passthrough. The remaining SafeCodec value behaviors (numpy/pandas scalars,
650
+ -> passthrough. The remaining BridgeCodec value behaviors (numpy/pandas scalars,
534
651
  bytes, sets, complex rejection, NaN/Infinity) are applied later during JSON
535
652
  encoding by default_encoder.
536
653
  """
@@ -558,12 +675,12 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
558
675
 
559
676
 
560
677
  # =============================================================================
561
- # JSON ENCODE: SafeCodec-equivalent value handling (NaN reject, scalars, bytes)
678
+ # JSON ENCODE: BridgeCodec-equivalent value handling (NaN reject, scalars, bytes)
562
679
  # =============================================================================
563
680
  #
564
- # This mirrors SafeCodec._default_encoder (runtime/safe_codec.py) for the VALUE
681
+ # This mirrors BridgeCodec._default_encoder (runtime/safe_codec.py) for the VALUE
565
682
  # behaviors that are part of the wire contract. The subprocess server still uses
566
- # the real SafeCodec for its final encode (it also enforces size limits); this
683
+ # the real BridgeCodec for its final encode (it also enforces size limits); this
567
684
  # core encoder exists so the Pyodide server gets identical value handling without
568
685
  # depending on safe_codec.py. The conformance suite asserts these behaviors match.
569
686
 
@@ -594,7 +711,7 @@ def _is_pandas_scalar(obj):
594
711
 
595
712
  def make_default_encoder(*, allow_nan):
596
713
  """
597
- Build a json.dumps default= encoder matching SafeCodec's value handling.
714
+ Build a json.dumps default= encoder matching BridgeCodec's value handling.
598
715
 
599
716
  Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps
600
717
  itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).
@@ -663,11 +780,11 @@ def make_default_encoder(*, allow_nan):
663
780
 
664
781
  def encode_value(value, *, allow_nan):
665
782
  """
666
- JSON-encode a fully-serialized response value, applying the SafeCodec-equivalent
783
+ JSON-encode a fully-serialized response value, applying the BridgeCodec-equivalent
667
784
  default encoder and rejecting NaN/Infinity when allow_nan is False.
668
785
 
669
786
  Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching
670
- SafeCodec's "Cannot serialize NaN..." wording so error parity holds.
787
+ BridgeCodec's "Cannot serialize NaN..." wording so error parity holds.
671
788
  """
672
789
  try:
673
790
  return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)
@@ -729,31 +846,31 @@ def coerce_dict(value, key):
729
846
  return value
730
847
 
731
848
 
732
- def handle_call(params, *, force_json_markers, torch_allow_copy):
849
+ def handle_call(params, *, force_json_markers, torch_allow_copy, allowed_modules, allow_private_attrs):
733
850
  module_name = require_str(params, 'module')
734
851
  function_name = require_str(params, 'functionName')
735
852
  args = deserialize(coerce_list(params.get('args'), 'args'))
736
853
  kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
737
- mod = importlib.import_module(module_name)
738
- func = getattr(mod, function_name)
854
+ mod = import_allowed_module(module_name, allowed_modules)
855
+ func = get_allowed_attr(mod, function_name, allow_private_attrs=allow_private_attrs)
739
856
  res = func(*args, **kwargs)
740
857
  return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
741
858
 
742
859
 
743
- def handle_instantiate(params, instances):
860
+ def handle_instantiate(params, instances, *, allowed_modules, allow_private_attrs):
744
861
  module_name = require_str(params, 'module')
745
862
  class_name = require_str(params, 'className')
746
863
  args = deserialize(coerce_list(params.get('args'), 'args'))
747
864
  kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'))
748
- mod = importlib.import_module(module_name)
749
- cls = getattr(mod, class_name)
865
+ mod = import_allowed_module(module_name, allowed_modules)
866
+ cls = get_allowed_attr(mod, class_name, allow_private_attrs=allow_private_attrs)
750
867
  obj = cls(*args, **kwargs)
751
868
  handle_id = str(id(obj))
752
869
  instances[handle_id] = obj
753
870
  return handle_id
754
871
 
755
872
 
756
- def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy):
873
+ def handle_call_method(params, instances, *, force_json_markers, torch_allow_copy, allow_private_attrs):
757
874
  handle_id = require_str(params, 'handle')
758
875
  method_name = require_str(params, 'methodName')
759
876
  args = deserialize(coerce_list(params.get('args'), 'args'))
@@ -761,7 +878,7 @@ def handle_call_method(params, instances, *, force_json_markers, torch_allow_cop
761
878
  if handle_id not in instances:
762
879
  raise InstanceHandleError(f'Unknown instance handle: {handle_id}')
763
880
  obj = instances[handle_id]
764
- func = getattr(obj, method_name)
881
+ func = get_allowed_attr(obj, method_name, allow_private_attrs=allow_private_attrs)
765
882
  res = func(*args, **kwargs)
766
883
  return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)
767
884
 
@@ -815,6 +932,8 @@ def dispatch_request(
815
932
  python_version=None,
816
933
  torch_allow_copy=False,
817
934
  arrow_available_override=None,
935
+ allowed_modules=None,
936
+ allow_private_attrs=False,
818
937
  ):
819
938
  """
820
939
  Validate and route a request, returning the fully-serialized response dict
@@ -824,6 +943,13 @@ def dispatch_request(
824
943
 
825
944
  allow_nan is accepted for signature symmetry; NaN rejection happens during
826
945
  the final encode_value() call, which the caller performs.
946
+
947
+ allowed_modules: None (default) disables the import allowlist so existing
948
+ behavior is preserved. Supplying a set restricts call/instantiate imports to
949
+ those modules (plus the stdlib the bridge itself needs) and raises
950
+ ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks
951
+ getattr of underscore-prefixed names; True restores unrestricted access. See
952
+ the IMPORT / ATTRIBUTE ALLOWLIST section above for the full trust model.
827
953
  """
828
954
  mid = require_protocol(msg)
829
955
  method = msg.get('method')
@@ -832,13 +958,23 @@ def dispatch_request(
832
958
  params = coerce_dict(msg.get('params'), 'params')
833
959
  if method == 'call':
834
960
  result = handle_call(
835
- params, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
961
+ params,
962
+ force_json_markers=force_json_markers,
963
+ torch_allow_copy=torch_allow_copy,
964
+ allowed_modules=allowed_modules,
965
+ allow_private_attrs=allow_private_attrs,
836
966
  )
837
967
  elif method == 'instantiate':
838
- result = handle_instantiate(params, instances)
968
+ result = handle_instantiate(
969
+ params, instances, allowed_modules=allowed_modules, allow_private_attrs=allow_private_attrs
970
+ )
839
971
  elif method == 'call_method':
840
972
  result = handle_call_method(
841
- params, instances, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy
973
+ params,
974
+ instances,
975
+ force_json_markers=force_json_markers,
976
+ torch_allow_copy=torch_allow_copy,
977
+ allow_private_attrs=allow_private_attrs,
842
978
  )
843
979
  elif method == 'dispose_instance':
844
980
  result = handle_dispose_instance(params, instances)
package/src/cli.ts CHANGED
@@ -46,23 +46,22 @@ function parseModules(modulesList?: string): string[] {
46
46
  .filter(Boolean);
47
47
  }
48
48
 
49
- function buildModulesConfig(
50
- modules: string[],
51
- runtime: RuntimeStrategy
52
- ): TywrapOptions['pythonModules'] {
53
- return Object.fromEntries(modules.map(name => [name, { runtime, typeHints: 'strict' }]));
49
+ function buildModulesConfig(modules: string[]): TywrapOptions['pythonModules'] {
50
+ // The per-module `runtime` field is dead input (never read during generation);
51
+ // the active runtime is resolved from the top-level `runtime` config. Seed only
52
+ // the fields that actually affect generation.
53
+ return Object.fromEntries(modules.map(name => [name, { typeHints: 'strict' }]));
54
54
  }
55
55
 
56
56
  function renderConfigTemplate(options: {
57
57
  format: 'ts' | 'json';
58
58
  modules: string[];
59
- runtime: RuntimeStrategy;
60
59
  outputDir: string;
61
60
  }): string {
62
61
  const modules = options.modules.length > 0 ? options.modules : ['math'];
63
62
  if (options.format === 'json') {
64
63
  const config = {
65
- pythonModules: buildModulesConfig(modules, options.runtime),
64
+ pythonModules: buildModulesConfig(modules),
66
65
  output: { dir: options.outputDir, format: 'esm', declaration: false, sourceMap: false },
67
66
  runtime: { node: { pythonPath: 'python3' } },
68
67
  types: { presets: ['stdlib'] },
@@ -71,9 +70,7 @@ function renderConfigTemplate(options: {
71
70
  }
72
71
 
73
72
  const moduleLines = modules
74
- .map(
75
- name => ` ${JSON.stringify(name)}: { runtime: '${options.runtime}', typeHints: 'strict' },`
76
- )
73
+ .map(name => ` ${JSON.stringify(name)}: { typeHints: 'strict' },`)
77
74
  .join('\n');
78
75
  return `import { defineConfig } from 'tywrap';
79
76
 
@@ -230,7 +227,7 @@ async function main(): Promise<void> {
230
227
 
231
228
  const overrides: NonNullable<ResolveConfigOptions['overrides']> = {};
232
229
  if (modules.length > 0) {
233
- overrides.pythonModules = buildModulesConfig(modules, argv.runtime);
230
+ overrides.pythonModules = buildModulesConfig(modules);
234
231
  }
235
232
 
236
233
  if (
@@ -456,7 +453,6 @@ async function main(): Promise<void> {
456
453
  const content = renderConfigTemplate({
457
454
  format: argv.format,
458
455
  modules,
459
- runtime: argv.runtime,
460
456
  outputDir: argv.outputDir,
461
457
  });
462
458