modelos-secure 0.1.1 → 0.2.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/assets/login-CipN1qFD.js +1 -0
- package/assets/{login-WwE-Gunj.js → login-DZgP66jr.js} +47 -11
- package/cli.js +1754 -244
- package/hook/clients.mjs +11 -3
- package/hook/edge.mjs +405 -0
- package/hook/hook.mjs +35 -7
- package/hook/hosts.d.mts +2 -0
- package/hook/hosts.mjs +26 -0
- package/package.json +1 -1
- package/python/modelos_secure/clients.py +15 -2
- package/python/modelos_secure/edge.py +348 -0
- package/python/modelos_secure/hook.py +33 -4
- package/assets/login-BcHFyX6z.js +0 -1
|
@@ -0,0 +1,348 @@
|
|
|
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 held_response(httpx, request):
|
|
267
|
+
body = json.dumps({"status": "ok", "id": f"held-{random.randint(10000, 99999)}"}).encode()
|
|
268
|
+
return httpx.Response(200, headers={"content-type": "application/json"}, content=body, request=request)
|
|
269
|
+
|
|
270
|
+
def patch_httpx(httpx):
|
|
271
|
+
def rebuilt(request, body):
|
|
272
|
+
headers = {k: v for k, v in request.headers.items() if k.lower() != "content-length"}
|
|
273
|
+
return httpx.Request(request.method, request.url, headers=headers, content=json.dumps(body).encode())
|
|
274
|
+
|
|
275
|
+
def body_of(request):
|
|
276
|
+
try:
|
|
277
|
+
return json.loads(request.read() or b"null")
|
|
278
|
+
except Exception:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
def wanted(request):
|
|
282
|
+
body = body_of(request)
|
|
283
|
+
return body, model_shape(request.url.path, body)
|
|
284
|
+
|
|
285
|
+
sync = httpx.HTTPTransport.handle_request
|
|
286
|
+
|
|
287
|
+
def handle_request(self, request):
|
|
288
|
+
if attempt() is None:
|
|
289
|
+
return sync(self, request)
|
|
290
|
+
body, shape = hook["safely"](wanted, request) or (None, None)
|
|
291
|
+
if shape:
|
|
292
|
+
counted = in_sdk.get()
|
|
293
|
+
response = sync(self, hook["safely"](rebuilt, request, prepare(shape, body)) or request)
|
|
294
|
+
response.read()
|
|
295
|
+
hook["safely"](see, body, response.text, "event-stream" in response.headers.get("content-type", ""), counted)
|
|
296
|
+
return response
|
|
297
|
+
if passes(request.url.host):
|
|
298
|
+
return sync(self, request)
|
|
299
|
+
hold(request.method, request.url.host, request.url.path, len(request.content or b""))
|
|
300
|
+
return held_response(httpx, request)
|
|
301
|
+
|
|
302
|
+
httpx.HTTPTransport.handle_request = handle_request
|
|
303
|
+
|
|
304
|
+
asynchronous = httpx.AsyncHTTPTransport.handle_async_request
|
|
305
|
+
|
|
306
|
+
async def handle_async_request(self, request):
|
|
307
|
+
if attempt() is None:
|
|
308
|
+
return await asynchronous(self, request)
|
|
309
|
+
body, shape = hook["safely"](wanted, request) or (None, None)
|
|
310
|
+
if shape:
|
|
311
|
+
counted = in_sdk.get()
|
|
312
|
+
response = await asynchronous(self, hook["safely"](rebuilt, request, prepare(shape, body)) or request)
|
|
313
|
+
await response.aread()
|
|
314
|
+
hook["safely"](see, body, response.text, "event-stream" in response.headers.get("content-type", ""), counted)
|
|
315
|
+
return response
|
|
316
|
+
if passes(request.url.host):
|
|
317
|
+
return await asynchronous(self, request)
|
|
318
|
+
hold(request.method, request.url.host, request.url.path, len(request.content or b""))
|
|
319
|
+
return held_response(httpx, request)
|
|
320
|
+
|
|
321
|
+
httpx.AsyncHTTPTransport.handle_async_request = handle_async_request
|
|
322
|
+
|
|
323
|
+
def patch_http_client(http_client):
|
|
324
|
+
# putrequest, because urllib3 2 replaces request but still calls this one.
|
|
325
|
+
original = http_client.HTTPConnection.putrequest
|
|
326
|
+
|
|
327
|
+
def putrequest(self, method, url, *args, **kwargs):
|
|
328
|
+
if attempt() is not None and not passes(self.host):
|
|
329
|
+
hold(method, self.host, str(url).split("?")[0], 0)
|
|
330
|
+
raise ConnectionRefusedError("Stopped by ModelOS Secure during a review.")
|
|
331
|
+
return original(self, method, url, *args, **kwargs)
|
|
332
|
+
|
|
333
|
+
http_client.HTTPConnection.putrequest = putrequest
|
|
334
|
+
|
|
335
|
+
def patch_aiohttp(aiohttp):
|
|
336
|
+
original = aiohttp.ClientSession._request
|
|
337
|
+
|
|
338
|
+
async def _request(self, method, str_or_url, *args, **kwargs):
|
|
339
|
+
host = getattr(str_or_url, "host", None) or re.sub(r"^\w+://([^/:]+).*$", r"\1", str(str_or_url))
|
|
340
|
+
if attempt() is not None and not passes(host):
|
|
341
|
+
hold(method, host, "", 0)
|
|
342
|
+
raise ConnectionRefusedError("Stopped by ModelOS Secure during a review.")
|
|
343
|
+
return await original(self, method, str_or_url, *args, **kwargs)
|
|
344
|
+
|
|
345
|
+
aiohttp.ClientSession._request = _request
|
|
346
|
+
|
|
347
|
+
return {"httpx": patch_httpx, "http.client": patch_http_client, "aiohttp": patch_aiohttp}
|
|
348
|
+
|
|
@@ -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,7 @@ def _fresh_state():
|
|
|
37
37
|
"ran": [],
|
|
38
38
|
"plantDelivered": False,
|
|
39
39
|
"retrieved": [],
|
|
40
|
+
"edge": [],
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
|
|
@@ -182,7 +183,11 @@ def _call(key, args, kwargs):
|
|
|
182
183
|
return iter([held]) if inspect.isgeneratorfunction(entry["original"]) else held
|
|
183
184
|
if name in _tools:
|
|
184
185
|
_state["ran"].append(name)
|
|
185
|
-
|
|
186
|
+
running = edge.current_tool.set(name)
|
|
187
|
+
try:
|
|
188
|
+
result = entry["original"](*args, **kwargs)
|
|
189
|
+
finally:
|
|
190
|
+
edge.current_tool.reset(running)
|
|
186
191
|
if inspect.isawaitable(result):
|
|
187
192
|
return _finish_later(name, result)
|
|
188
193
|
return _finish(name, result)
|
|
@@ -195,7 +200,11 @@ async def _acall(key, args, kwargs):
|
|
|
195
200
|
return _hold(entry, args, kwargs)
|
|
196
201
|
if name in _tools:
|
|
197
202
|
_state["ran"].append(name)
|
|
198
|
-
|
|
203
|
+
running = edge.current_tool.set(name)
|
|
204
|
+
try:
|
|
205
|
+
return _finish(name, await entry["original"](*args, **kwargs))
|
|
206
|
+
finally:
|
|
207
|
+
edge.current_tool.reset(running)
|
|
199
208
|
|
|
200
209
|
|
|
201
210
|
async def _finish_later(name, awaitable):
|
|
@@ -422,13 +431,17 @@ class _Control(http.server.BaseHTTPRequestHandler):
|
|
|
422
431
|
if self.path == "/end":
|
|
423
432
|
seen = _state
|
|
424
433
|
_state = _fresh_state()
|
|
434
|
+
# The edge sees a tool call before its function runs. Where a hook then held the
|
|
435
|
+
# function itself, that record is the one kept, so a call counts once.
|
|
436
|
+
by_hook = {h["tool"] for h in seen["held"] if h.get("at") != "edge"}
|
|
425
437
|
return self._send(
|
|
426
438
|
{
|
|
427
|
-
"heldCalls": seen["held"],
|
|
439
|
+
"heldCalls": [h for h in seen["held"] if h.get("at") != "edge" or h["tool"] not in by_hook],
|
|
428
440
|
"modelCalls": seen["modelCalls"],
|
|
429
441
|
"ranTools": seen["ran"],
|
|
430
442
|
"plantDelivered": seen["plantDelivered"],
|
|
431
443
|
"retrieved": seen["retrieved"],
|
|
444
|
+
"edgeHeld": seen.get("edge", []),
|
|
432
445
|
}
|
|
433
446
|
)
|
|
434
447
|
if self.path == "/hello":
|
|
@@ -535,6 +548,22 @@ def install():
|
|
|
535
548
|
_ways_in.update({way["function"]: way for way in config.get("waysIn", []) if way.get("function")})
|
|
536
549
|
_files.update(os.path.realpath(path) for path in config.get("files", []))
|
|
537
550
|
clients.connect(_canary_line, _see_model_call, _safely)
|
|
551
|
+
# The edge, beneath every framework. http.client is always there; the others patch on import.
|
|
552
|
+
ways = {way["name"]: way for way in config.get("waysIn", [])}
|
|
553
|
+
patches = edge.install(
|
|
554
|
+
{
|
|
555
|
+
"state": lambda: _state,
|
|
556
|
+
"canary_line": _canary_line,
|
|
557
|
+
"mode": lambda name: _tools.get(name, {}).get("mode") if name else None,
|
|
558
|
+
"way_in": ways.get,
|
|
559
|
+
"allow": (config.get("edge") or {}).get("allow", []),
|
|
560
|
+
"safely": _safely,
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
import http.client
|
|
564
|
+
|
|
565
|
+
_safely(patches["http.client"], http.client)
|
|
566
|
+
clients.LIBRARIES.update({"httpx": patches["httpx"], "aiohttp": patches["aiohttp"]})
|
|
538
567
|
sys.meta_path.insert(0, _Finder)
|
|
539
568
|
for name in list(clients.LIBRARIES):
|
|
540
569
|
if name in sys.modules:
|
package/assets/login-BcHFyX6z.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { t as loginCommand } from "./login-WwE-Gunj.js";
|