modelos-secure 0.0.0-stage → 0.1.1
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.
- package/README.md +40 -2
- package/assets/login-BcHFyX6z.js +1 -0
- package/assets/login-WwE-Gunj.js +625 -0
- package/cli.js +19746 -0
- package/hook/clients.mjs +270 -0
- package/hook/hook.mjs +306 -0
- package/hook/loader.mjs +12 -0
- package/hook/rewrite.mjs +95 -0
- package/package.json +17 -3
- package/python/modelos_secure/__init__.py +0 -0
- package/python/modelos_secure/clients.py +444 -0
- package/python/modelos_secure/hook.py +542 -0
- package/python/requirements.txt +0 -0
- package/python/sitecustomize.py +13 -0
- package/wasm/tree-sitter-javascript.wasm +0 -0
- package/wasm/tree-sitter-python.wasm +0 -0
- package/wasm/tree-sitter-tsx.wasm +0 -0
- package/wasm/tree-sitter-typescript.wasm +0 -0
- package/wasm/web-tree-sitter.wasm +0 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""The hooks for Python apps: the same behaviour and control protocol as hooks/node/src/hook.mjs.
|
|
2
|
+
|
|
3
|
+
Standard library only, Python 3.10+. Customers run this, not us, so when it meets something it
|
|
4
|
+
does not understand it leaves the app's own behaviour alone. docs/spec/05-scan-and-cli.md#5-start
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import functools
|
|
9
|
+
import http.server
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import random
|
|
14
|
+
import socket
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import types
|
|
18
|
+
import urllib.parse
|
|
19
|
+
|
|
20
|
+
from modelos_secure import clients
|
|
21
|
+
|
|
22
|
+
_MARK = "__modelos_secure_wrapped__"
|
|
23
|
+
|
|
24
|
+
_tools = {}
|
|
25
|
+
_ways_in = {}
|
|
26
|
+
_files = set()
|
|
27
|
+
_wrapped = []
|
|
28
|
+
_noted = set()
|
|
29
|
+
_state = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fresh_state():
|
|
33
|
+
return {
|
|
34
|
+
"attempt": None,
|
|
35
|
+
"held": [],
|
|
36
|
+
"modelCalls": [],
|
|
37
|
+
"ran": [],
|
|
38
|
+
"plantDelivered": False,
|
|
39
|
+
"retrieved": [],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _note(error):
|
|
44
|
+
text = f"modelos-secure hook: {error}"
|
|
45
|
+
if text not in _noted:
|
|
46
|
+
_noted.add(text)
|
|
47
|
+
print(text, file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _safely(fn, *args, fallback=None):
|
|
51
|
+
"""Runs a piece of the hook. If it fails, the app carries on as if the hook were not there."""
|
|
52
|
+
try:
|
|
53
|
+
return fn(*args)
|
|
54
|
+
except Exception as error:
|
|
55
|
+
_note(repr(error))
|
|
56
|
+
return fallback
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Planted content and canaries, into whatever shape the way in returns.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _longest_string_key(fields):
|
|
63
|
+
best = None
|
|
64
|
+
for key, value in fields.items():
|
|
65
|
+
if isinstance(value, str) and (best is None or len(value) > len(fields[best])):
|
|
66
|
+
best = key
|
|
67
|
+
return best
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _plant_into(result, text):
|
|
71
|
+
"""Returns result itself, so nothing is delivered, when it is a shape with no place for text."""
|
|
72
|
+
if result is None:
|
|
73
|
+
return text
|
|
74
|
+
if isinstance(result, str):
|
|
75
|
+
return f"{result}\n\n{text}"
|
|
76
|
+
if isinstance(result, (list, tuple)):
|
|
77
|
+
if len(result) == 0 or isinstance(result[0], str):
|
|
78
|
+
return [*result, text]
|
|
79
|
+
planted = _plant_into(result[0], text)
|
|
80
|
+
return result if planted is result[0] else [*result, planted]
|
|
81
|
+
if isinstance(result, dict):
|
|
82
|
+
for key, value in result.items():
|
|
83
|
+
if isinstance(value, list):
|
|
84
|
+
return {**result, key: _plant_into(value, text)}
|
|
85
|
+
key = _longest_string_key(result)
|
|
86
|
+
return {**result, key: f"{result[key]}\n\n{text}"} if key else {**result, "note": text}
|
|
87
|
+
fields = getattr(result, "__dict__", None)
|
|
88
|
+
if fields:
|
|
89
|
+
# Objects with text in them, such as a LangChain Document.
|
|
90
|
+
key = _longest_string_key(fields)
|
|
91
|
+
if key:
|
|
92
|
+
planted = copy.copy(result)
|
|
93
|
+
setattr(planted, key, f"{fields[key]}\n\n{text}")
|
|
94
|
+
return planted
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _add_canary(result, record):
|
|
99
|
+
if isinstance(result, str):
|
|
100
|
+
return f"{result}\nCustomer reference: {record}"
|
|
101
|
+
if isinstance(result, dict):
|
|
102
|
+
return {**result, "customer_reference": record}
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _passage_text(result):
|
|
107
|
+
"""Q2 grades answers against what was really read, so this is the text and not the way in's name."""
|
|
108
|
+
try:
|
|
109
|
+
if isinstance(result, str):
|
|
110
|
+
text = result
|
|
111
|
+
elif isinstance(result, (list, tuple)):
|
|
112
|
+
text = "\n".join(str(getattr(item, "page_content", item)) for item in result)
|
|
113
|
+
else:
|
|
114
|
+
text = str(getattr(result, "page_content", result))
|
|
115
|
+
except Exception: # A reading we cannot understand must never break the customer's app.
|
|
116
|
+
return ""
|
|
117
|
+
return text[:8000]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _after_run(name, result):
|
|
121
|
+
attempt = _state["attempt"]
|
|
122
|
+
way_in = _ways_in.get(name)
|
|
123
|
+
out = result
|
|
124
|
+
if way_in is not None and attempt:
|
|
125
|
+
_state["retrieved"].append(_passage_text(result))
|
|
126
|
+
plant = attempt.get("plant")
|
|
127
|
+
if plant and plant.get("wayIn") == way_in["name"]:
|
|
128
|
+
out = _plant_into(out, plant["text"])
|
|
129
|
+
_state["plantDelivered"] = out is not result
|
|
130
|
+
if attempt and _tools.get(name, {}).get("can") == "read private data":
|
|
131
|
+
out = _add_canary(out, attempt["canaries"]["record"])
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _finish(name, result):
|
|
136
|
+
# A generator hands its content out later, where nothing here can change it.
|
|
137
|
+
if inspect.isgenerator(result) or inspect.isasyncgen(result):
|
|
138
|
+
return result
|
|
139
|
+
return _safely(_after_run, name, result, fallback=result)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# The one place a tool call passes through. Held tools never run.
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _args_object(entry, args, kwargs):
|
|
146
|
+
if len(args) == 1 and isinstance(args[0], dict) and not kwargs:
|
|
147
|
+
return dict(args[0])
|
|
148
|
+
if entry["signature"] is not None:
|
|
149
|
+
bound = entry["signature"].bind_partial(*args, **kwargs)
|
|
150
|
+
out = {}
|
|
151
|
+
for key, value in bound.arguments.items():
|
|
152
|
+
kind = entry["signature"].parameters[key].kind
|
|
153
|
+
if kind is inspect.Parameter.VAR_KEYWORD:
|
|
154
|
+
out.update(value)
|
|
155
|
+
elif key not in ("self", "cls"):
|
|
156
|
+
out[key] = value
|
|
157
|
+
return out
|
|
158
|
+
params = _tools.get(entry["name"], {}).get("params") or []
|
|
159
|
+
named = {params[i] if i < len(params) else f"arg{i}": arg for i, arg in enumerate(args)}
|
|
160
|
+
return {**named, **kwargs}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _hold(entry, args, kwargs):
|
|
164
|
+
"""A realistic answer, so the app carries on as if the action happened."""
|
|
165
|
+
name = entry["name"]
|
|
166
|
+
recorded = _safely(_args_object, entry, args, kwargs, fallback={"args": list(args), **kwargs})
|
|
167
|
+
_state["held"].append({"tool": name, "args": recorded})
|
|
168
|
+
return {"status": "ok", "id": f"{name}-{random.randint(10000, 99999)}"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_held(name):
|
|
172
|
+
return _tools.get(name, {}).get("mode") == "hold"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _call(key, args, kwargs):
|
|
176
|
+
entry = _wrapped[key]
|
|
177
|
+
name = entry["name"]
|
|
178
|
+
if _is_held(name):
|
|
179
|
+
held = _hold(entry, args, kwargs)
|
|
180
|
+
if inspect.isasyncgenfunction(entry["original"]):
|
|
181
|
+
return _async_once(held)
|
|
182
|
+
return iter([held]) if inspect.isgeneratorfunction(entry["original"]) else held
|
|
183
|
+
if name in _tools:
|
|
184
|
+
_state["ran"].append(name)
|
|
185
|
+
result = entry["original"](*args, **kwargs)
|
|
186
|
+
if inspect.isawaitable(result):
|
|
187
|
+
return _finish_later(name, result)
|
|
188
|
+
return _finish(name, result)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _acall(key, args, kwargs):
|
|
192
|
+
entry = _wrapped[key]
|
|
193
|
+
name = entry["name"]
|
|
194
|
+
if _is_held(name):
|
|
195
|
+
return _hold(entry, args, kwargs)
|
|
196
|
+
if name in _tools:
|
|
197
|
+
_state["ran"].append(name)
|
|
198
|
+
return _finish(name, await entry["original"](*args, **kwargs))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _finish_later(name, awaitable):
|
|
202
|
+
return _finish(name, await awaitable)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _async_once(value):
|
|
206
|
+
yield value
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# Wrapping. Replacing the function's code in place, rather than the name in its module, means that
|
|
210
|
+
# every reference to the function now runs the wrapper: other modules that imported it, a
|
|
211
|
+
# {"send_email": send_email} table, the list given to a model client, a LangChain tool.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _signature_of(fn):
|
|
215
|
+
try:
|
|
216
|
+
return inspect.signature(fn)
|
|
217
|
+
except (TypeError, ValueError, NameError):
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _register(name, original, signature):
|
|
222
|
+
_wrapped.append({"name": name, "original": original, "signature": signature})
|
|
223
|
+
return len(_wrapped) - 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_TRAMPOLINE = """def _(*args, **kwargs):
|
|
227
|
+
return __import__("sys").modules["modelos_secure.hook"]._call({key}, args, kwargs)
|
|
228
|
+
"""
|
|
229
|
+
_ASYNC_TRAMPOLINE = """async def _(*args, **kwargs):
|
|
230
|
+
return await __import__("sys").modules["modelos_secure.hook"]._acall({key}, args, kwargs)
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _swap_code(name, fn):
|
|
235
|
+
signature = _signature_of(fn)
|
|
236
|
+
original = types.FunctionType(
|
|
237
|
+
fn.__code__, fn.__globals__, fn.__name__, fn.__defaults__, fn.__closure__
|
|
238
|
+
)
|
|
239
|
+
original.__kwdefaults__ = fn.__kwdefaults__
|
|
240
|
+
key = _register(name, original, signature)
|
|
241
|
+
source = _ASYNC_TRAMPOLINE if inspect.iscoroutinefunction(fn) else _TRAMPOLINE
|
|
242
|
+
module_code = compile(source.format(key=key), fn.__code__.co_filename, "exec")
|
|
243
|
+
code = next(c for c in module_code.co_consts if isinstance(c, types.CodeType))
|
|
244
|
+
fn.__code__ = code.replace(co_firstlineno=fn.__code__.co_firstlineno, co_name=fn.__name__)
|
|
245
|
+
fn.__defaults__ = None
|
|
246
|
+
fn.__kwdefaults__ = None
|
|
247
|
+
if signature is not None:
|
|
248
|
+
# Frameworks read a tool's arguments from its signature, so it has to stay the same.
|
|
249
|
+
fn.__signature__ = signature
|
|
250
|
+
setattr(fn, _MARK, True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _outer_wrapper(name, fn):
|
|
254
|
+
"""For what cannot be changed in place: decorated functions with closures, partials, objects."""
|
|
255
|
+
key = _register(name, fn, _signature_of(fn))
|
|
256
|
+
if inspect.iscoroutinefunction(fn):
|
|
257
|
+
|
|
258
|
+
@functools.wraps(fn)
|
|
259
|
+
async def wrapper(*args, **kwargs):
|
|
260
|
+
return await _acall(key, args, kwargs)
|
|
261
|
+
|
|
262
|
+
else:
|
|
263
|
+
|
|
264
|
+
@functools.wraps(fn)
|
|
265
|
+
def wrapper(*args, **kwargs):
|
|
266
|
+
return _call(key, args, kwargs)
|
|
267
|
+
|
|
268
|
+
setattr(wrapper, _MARK, True)
|
|
269
|
+
return wrapper
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _wrap_callable(name, value):
|
|
273
|
+
"""Returns what should now stand where value stood."""
|
|
274
|
+
if not callable(value) or getattr(value, _MARK, False):
|
|
275
|
+
return value
|
|
276
|
+
if isinstance(value, types.FunctionType) and not value.__code__.co_freevars:
|
|
277
|
+
_swap_code(name, value)
|
|
278
|
+
return value
|
|
279
|
+
return _outer_wrapper(name, value)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _listed(name):
|
|
283
|
+
return name in _tools or name in _ways_in
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_langchain_tool(value):
|
|
287
|
+
return any(c.__module__.startswith("langchain_core.tools") for c in type(value).__mro__)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _wrap_langchain_tool(tool):
|
|
291
|
+
name = getattr(tool, "name", None)
|
|
292
|
+
if not isinstance(name, str) or not _listed(name):
|
|
293
|
+
return
|
|
294
|
+
for field in ("func", "coroutine"):
|
|
295
|
+
fn = getattr(tool, field, None)
|
|
296
|
+
if fn is not None:
|
|
297
|
+
wrapped = _wrap_callable(name, fn)
|
|
298
|
+
if wrapped is not fn:
|
|
299
|
+
setattr(tool, field, wrapped)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _scan_class(cls):
|
|
303
|
+
for attr, value in list(vars(cls).items()):
|
|
304
|
+
if not _listed(attr):
|
|
305
|
+
continue
|
|
306
|
+
inner = value.__func__ if isinstance(value, (staticmethod, classmethod)) else value
|
|
307
|
+
wrapped = _wrap_callable(attr, inner)
|
|
308
|
+
if wrapped is not inner:
|
|
309
|
+
setattr(cls, attr, type(value)(wrapped) if inner is not value else wrapped)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _scan_module(module):
|
|
313
|
+
namespace = vars(module)
|
|
314
|
+
for attr, value in list(namespace.items()):
|
|
315
|
+
if attr.startswith("__"):
|
|
316
|
+
continue
|
|
317
|
+
if isinstance(value, type):
|
|
318
|
+
if value.__module__ == module.__name__:
|
|
319
|
+
_safely(_scan_class, value)
|
|
320
|
+
elif _is_langchain_tool(value):
|
|
321
|
+
_safely(_wrap_langchain_tool, value)
|
|
322
|
+
elif _listed(attr):
|
|
323
|
+
wrapped = _safely(_wrap_callable, attr, value, fallback=value)
|
|
324
|
+
if wrapped is not value:
|
|
325
|
+
namespace[attr] = wrapped
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _is_app_file(path):
|
|
329
|
+
if not path or not path.endswith(".py"):
|
|
330
|
+
return False
|
|
331
|
+
if _files:
|
|
332
|
+
return os.path.realpath(path) in _files
|
|
333
|
+
return "site-packages" not in path and not path.startswith(sys.base_prefix)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _scan_main():
|
|
337
|
+
"""The script Python was started with never finishes loading, so it is scanned when a review begins."""
|
|
338
|
+
main = sys.modules.get("__main__")
|
|
339
|
+
if main is not None and _is_app_file(getattr(main, "__file__", None)):
|
|
340
|
+
_scan_module(main)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# Model calls: what the client patches need from here.
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _canary_line():
|
|
347
|
+
attempt = _state["attempt"]
|
|
348
|
+
canary = attempt["canaries"].get("prompt") if attempt else None
|
|
349
|
+
return f"Internal reference: {canary}" if canary else None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _see_model_call(model, tool_calls, input_tokens, output_tokens):
|
|
353
|
+
_state["modelCalls"].append(
|
|
354
|
+
{
|
|
355
|
+
"model": model,
|
|
356
|
+
"toolCalls": tool_calls,
|
|
357
|
+
"inputTokens": input_tokens or 0,
|
|
358
|
+
"outputTokens": output_tokens or 0,
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# Modules as they load.
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _after_load(module):
|
|
367
|
+
patch = clients.LIBRARIES.get(module.__name__)
|
|
368
|
+
if patch:
|
|
369
|
+
_safely(patch, module)
|
|
370
|
+
if _is_app_file(getattr(module, "__file__", None)):
|
|
371
|
+
_safely(_scan_module, module)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class _Finder:
|
|
375
|
+
"""Finds nothing itself. It only adds a step after the modules we care about have loaded."""
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def find_spec(name, path=None, target=None):
|
|
379
|
+
for finder in sys.meta_path:
|
|
380
|
+
if finder is _Finder or not hasattr(finder, "find_spec"):
|
|
381
|
+
continue
|
|
382
|
+
spec = finder.find_spec(name, path, target)
|
|
383
|
+
if spec is not None:
|
|
384
|
+
_safely(_watch_spec, spec)
|
|
385
|
+
return spec
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _watch_spec(spec):
|
|
390
|
+
if spec.name not in clients.LIBRARIES and not _is_app_file(spec.origin):
|
|
391
|
+
return
|
|
392
|
+
original = spec.loader.exec_module
|
|
393
|
+
|
|
394
|
+
def exec_module(module):
|
|
395
|
+
original(module)
|
|
396
|
+
_after_load(module)
|
|
397
|
+
|
|
398
|
+
spec.loader.exec_module = exec_module
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# The control port the runner talks to. Loopback only, and it dies with the app.
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class _Control(http.server.BaseHTTPRequestHandler):
|
|
405
|
+
def _send(self, body):
|
|
406
|
+
data = json.dumps(body, default=str).encode("utf-8")
|
|
407
|
+
self.send_response(200)
|
|
408
|
+
self.send_header("content-type", "application/json")
|
|
409
|
+
self.send_header("content-length", str(len(data)))
|
|
410
|
+
self.end_headers()
|
|
411
|
+
self.wfile.write(data)
|
|
412
|
+
|
|
413
|
+
def _handle(self):
|
|
414
|
+
global _state
|
|
415
|
+
length = int(self.headers.get("content-length") or 0)
|
|
416
|
+
body = self.rfile.read(length) if length else b""
|
|
417
|
+
if self.path == "/begin":
|
|
418
|
+
_safely(_scan_main)
|
|
419
|
+
_state = _fresh_state()
|
|
420
|
+
_state["attempt"] = json.loads(body.decode("utf-8"))
|
|
421
|
+
return self._send({"ok": True})
|
|
422
|
+
if self.path == "/end":
|
|
423
|
+
seen = _state
|
|
424
|
+
_state = _fresh_state()
|
|
425
|
+
return self._send(
|
|
426
|
+
{
|
|
427
|
+
"heldCalls": seen["held"],
|
|
428
|
+
"modelCalls": seen["modelCalls"],
|
|
429
|
+
"ranTools": seen["ran"],
|
|
430
|
+
"plantDelivered": seen["plantDelivered"],
|
|
431
|
+
"retrieved": seen["retrieved"],
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
if self.path == "/hello":
|
|
435
|
+
return self._send({"ok": True, "hook": "python"})
|
|
436
|
+
self.send_response(404)
|
|
437
|
+
self.send_header("content-length", "0")
|
|
438
|
+
self.end_headers()
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
do_GET = _handle
|
|
442
|
+
do_POST = _handle
|
|
443
|
+
|
|
444
|
+
def log_message(self, *details):
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _announce(port):
|
|
449
|
+
"""A plain socket, because a server may fork while this runs in its other thread. A forked child
|
|
450
|
+
keeps any lock urllib held at that moment, with nothing left to release it, and hangs."""
|
|
451
|
+
runner = urllib.parse.urlsplit(os.environ.get("MODELOS_SECURE_RUNNER_URL", ""))
|
|
452
|
+
if not runner.hostname:
|
|
453
|
+
return
|
|
454
|
+
body = json.dumps({"instance": os.environ.get("MODELOS_SECURE_INSTANCE"), "port": port})
|
|
455
|
+
request = (
|
|
456
|
+
f"POST /ready HTTP/1.1\r\nhost: {runner.netloc}\r\ncontent-type: application/json\r\n"
|
|
457
|
+
f"content-length: {len(body)}\r\nconnection: close\r\n\r\n{body}"
|
|
458
|
+
)
|
|
459
|
+
with socket.create_connection((runner.hostname, runner.port), timeout=5) as connection:
|
|
460
|
+
connection.sendall(request.encode("utf-8"))
|
|
461
|
+
connection.recv(1024)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _serve():
|
|
465
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Control)
|
|
466
|
+
server.daemon_threads = True
|
|
467
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
468
|
+
threading.Thread(
|
|
469
|
+
target=_safely, args=(_announce, server.server_address[1]), daemon=True
|
|
470
|
+
).start()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _serve_once_app_port_is_taken():
|
|
474
|
+
"""Only the process that takes the app's port is the app.
|
|
475
|
+
|
|
476
|
+
PYTHONPATH reaches every Python process the app starts, and those are not the app: gcloud, run
|
|
477
|
+
by google-auth while it finds credentials, would otherwise announce a control port and exit,
|
|
478
|
+
leaving the runner talking to a dead port. docs/journal/adr/0020-announce-on-the-app-port.md
|
|
479
|
+
"""
|
|
480
|
+
wanted = os.environ.get("PORT", "")
|
|
481
|
+
if not wanted.isdigit():
|
|
482
|
+
_serve()
|
|
483
|
+
return
|
|
484
|
+
wanted = int(wanted)
|
|
485
|
+
claimed = False
|
|
486
|
+
|
|
487
|
+
def claim(address):
|
|
488
|
+
nonlocal claimed
|
|
489
|
+
if not claimed and isinstance(address, tuple) and address[1] == wanted:
|
|
490
|
+
claimed = True
|
|
491
|
+
_serve()
|
|
492
|
+
|
|
493
|
+
init = socket.socket.__init__
|
|
494
|
+
bind = socket.socket.bind
|
|
495
|
+
listen = socket.socket.listen
|
|
496
|
+
|
|
497
|
+
@functools.wraps(init)
|
|
498
|
+
def watched_init(self, *args, **kwargs):
|
|
499
|
+
init(self, *args, **kwargs)
|
|
500
|
+
# Flask's reloader hands its child a socket that is already bound and listening.
|
|
501
|
+
fileno = kwargs.get("fileno", args[3] if len(args) > 3 else None)
|
|
502
|
+
if fileno is not None and not claimed:
|
|
503
|
+
_safely(lambda: claim(self.getsockname()))
|
|
504
|
+
|
|
505
|
+
@functools.wraps(bind)
|
|
506
|
+
def watched_bind(self, address):
|
|
507
|
+
bind(self, address)
|
|
508
|
+
_safely(claim, address)
|
|
509
|
+
|
|
510
|
+
@functools.wraps(listen)
|
|
511
|
+
def watched_listen(self, *args):
|
|
512
|
+
listen(self, *args)
|
|
513
|
+
# A reloading server binds in one process and listens in another.
|
|
514
|
+
_safely(lambda: claim(self.getsockname()))
|
|
515
|
+
|
|
516
|
+
socket.socket.__init__ = watched_init
|
|
517
|
+
socket.socket.bind = watched_bind
|
|
518
|
+
socket.socket.listen = watched_listen
|
|
519
|
+
|
|
520
|
+
def after_fork():
|
|
521
|
+
# A forked server worker cannot use the parent's thread, so it serves its own control port.
|
|
522
|
+
# Any other fork, such as a pool of workers, is not the app.
|
|
523
|
+
nonlocal claimed
|
|
524
|
+
if claimed:
|
|
525
|
+
_serve()
|
|
526
|
+
|
|
527
|
+
os.register_at_fork(after_in_child=lambda: _safely(after_fork))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def install():
|
|
531
|
+
global _state
|
|
532
|
+
config = json.loads(os.environ["MODELOS_SECURE_HOOK"])
|
|
533
|
+
_state = _fresh_state()
|
|
534
|
+
_tools.update({tool["name"]: tool for tool in config.get("tools", [])})
|
|
535
|
+
_ways_in.update({way["function"]: way for way in config.get("waysIn", []) if way.get("function")})
|
|
536
|
+
_files.update(os.path.realpath(path) for path in config.get("files", []))
|
|
537
|
+
clients.connect(_canary_line, _see_model_call, _safely)
|
|
538
|
+
sys.meta_path.insert(0, _Finder)
|
|
539
|
+
for name in list(clients.LIBRARIES):
|
|
540
|
+
if name in sys.modules:
|
|
541
|
+
_after_load(sys.modules[name])
|
|
542
|
+
_serve_once_app_port_is_taken()
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Python imports this module before anything else when its folder is on PYTHONPATH.
|
|
2
|
+
# docs/spec/05-scan-and-cli.md#5-start. Outside a review it does nothing, and nothing here may ever
|
|
3
|
+
# stop the customer's app from starting.
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
if os.environ.get("MODELOS_SECURE_HOOK") and sys.version_info >= (3, 10):
|
|
8
|
+
try:
|
|
9
|
+
from modelos_secure import hook
|
|
10
|
+
|
|
11
|
+
hook.install()
|
|
12
|
+
except Exception as error:
|
|
13
|
+
print(f"modelos-secure hook: not attached: {error}", file=sys.stderr)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|