modelos-secure 0.0.0-stage → 0.1.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.
@@ -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