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.
- package/README.md +31 -12
- package/dist/README.md +60 -0
- package/dist/assets/login-CvPyhFJ7.js +1 -0
- package/{assets/login-JPkrnvZC.js → dist/assets/login-DeAJGhiN.js} +45 -7
- package/{cli.js → dist/cli.js} +3348 -433
- package/{hook → dist/hook}/clients.mjs +11 -3
- package/dist/hook/edge.mjs +411 -0
- package/{hook → dist/hook}/hook.mjs +39 -7
- package/dist/hook/hosts.d.mts +2 -0
- package/dist/hook/hosts.mjs +26 -0
- package/dist/package.json +20 -0
- package/{python → dist/python}/modelos_secure/clients.py +15 -2
- package/dist/python/modelos_secure/edge.py +365 -0
- package/{python → dist/python}/modelos_secure/hook.py +36 -4
- package/package.json +27 -11
- package/assets/login-BoqCvsTW.js +0 -1
- /package/{hook → dist/hook}/loader.mjs +0 -0
- /package/{hook → dist/hook}/rewrite.mjs +0 -0
- /package/{python → dist/python}/modelos_secure/__init__.py +0 -0
- /package/{python → dist/python}/requirements.txt +0 -0
- /package/{python → dist/python}/sitecustomize.py +0 -0
- /package/{wasm → dist/wasm}/tree-sitter-javascript.wasm +0 -0
- /package/{wasm → dist/wasm}/tree-sitter-python.wasm +0 -0
- /package/{wasm → dist/wasm}/tree-sitter-tsx.wasm +0 -0
- /package/{wasm → dist/wasm}/tree-sitter-typescript.wasm +0 -0
- /package/{wasm → dist/wasm}/web-tree-sitter.wasm +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"
|
|
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": ">=
|
|
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
|
+
}
|
package/assets/login-BoqCvsTW.js
DELETED
|
@@ -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
|
|
File without changes
|