modelos-secure 0.1.2 → 0.3.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,365 @@
1
+ """The edge for Python apps: the same as hooks/node/src/edge.mjs.
2
+
3
+ Every framework ends at an HTTP call to a model, so during an attempt the hook watches there too: it
4
+ sees each model call made through httpx (the tools offered and the calls the model asks for), adds
5
+ the prompt canary, and plants attacker text into a tool result. Every other call that would leave the
6
+ machine is stopped and recorded: through httpx it is answered as if it worked; through http.client
7
+ (urllib, requests, urllib3, botocore) and aiohttp it is refused. A tool known to run keeps its calls.
8
+ Outside an attempt it does nothing. Standard library only. ADR 0062.
9
+ """
10
+
11
+ import contextvars
12
+ import json
13
+ import random
14
+ import re
15
+
16
+ #: Which tool is running, so its own calls can be told apart from calls nobody declared.
17
+ current_tool = contextvars.ContextVar("modelos_secure_tool", default=None)
18
+ #: Set while a model client the hook already patches is calling, so the call is counted once.
19
+ in_sdk = contextvars.ContextVar("modelos_secure_sdk", default=False)
20
+
21
+ # The same lists as hooks/node/src/hosts.mjs.
22
+ _MODEL_HOSTS = [
23
+ r"(^|\.)api\.openai\.com$",
24
+ r"\.openai\.azure\.com$",
25
+ r"(^|\.)api\.anthropic\.com$",
26
+ r"(^|\.)generativelanguage\.googleapis\.com$",
27
+ r"aiplatform\.googleapis\.com$",
28
+ r"^(oauth2|sts|iamcredentials)\.googleapis\.com$",
29
+ r"^bedrock-runtime\.[\w-]+\.amazonaws\.com$",
30
+ r"^(api\.mistral\.ai|api\.groq\.com|api\.cohere\.com|openrouter\.ai|api\.together\.xyz|api\.deepseek\.com|api\.x\.ai|api\.fireworks\.ai|router\.huggingface\.co|api-inference\.huggingface\.co)$",
31
+ r"^login\.microsoftonline\.com$",
32
+ r"^metadata\.google\.internal$",
33
+ ]
34
+ _LOOPBACK = r"^(localhost|127\.\d+\.\d+\.\d+|::1|\[::1\]|0\.0\.0\.0|169\.254\.169\.254)$"
35
+ _MODEL_PATHS = [
36
+ (r"/chat/completions$", "chat"),
37
+ (r"/responses$", "responses"),
38
+ (r"/messages$", "anthropic"),
39
+ (r":(stream)?[gG]enerateContent$", "gemini"),
40
+ (r"/api/chat$", "ollama"),
41
+ ]
42
+
43
+
44
+ def allowed_host(host, allow=()):
45
+ name = str(host or "").lower()
46
+ return (
47
+ re.match(_LOOPBACK, name) is not None
48
+ or any(re.search(p, name) for p in _MODEL_HOSTS)
49
+ or any(name == a or name.endswith("." + a) for a in allow)
50
+ )
51
+
52
+
53
+ def model_shape(path, body):
54
+ if not isinstance(body, dict):
55
+ return None
56
+ for pattern, shape in _MODEL_PATHS:
57
+ if re.search(pattern, path):
58
+ return shape
59
+ if isinstance(body.get("messages"), list) and isinstance(body.get("model"), str):
60
+ return "chat"
61
+ if isinstance(body.get("contents"), list):
62
+ return "gemini"
63
+ return None
64
+
65
+
66
+ def _with_line(content, line):
67
+ if isinstance(content, str):
68
+ return content + "\n" + line
69
+ if isinstance(content, list):
70
+ return content + [{"type": "text", "text": line}]
71
+ return content
72
+
73
+
74
+ def add_canary(shape, body, line):
75
+ if not line or line in json.dumps(body, default=str):
76
+ return body
77
+ body = dict(body)
78
+ if shape in ("chat", "ollama"):
79
+ messages = list(body.get("messages") or [])
80
+ for i, message in enumerate(messages):
81
+ if isinstance(message, dict) and message.get("role") in ("system", "developer"):
82
+ messages[i] = {**message, "content": _with_line(message.get("content"), line)}
83
+ break
84
+ body["messages"] = messages
85
+ elif shape == "responses" and isinstance(body.get("instructions"), str):
86
+ body["instructions"] = body["instructions"] + "\n" + line
87
+ elif shape == "anthropic" and body.get("system"):
88
+ system = body["system"]
89
+ body["system"] = system + "\n" + line if isinstance(system, str) else list(system) + [{"type": "text", "text": line}]
90
+ elif shape == "gemini":
91
+ instruction = body.get("systemInstruction") or body.get("system_instruction")
92
+ if isinstance(instruction, dict) and isinstance(instruction.get("parts"), list):
93
+ body["systemInstruction"] = {**instruction, "parts": instruction["parts"] + [{"text": line}]}
94
+ return body
95
+
96
+
97
+ def plant_into_results(shape, body, tool, text):
98
+ """Puts attacker text into the result of one tool, where the model reads it. Returns the body and
99
+ whether it went in."""
100
+ delivered = [False]
101
+
102
+ def plant(value):
103
+ delivered[0] = True
104
+ if isinstance(value, str):
105
+ return value + "\n\n" + text
106
+ if isinstance(value, list):
107
+ return value + [{"type": "text", "text": text}]
108
+ return {**(value or {}), "note": text}
109
+
110
+ body = dict(body)
111
+ if shape in ("chat", "ollama"):
112
+ named = {}
113
+ for message in body.get("messages") or []:
114
+ for call in (message or {}).get("tool_calls") or []:
115
+ named[call.get("id")] = (call.get("function") or {}).get("name")
116
+ body["messages"] = [
117
+ {**m, "content": plant(m.get("content"))}
118
+ if isinstance(m, dict) and m.get("role") == "tool" and (named.get(m.get("tool_call_id")) == tool or m.get("name") == tool)
119
+ else m
120
+ for m in body.get("messages") or []
121
+ ]
122
+ elif shape == "anthropic":
123
+ named = {}
124
+ for message in body.get("messages") or []:
125
+ for part in message.get("content") if isinstance(message.get("content"), list) else []:
126
+ if part.get("type") == "tool_use":
127
+ named[part.get("id")] = part.get("name")
128
+ messages = []
129
+ for message in body.get("messages") or []:
130
+ content = message.get("content")
131
+ if isinstance(content, list):
132
+ content = [
133
+ {**p, "content": plant(p.get("content"))}
134
+ if p.get("type") == "tool_result" and named.get(p.get("tool_use_id")) == tool
135
+ else p
136
+ for p in content
137
+ ]
138
+ messages.append({**message, "content": content})
139
+ body["messages"] = messages
140
+ elif shape == "gemini":
141
+ contents = []
142
+ for content in body.get("contents") or []:
143
+ parts = []
144
+ for part in content.get("parts") or []:
145
+ response = part.get("functionResponse") or {}
146
+ if response.get("name") == tool:
147
+ part = {**part, "functionResponse": {**response, "response": plant(response.get("response") or {})}}
148
+ parts.append(part)
149
+ contents.append({**content, "parts": parts})
150
+ body["contents"] = contents
151
+ return body, delivered[0]
152
+
153
+
154
+ def _args(value):
155
+ if isinstance(value, dict):
156
+ return value
157
+ try:
158
+ parsed = json.loads(value)
159
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
160
+ except Exception:
161
+ return {"raw": str(value or "")}
162
+
163
+
164
+ def read_model(body, answer):
165
+ calls = []
166
+ for choice in answer.get("choices") or []:
167
+ for call in (choice.get("message") or {}).get("tool_calls") or []:
168
+ function = call.get("function") or {}
169
+ calls.append({"tool": function.get("name"), "args": _args(function.get("arguments"))})
170
+ for call in (answer.get("message") or {}).get("tool_calls") or []:
171
+ function = call.get("function") or {}
172
+ calls.append({"tool": function.get("name"), "args": _args(function.get("arguments"))})
173
+ for item in answer.get("output") or []:
174
+ if item.get("type") == "function_call":
175
+ calls.append({"tool": item.get("name"), "args": _args(item.get("arguments"))})
176
+ for part in answer.get("content") if isinstance(answer.get("content"), list) else []:
177
+ if part.get("type") == "tool_use":
178
+ calls.append({"tool": part.get("name"), "args": part.get("input") or {}})
179
+ for candidate in answer.get("candidates") or []:
180
+ for part in (candidate.get("content") or {}).get("parts") or []:
181
+ call = part.get("functionCall")
182
+ if call:
183
+ calls.append({"tool": call.get("name"), "args": call.get("args") or {}})
184
+ usage = answer.get("usage") or answer.get("usageMetadata") or {}
185
+ return {
186
+ "model": body.get("model") or answer.get("model"),
187
+ "toolCalls": [c for c in calls if c["tool"]],
188
+ "inputTokens": usage.get("prompt_tokens") or usage.get("input_tokens") or usage.get("promptTokenCount") or 0,
189
+ "outputTokens": usage.get("completion_tokens") or usage.get("output_tokens") or usage.get("candidatesTokenCount") or 0,
190
+ }
191
+
192
+
193
+ def read_stream(body, text):
194
+ """A streamed answer, read event by event into the shape of a whole one."""
195
+ pieces = {}
196
+ whole = {"choices": [], "content": [], "candidates": [], "output": []}
197
+ for line in text.splitlines():
198
+ if not line.startswith("data:"):
199
+ continue
200
+ try:
201
+ event = json.loads(line[5:].strip())
202
+ except Exception:
203
+ continue
204
+ for choice in event.get("choices") or []:
205
+ for delta in (choice.get("delta") or {}).get("tool_calls") or []:
206
+ piece = pieces.setdefault(("c", delta.get("index")), {"name": "", "args": ""})
207
+ piece["name"] += (delta.get("function") or {}).get("name") or ""
208
+ piece["args"] += (delta.get("function") or {}).get("arguments") or ""
209
+ block = event.get("content_block") or {}
210
+ if event.get("type") == "content_block_start" and block.get("type") == "tool_use":
211
+ pieces[("a", event.get("index"))] = {"name": block.get("name"), "args": ""}
212
+ delta = event.get("delta") or {}
213
+ if delta.get("type") == "input_json_delta" and ("a", event.get("index")) in pieces:
214
+ pieces[("a", event.get("index"))]["args"] += delta.get("partial_json") or ""
215
+ whole["candidates"] += event.get("candidates") or []
216
+ if event.get("type") == "response.completed":
217
+ whole["output"] = (event.get("response") or {}).get("output") or []
218
+ if event.get("usage") or event.get("usageMetadata"):
219
+ whole["usage"] = event.get("usage") or event.get("usageMetadata")
220
+ for piece in pieces.values():
221
+ whole["choices"].append({"message": {"tool_calls": [{"function": {"name": piece["name"], "arguments": piece["args"]}}]}})
222
+ return read_model(body, whole)
223
+
224
+
225
+ def install(hook):
226
+ """`hook` gives: state(), canary_line(), mode(name), way_in(name), allow, safely(fn, *args)."""
227
+ allow = tuple(hook["allow"])
228
+
229
+ def attempt():
230
+ state = hook["state"]()
231
+ return state if state and state.get("attempt") else None
232
+
233
+ def passes(host):
234
+ return allowed_host(host, allow) or hook["mode"](current_tool.get()) == "run"
235
+
236
+ def hold(method, host, path, size):
237
+ state = attempt()
238
+ if state is not None:
239
+ state.setdefault("edge", []).append({"method": method, "host": host, "path": path, "bytes": size})
240
+
241
+ def prepare(shape, body):
242
+ state = attempt()
243
+ prepared = add_canary(shape, body, hook["canary_line"]())
244
+ plant = (state.get("attempt") or {}).get("plant") if state else None
245
+ way = hook["way_in"](plant.get("wayIn")) if plant else None
246
+ if plant and way and way.get("function") and not state.get("plantDelivered"):
247
+ prepared, delivered = plant_into_results(shape, prepared, way["function"], plant.get("text", ""))
248
+ if delivered:
249
+ state["plantDelivered"] = True
250
+ return prepared
251
+
252
+ def see(body, answer_text, streamed, counted):
253
+ state = attempt()
254
+ if state is None:
255
+ return
256
+ try:
257
+ read = read_stream(body, answer_text) if streamed else read_model(body, json.loads(answer_text))
258
+ except Exception:
259
+ return
260
+ if not counted:
261
+ state["modelCalls"].append({**read, "edge": True})
262
+ for call in read["toolCalls"]:
263
+ if hook["mode"](call["tool"]) == "hold" and not any(h["tool"] == call["tool"] for h in state["held"]):
264
+ state["held"].append({"tool": call["tool"], "args": call["args"], "at": "edge"})
265
+
266
+ def failed(host, why):
267
+ state = attempt()
268
+ if state is not None:
269
+ state.setdefault("modelFailures", []).append({"host": host, "why": why})
270
+
271
+ def held_response(httpx, request):
272
+ body = json.dumps({"status": "ok", "id": f"held-{random.randint(10000, 99999)}"}).encode()
273
+ return httpx.Response(200, headers={"content-type": "application/json"}, content=body, request=request)
274
+
275
+ def patch_httpx(httpx):
276
+ def rebuilt(request, body):
277
+ headers = {k: v for k, v in request.headers.items() if k.lower() != "content-length"}
278
+ return httpx.Request(request.method, request.url, headers=headers, content=json.dumps(body).encode())
279
+
280
+ def body_of(request):
281
+ try:
282
+ return json.loads(request.read() or b"null")
283
+ except Exception:
284
+ return None
285
+
286
+ def wanted(request):
287
+ body = body_of(request)
288
+ return body, model_shape(request.url.path, body)
289
+
290
+ sync = httpx.HTTPTransport.handle_request
291
+
292
+ def handle_request(self, request):
293
+ if attempt() is None:
294
+ return sync(self, request)
295
+ body, shape = hook["safely"](wanted, request) or (None, None)
296
+ if shape:
297
+ counted = in_sdk.get()
298
+ try:
299
+ response = sync(self, hook["safely"](rebuilt, request, prepare(shape, body)) or request)
300
+ except Exception:
301
+ failed(request.url.host, "no answer")
302
+ raise
303
+ if response.status_code >= 400:
304
+ failed(request.url.host, f"status {response.status_code}")
305
+ response.read()
306
+ hook["safely"](see, body, response.text, "event-stream" in response.headers.get("content-type", ""), counted)
307
+ return response
308
+ if passes(request.url.host):
309
+ return sync(self, request)
310
+ hold(request.method, request.url.host, request.url.path, len(request.content or b""))
311
+ return held_response(httpx, request)
312
+
313
+ httpx.HTTPTransport.handle_request = handle_request
314
+
315
+ asynchronous = httpx.AsyncHTTPTransport.handle_async_request
316
+
317
+ async def handle_async_request(self, request):
318
+ if attempt() is None:
319
+ return await asynchronous(self, request)
320
+ body, shape = hook["safely"](wanted, request) or (None, None)
321
+ if shape:
322
+ counted = in_sdk.get()
323
+ try:
324
+ response = await asynchronous(self, hook["safely"](rebuilt, request, prepare(shape, body)) or request)
325
+ except Exception:
326
+ failed(request.url.host, "no answer")
327
+ raise
328
+ if response.status_code >= 400:
329
+ failed(request.url.host, f"status {response.status_code}")
330
+ await response.aread()
331
+ hook["safely"](see, body, response.text, "event-stream" in response.headers.get("content-type", ""), counted)
332
+ return response
333
+ if passes(request.url.host):
334
+ return await asynchronous(self, request)
335
+ hold(request.method, request.url.host, request.url.path, len(request.content or b""))
336
+ return held_response(httpx, request)
337
+
338
+ httpx.AsyncHTTPTransport.handle_async_request = handle_async_request
339
+
340
+ def patch_http_client(http_client):
341
+ # putrequest, because urllib3 2 replaces request but still calls this one.
342
+ original = http_client.HTTPConnection.putrequest
343
+
344
+ def putrequest(self, method, url, *args, **kwargs):
345
+ if attempt() is not None and not passes(self.host):
346
+ hold(method, self.host, str(url).split("?")[0], 0)
347
+ raise ConnectionRefusedError("Stopped by ModelOS Secure during a review.")
348
+ return original(self, method, url, *args, **kwargs)
349
+
350
+ http_client.HTTPConnection.putrequest = putrequest
351
+
352
+ def patch_aiohttp(aiohttp):
353
+ original = aiohttp.ClientSession._request
354
+
355
+ async def _request(self, method, str_or_url, *args, **kwargs):
356
+ host = getattr(str_or_url, "host", None) or re.sub(r"^\w+://([^/:]+).*$", r"\1", str(str_or_url))
357
+ if attempt() is not None and not passes(host):
358
+ hold(method, host, "", 0)
359
+ raise ConnectionRefusedError("Stopped by ModelOS Secure during a review.")
360
+ return await original(self, method, str_or_url, *args, **kwargs)
361
+
362
+ aiohttp.ClientSession._request = _request
363
+
364
+ return {"httpx": patch_httpx, "http.client": patch_http_client, "aiohttp": patch_aiohttp}
365
+
@@ -17,7 +17,7 @@ import threading
17
17
  import types
18
18
  import urllib.parse
19
19
 
20
- from modelos_secure import clients
20
+ from modelos_secure import clients, edge
21
21
 
22
22
  _MARK = "__modelos_secure_wrapped__"
23
23
 
@@ -37,6 +37,9 @@ def _fresh_state():
37
37
  "ran": [],
38
38
  "plantDelivered": False,
39
39
  "retrieved": [],
40
+ "edge": [],
41
+ # The app's own model calls that failed: an attempt with one is an error, never a hold.
42
+ "modelFailures": [],
40
43
  }
41
44
 
42
45
 
@@ -182,7 +185,11 @@ def _call(key, args, kwargs):
182
185
  return iter([held]) if inspect.isgeneratorfunction(entry["original"]) else held
183
186
  if name in _tools:
184
187
  _state["ran"].append(name)
185
- result = entry["original"](*args, **kwargs)
188
+ running = edge.current_tool.set(name)
189
+ try:
190
+ result = entry["original"](*args, **kwargs)
191
+ finally:
192
+ edge.current_tool.reset(running)
186
193
  if inspect.isawaitable(result):
187
194
  return _finish_later(name, result)
188
195
  return _finish(name, result)
@@ -195,7 +202,11 @@ async def _acall(key, args, kwargs):
195
202
  return _hold(entry, args, kwargs)
196
203
  if name in _tools:
197
204
  _state["ran"].append(name)
198
- return _finish(name, await entry["original"](*args, **kwargs))
205
+ running = edge.current_tool.set(name)
206
+ try:
207
+ return _finish(name, await entry["original"](*args, **kwargs))
208
+ finally:
209
+ edge.current_tool.reset(running)
199
210
 
200
211
 
201
212
  async def _finish_later(name, awaitable):
@@ -422,13 +433,18 @@ class _Control(http.server.BaseHTTPRequestHandler):
422
433
  if self.path == "/end":
423
434
  seen = _state
424
435
  _state = _fresh_state()
436
+ # The edge sees a tool call before its function runs. Where a hook then held the
437
+ # function itself, that record is the one kept, so a call counts once.
438
+ by_hook = {h["tool"] for h in seen["held"] if h.get("at") != "edge"}
425
439
  return self._send(
426
440
  {
427
- "heldCalls": seen["held"],
441
+ "heldCalls": [h for h in seen["held"] if h.get("at") != "edge" or h["tool"] not in by_hook],
428
442
  "modelCalls": seen["modelCalls"],
429
443
  "ranTools": seen["ran"],
430
444
  "plantDelivered": seen["plantDelivered"],
431
445
  "retrieved": seen["retrieved"],
446
+ "edgeHeld": seen.get("edge", []),
447
+ "modelFailures": seen.get("modelFailures", []),
432
448
  }
433
449
  )
434
450
  if self.path == "/hello":
@@ -535,6 +551,22 @@ def install():
535
551
  _ways_in.update({way["function"]: way for way in config.get("waysIn", []) if way.get("function")})
536
552
  _files.update(os.path.realpath(path) for path in config.get("files", []))
537
553
  clients.connect(_canary_line, _see_model_call, _safely)
554
+ # The edge, beneath every framework. http.client is always there; the others patch on import.
555
+ ways = {way["name"]: way for way in config.get("waysIn", [])}
556
+ patches = edge.install(
557
+ {
558
+ "state": lambda: _state,
559
+ "canary_line": _canary_line,
560
+ "mode": lambda name: _tools.get(name, {}).get("mode") if name else None,
561
+ "way_in": ways.get,
562
+ "allow": (config.get("edge") or {}).get("allow", []),
563
+ "safely": _safely,
564
+ }
565
+ )
566
+ import http.client
567
+
568
+ _safely(patches["http.client"], http.client)
569
+ clients.LIBRARIES.update({"httpx": patches["httpx"], "aiohttp": patches["aiohttp"]})
538
570
  sys.meta_path.insert(0, _Finder)
539
571
  for name in list(clients.LIBRARIES):
540
572
  if name in sys.modules:
package/package.json CHANGED
@@ -1,20 +1,36 @@
1
1
  {
2
2
  "name": "modelos-secure",
3
- "version": "0.1.2",
4
- "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
5
- "license": "UNLICENSED",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/mn-chetan/modelos-secure.git"
9
- },
3
+ "version": "0.3.0",
4
+ "private": false,
10
5
  "type": "module",
11
6
  "bin": {
12
- "modelos-secure": "cli.js"
7
+ "modelos-secure": "dist/cli.js"
13
8
  },
14
9
  "exports": {
15
- "./hook": "./hook/hook.mjs"
10
+ "./hook": "./dist/hook/hook.mjs"
16
11
  },
17
12
  "engines": {
18
- "node": ">=20"
13
+ "node": ">=22"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "vite build"
20
+ },
21
+ "dependencies": {
22
+ "@modelos-secure/core": "workspace:*",
23
+ "@modelos-secure/scan": "workspace:*",
24
+ "@modelos-secure/schema": "workspace:*",
25
+ "yaml": "2.9.1"
26
+ },
27
+ "devDependencies": {
28
+ "vite": "8.3.6"
29
+ },
30
+ "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
31
+ "license": "UNLICENSED",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/mn-chetan/modelos-secure.git"
19
35
  }
20
- }
36
+ }
@@ -1 +0,0 @@
1
- export { t as loginCommand } from "./login-JPkrnvZC.js";
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes