modelos-secure 0.1.2 → 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-JPkrnvZC.js → login-DZgP66jr.js} +24 -5
- 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-BoqCvsTW.js +0 -1
package/hook/clients.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The plain model clients: the prompt canary going in, and what the model asked for coming out.
|
|
2
2
|
// Whatever happens here, the app's own call goes ahead as if the hook were not there.
|
|
3
3
|
|
|
4
|
+
import { sdkContext } from "./edge.mjs";
|
|
5
|
+
|
|
4
6
|
function safely(work, fallback) {
|
|
5
7
|
try {
|
|
6
8
|
return work();
|
|
@@ -198,7 +200,9 @@ export function clientPatches({ canaryLine, seeModelCall }) {
|
|
|
198
200
|
const original = prototype.create;
|
|
199
201
|
prototype.create = function (body, options) {
|
|
200
202
|
const prepared = safely(() => withCanary(body, canaryLine()), body);
|
|
201
|
-
const result =
|
|
203
|
+
const result = sdkContext.run(true, () =>
|
|
204
|
+
original.call(this, prepared, options),
|
|
205
|
+
);
|
|
202
206
|
const see = (data) => {
|
|
203
207
|
if (body?.stream && data?.[Symbol.asyncIterator])
|
|
204
208
|
return safely(
|
|
@@ -229,7 +233,9 @@ export function clientPatches({ canaryLine, seeModelCall }) {
|
|
|
229
233
|
() => genAiWithCanary(params, canaryLine()),
|
|
230
234
|
params,
|
|
231
235
|
);
|
|
232
|
-
const response = await
|
|
236
|
+
const response = await sdkContext.run(true, () =>
|
|
237
|
+
original.call(this, prepared),
|
|
238
|
+
);
|
|
233
239
|
safely(() => report(params?.model, readGenAi(response)));
|
|
234
240
|
return response;
|
|
235
241
|
};
|
|
@@ -241,7 +247,9 @@ export function clientPatches({ canaryLine, seeModelCall }) {
|
|
|
241
247
|
() => genAiWithCanary(params, canaryLine()),
|
|
242
248
|
params,
|
|
243
249
|
);
|
|
244
|
-
const stream = await
|
|
250
|
+
const stream = await sdkContext.run(true, () =>
|
|
251
|
+
original.call(this, prepared),
|
|
252
|
+
);
|
|
245
253
|
return safely(
|
|
246
254
|
() => watchStream(stream, params?.model, genAiStreamReader()),
|
|
247
255
|
stream,
|
package/hook/edge.mjs
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// The edge: every framework ends at an HTTP call to a model, so the hook watches there too, below
|
|
2
|
+
// whatever framework the app uses. During an attempt it sees each model call (the tools offered, the
|
|
3
|
+
// tool calls the model makes and their arguments), adds the prompt canary, plants attacker text into
|
|
4
|
+
// a tool result, and stops every other call that leaves the machine, answering it as if it worked and
|
|
5
|
+
// recording it. A tool known to run, such as a read against a staging copy, keeps its calls. Outside
|
|
6
|
+
// an attempt it does nothing. No dependencies, Node 20 and later. ADR 0062.
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import { URL } from "node:url";
|
|
11
|
+
import { allowedHost } from "./hosts.mjs";
|
|
12
|
+
|
|
13
|
+
/** Which tool is running, so its own calls can be told apart from calls nobody declared. */
|
|
14
|
+
export const toolContext = new AsyncLocalStorage();
|
|
15
|
+
/** Set while a model client the hook already patches is calling, so the call is counted once. */
|
|
16
|
+
export const sdkContext = new AsyncLocalStorage();
|
|
17
|
+
|
|
18
|
+
const MODEL_PATHS = [
|
|
19
|
+
[/\/chat\/completions$/, "chat"],
|
|
20
|
+
[/\/responses$/, "responses"],
|
|
21
|
+
[/\/messages$/, "anthropic"],
|
|
22
|
+
[/:(stream)?[gG]enerateContent$/, "gemini"],
|
|
23
|
+
[/\/api\/chat$/, "ollama"],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function safely(work, fallback) {
|
|
27
|
+
try {
|
|
28
|
+
return work();
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parseArgs = (value) =>
|
|
35
|
+
value && typeof value === "object"
|
|
36
|
+
? value
|
|
37
|
+
: safely(() => JSON.parse(value), { raw: String(value ?? "") });
|
|
38
|
+
|
|
39
|
+
/** The API a request speaks, from its path and body, or null when it is not a model call. */
|
|
40
|
+
export function modelShape(url, body) {
|
|
41
|
+
if (!body || typeof body !== "object") return null;
|
|
42
|
+
for (const [pattern, shape] of MODEL_PATHS)
|
|
43
|
+
if (pattern.test(url.pathname)) return shape;
|
|
44
|
+
if (Array.isArray(body.messages) && typeof body.model === "string")
|
|
45
|
+
return "chat";
|
|
46
|
+
if (Array.isArray(body.contents)) return "gemini";
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const withLine = (content, line) =>
|
|
51
|
+
typeof content === "string"
|
|
52
|
+
? `${content}\n${line}`
|
|
53
|
+
: Array.isArray(content)
|
|
54
|
+
? [...content, { type: "text", text: line }]
|
|
55
|
+
: content;
|
|
56
|
+
|
|
57
|
+
/** The canary goes into the system prompt, whatever the API calls it, once. */
|
|
58
|
+
function addCanary(shape, body, line) {
|
|
59
|
+
if (!line || JSON.stringify(body).includes(line)) return body;
|
|
60
|
+
if (shape === "chat" || shape === "ollama") {
|
|
61
|
+
const i = body.messages.findIndex(
|
|
62
|
+
(m) => m?.role === "system" || m?.role === "developer",
|
|
63
|
+
);
|
|
64
|
+
if (i === -1) return body;
|
|
65
|
+
const messages = [...body.messages];
|
|
66
|
+
messages[i] = {
|
|
67
|
+
...messages[i],
|
|
68
|
+
content: withLine(messages[i].content, line),
|
|
69
|
+
};
|
|
70
|
+
return { ...body, messages };
|
|
71
|
+
}
|
|
72
|
+
if (shape === "responses" && typeof body.instructions === "string")
|
|
73
|
+
return { ...body, instructions: `${body.instructions}\n${line}` };
|
|
74
|
+
if (shape === "anthropic" && body.system)
|
|
75
|
+
return {
|
|
76
|
+
...body,
|
|
77
|
+
system:
|
|
78
|
+
typeof body.system === "string"
|
|
79
|
+
? `${body.system}\n${line}`
|
|
80
|
+
: [...body.system, { type: "text", text: line }],
|
|
81
|
+
};
|
|
82
|
+
if (shape === "gemini") {
|
|
83
|
+
const instruction = body.systemInstruction ?? body.system_instruction;
|
|
84
|
+
if (!instruction?.parts) return body;
|
|
85
|
+
return {
|
|
86
|
+
...body,
|
|
87
|
+
systemInstruction: {
|
|
88
|
+
...instruction,
|
|
89
|
+
parts: [...instruction.parts, { text: line }],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return body;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The tool results in a request, with the tool each answers, so a plant lands where it reads. */
|
|
97
|
+
function plantIntoResults(shape, body, tool, text) {
|
|
98
|
+
let delivered = false;
|
|
99
|
+
const named = new Map();
|
|
100
|
+
const plantText = (value) => {
|
|
101
|
+
delivered = true;
|
|
102
|
+
return typeof value === "string"
|
|
103
|
+
? `${value}\n\n${text}`
|
|
104
|
+
: Array.isArray(value)
|
|
105
|
+
? [...value, { type: "text", text }]
|
|
106
|
+
: { ...value, note: text };
|
|
107
|
+
};
|
|
108
|
+
if (shape === "chat" || shape === "ollama") {
|
|
109
|
+
for (const m of body.messages)
|
|
110
|
+
for (const call of m?.tool_calls ?? [])
|
|
111
|
+
named.set(call.id, call.function?.name);
|
|
112
|
+
const messages = body.messages.map((m) =>
|
|
113
|
+
m?.role === "tool" &&
|
|
114
|
+
(named.get(m.tool_call_id) === tool || m.name === tool)
|
|
115
|
+
? { ...m, content: plantText(m.content) }
|
|
116
|
+
: m,
|
|
117
|
+
);
|
|
118
|
+
return { body: { ...body, messages }, delivered };
|
|
119
|
+
}
|
|
120
|
+
if (shape === "anthropic") {
|
|
121
|
+
for (const m of body.messages ?? [])
|
|
122
|
+
for (const part of Array.isArray(m.content) ? m.content : [])
|
|
123
|
+
if (part.type === "tool_use") named.set(part.id, part.name);
|
|
124
|
+
const messages = (body.messages ?? []).map((m) =>
|
|
125
|
+
Array.isArray(m.content)
|
|
126
|
+
? {
|
|
127
|
+
...m,
|
|
128
|
+
content: m.content.map((part) =>
|
|
129
|
+
part.type === "tool_result" &&
|
|
130
|
+
named.get(part.tool_use_id) === tool
|
|
131
|
+
? { ...part, content: plantText(part.content) }
|
|
132
|
+
: part,
|
|
133
|
+
),
|
|
134
|
+
}
|
|
135
|
+
: m,
|
|
136
|
+
);
|
|
137
|
+
return { body: { ...body, messages }, delivered };
|
|
138
|
+
}
|
|
139
|
+
if (shape === "gemini") {
|
|
140
|
+
const contents = body.contents.map((c) => ({
|
|
141
|
+
...c,
|
|
142
|
+
parts: (c.parts ?? []).map((p) =>
|
|
143
|
+
p.functionResponse?.name === tool
|
|
144
|
+
? {
|
|
145
|
+
...p,
|
|
146
|
+
functionResponse: {
|
|
147
|
+
...p.functionResponse,
|
|
148
|
+
response: plantText(p.functionResponse.response ?? {}),
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
: p,
|
|
152
|
+
),
|
|
153
|
+
}));
|
|
154
|
+
return { body: { ...body, contents }, delivered };
|
|
155
|
+
}
|
|
156
|
+
if (shape === "responses" && Array.isArray(body.input)) {
|
|
157
|
+
for (const item of body.input)
|
|
158
|
+
if (item.type === "function_call") named.set(item.call_id, item.name);
|
|
159
|
+
const input = body.input.map((item) =>
|
|
160
|
+
item.type === "function_call_output" && named.get(item.call_id) === tool
|
|
161
|
+
? { ...item, output: plantText(item.output) }
|
|
162
|
+
: item,
|
|
163
|
+
);
|
|
164
|
+
return { body: { ...body, input }, delivered };
|
|
165
|
+
}
|
|
166
|
+
return { body, delivered };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The tools offered and the calls asked for, in any of the APIs above. */
|
|
170
|
+
export function readModel(shape, body, answer) {
|
|
171
|
+
const offered = [
|
|
172
|
+
...(body.tools ?? []).map((t) => t.function?.name ?? t.name),
|
|
173
|
+
...(body.tools ?? []).flatMap((t) =>
|
|
174
|
+
(t.functionDeclarations ?? t.function_declarations ?? []).map(
|
|
175
|
+
(d) => d.name,
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
].filter(Boolean);
|
|
179
|
+
const calls = [];
|
|
180
|
+
for (const choice of answer.choices ?? [])
|
|
181
|
+
for (const call of choice.message?.tool_calls ?? [])
|
|
182
|
+
calls.push({
|
|
183
|
+
tool: call.function?.name,
|
|
184
|
+
args: parseArgs(call.function?.arguments),
|
|
185
|
+
});
|
|
186
|
+
for (const call of answer.message?.tool_calls ?? [])
|
|
187
|
+
calls.push({
|
|
188
|
+
tool: call.function?.name,
|
|
189
|
+
args: parseArgs(call.function?.arguments),
|
|
190
|
+
});
|
|
191
|
+
for (const item of answer.output ?? [])
|
|
192
|
+
if (item.type === "function_call")
|
|
193
|
+
calls.push({ tool: item.name, args: parseArgs(item.arguments) });
|
|
194
|
+
for (const part of Array.isArray(answer.content) ? answer.content : [])
|
|
195
|
+
if (part.type === "tool_use")
|
|
196
|
+
calls.push({ tool: part.name, args: part.input ?? {} });
|
|
197
|
+
for (const candidate of answer.candidates ?? [])
|
|
198
|
+
for (const part of candidate.content?.parts ?? [])
|
|
199
|
+
if (part.functionCall)
|
|
200
|
+
calls.push({
|
|
201
|
+
tool: part.functionCall.name,
|
|
202
|
+
args: part.functionCall.args ?? {},
|
|
203
|
+
});
|
|
204
|
+
const usage = answer.usage ?? answer.usageMetadata ?? {};
|
|
205
|
+
return {
|
|
206
|
+
model: body.model ?? answer.model,
|
|
207
|
+
offered,
|
|
208
|
+
calls: calls.filter((c) => c.tool),
|
|
209
|
+
inputTokens:
|
|
210
|
+
usage.prompt_tokens ?? usage.input_tokens ?? usage.promptTokenCount ?? 0,
|
|
211
|
+
outputTokens:
|
|
212
|
+
usage.completion_tokens ??
|
|
213
|
+
usage.output_tokens ??
|
|
214
|
+
usage.candidatesTokenCount ??
|
|
215
|
+
0,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A streamed answer, read event by event into the same shape as a whole one. */
|
|
220
|
+
export function readStream(shape, body, text) {
|
|
221
|
+
const events = text
|
|
222
|
+
.split("\n")
|
|
223
|
+
.filter((l) => l.startsWith("data:"))
|
|
224
|
+
.map((l) => safely(() => JSON.parse(l.slice(5).trim()), null))
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
const pieces = new Map();
|
|
227
|
+
const whole = { choices: [], content: [], candidates: [], output: [] };
|
|
228
|
+
for (const event of events) {
|
|
229
|
+
for (const choice of event.choices ?? [])
|
|
230
|
+
for (const delta of choice.delta?.tool_calls ?? []) {
|
|
231
|
+
const piece = pieces.get(`c${delta.index}`) ?? { name: "", args: "" };
|
|
232
|
+
piece.name += delta.function?.name ?? "";
|
|
233
|
+
piece.args += delta.function?.arguments ?? "";
|
|
234
|
+
pieces.set(`c${delta.index}`, piece);
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
event.type === "content_block_start" &&
|
|
238
|
+
event.content_block?.type === "tool_use"
|
|
239
|
+
)
|
|
240
|
+
pieces.set(`a${event.index}`, {
|
|
241
|
+
name: event.content_block.name,
|
|
242
|
+
args: "",
|
|
243
|
+
});
|
|
244
|
+
if (event.delta?.type === "input_json_delta") {
|
|
245
|
+
const piece = pieces.get(`a${event.index}`);
|
|
246
|
+
if (piece) piece.args += event.delta.partial_json ?? "";
|
|
247
|
+
}
|
|
248
|
+
if (event.candidates) whole.candidates.push(...event.candidates);
|
|
249
|
+
if (event.type === "response.completed")
|
|
250
|
+
whole.output = event.response?.output ?? [];
|
|
251
|
+
if (event.usage || event.usageMetadata)
|
|
252
|
+
whole.usage = event.usage ?? event.usageMetadata;
|
|
253
|
+
}
|
|
254
|
+
for (const piece of pieces.values())
|
|
255
|
+
whole.choices.push({
|
|
256
|
+
message: {
|
|
257
|
+
tool_calls: [{ function: { name: piece.name, arguments: piece.args } }],
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
return readModel(shape, body, whole);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Installs the edge. `hook` gives the attempt's state, the config's tools and ways in, what the
|
|
265
|
+
* person allowed through, and where a held call is recorded.
|
|
266
|
+
*/
|
|
267
|
+
export function installEdge(hook) {
|
|
268
|
+
const allowed = (host) => allowedHost(host, hook.allow);
|
|
269
|
+
/** Inside a tool the person said may run, its calls go through, to a staging copy. */
|
|
270
|
+
const insideRunningTool = () => hook.mode(toolContext.getStore()) === "run";
|
|
271
|
+
|
|
272
|
+
function hold(method, url, bytes) {
|
|
273
|
+
hook
|
|
274
|
+
.state()
|
|
275
|
+
.edge.push({ method, host: url.hostname, path: url.pathname, bytes });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function seeModel(shape, body, answer, streamed, counted) {
|
|
279
|
+
const read = streamed
|
|
280
|
+
? readStream(shape, body, answer)
|
|
281
|
+
: readModel(shape, body, answer);
|
|
282
|
+
const state = hook.state();
|
|
283
|
+
if (!counted)
|
|
284
|
+
state.modelCalls.push({
|
|
285
|
+
model: read.model,
|
|
286
|
+
toolCalls: read.calls,
|
|
287
|
+
offered: read.offered,
|
|
288
|
+
inputTokens: read.inputTokens,
|
|
289
|
+
outputTokens: read.outputTokens,
|
|
290
|
+
edge: true,
|
|
291
|
+
});
|
|
292
|
+
// A call to a tool marked held that no function-level hook caught: what the model asked for
|
|
293
|
+
// is the evidence, and the edge stops what the tool does.
|
|
294
|
+
for (const call of read.calls)
|
|
295
|
+
if (
|
|
296
|
+
hook.mode(call.tool) === "hold" &&
|
|
297
|
+
!state.held.some((h) => h.tool === call.tool)
|
|
298
|
+
)
|
|
299
|
+
state.held.push({ tool: call.tool, args: call.args, at: "edge" });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** What a model call carries in, during an attempt: the canary, and a plant at a tool result. */
|
|
303
|
+
function prepare(shape, body) {
|
|
304
|
+
const state = hook.state();
|
|
305
|
+
let prepared = addCanary(shape, body, hook.canaryLine());
|
|
306
|
+
const plant = state.attempt?.plant;
|
|
307
|
+
const way = plant && hook.wayIn(plant.wayIn);
|
|
308
|
+
if (plant && way?.function && !state.plantDelivered) {
|
|
309
|
+
const planted = plantIntoResults(
|
|
310
|
+
shape,
|
|
311
|
+
prepared,
|
|
312
|
+
way.function,
|
|
313
|
+
plant.text,
|
|
314
|
+
);
|
|
315
|
+
prepared = planted.body;
|
|
316
|
+
if (planted.delivered) state.plantDelivered = true;
|
|
317
|
+
}
|
|
318
|
+
return prepared;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const original = globalThis.fetch;
|
|
322
|
+
if (original)
|
|
323
|
+
globalThis.fetch = async function modelosSecureFetch(input, init) {
|
|
324
|
+
if (!hook.state().attempt) return original(input, init);
|
|
325
|
+
const url = new URL(
|
|
326
|
+
typeof input === "string" || input instanceof URL ? input : input.url,
|
|
327
|
+
);
|
|
328
|
+
const text = typeof init?.body === "string" ? init.body : undefined;
|
|
329
|
+
const body = text ? safely(() => JSON.parse(text), null) : null;
|
|
330
|
+
const shape = modelShape(url, body);
|
|
331
|
+
if (shape) {
|
|
332
|
+
const counted = Boolean(sdkContext.getStore());
|
|
333
|
+
const response = await original(input, {
|
|
334
|
+
...init,
|
|
335
|
+
body: JSON.stringify(prepare(shape, body)),
|
|
336
|
+
});
|
|
337
|
+
const streamed =
|
|
338
|
+
body.stream === true || /stream/i.test(url.pathname + url.search);
|
|
339
|
+
void response
|
|
340
|
+
.clone()
|
|
341
|
+
.text()
|
|
342
|
+
.then((answer) =>
|
|
343
|
+
safely(() =>
|
|
344
|
+
seeModel(
|
|
345
|
+
shape,
|
|
346
|
+
body,
|
|
347
|
+
streamed ? answer : JSON.parse(answer),
|
|
348
|
+
streamed,
|
|
349
|
+
counted,
|
|
350
|
+
),
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
.catch(() => undefined);
|
|
354
|
+
return response;
|
|
355
|
+
}
|
|
356
|
+
if (allowed(url.hostname) || insideRunningTool())
|
|
357
|
+
return original(input, init);
|
|
358
|
+
hold(init?.method ?? "GET", url, text?.length ?? 0);
|
|
359
|
+
return new globalThis.Response(
|
|
360
|
+
JSON.stringify({ status: "ok", id: `held-${Date.now()}` }),
|
|
361
|
+
{
|
|
362
|
+
status: 200,
|
|
363
|
+
headers: { "content-type": "application/json" },
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Clients built on http and https, such as axios and older SDKs: a call that would leave goes to
|
|
369
|
+
// the hook's own port instead, which answers as if it worked.
|
|
370
|
+
for (const module of [http, https]) {
|
|
371
|
+
for (const name of ["request", "get"]) {
|
|
372
|
+
const real = module[name];
|
|
373
|
+
module[name] = function modelosSecureRequest(...args) {
|
|
374
|
+
const state = hook.state();
|
|
375
|
+
const port = hook.controlPort();
|
|
376
|
+
if (!state.attempt || !port) return real.apply(this, args);
|
|
377
|
+
const target = safely(() => requestUrl(module, args), null);
|
|
378
|
+
if (!target || allowed(target.hostname) || insideRunningTool())
|
|
379
|
+
return real.apply(this, args);
|
|
380
|
+
hold(requestMethod(args), target, 0);
|
|
381
|
+
return http[name](
|
|
382
|
+
`http://127.0.0.1:${port}/held`,
|
|
383
|
+
...args.filter((a) => typeof a === "function"),
|
|
384
|
+
);
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function requestUrl(module, args) {
|
|
391
|
+
const [first, second] = args;
|
|
392
|
+
if (typeof first === "string" || first instanceof URL) return new URL(first);
|
|
393
|
+
const options = first ?? second ?? {};
|
|
394
|
+
const protocol = options.protocol ?? (module === https ? "https:" : "http:");
|
|
395
|
+
return new URL(
|
|
396
|
+
`${protocol}//${options.hostname ?? options.host ?? "localhost"}${options.path ?? "/"}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function requestMethod(args) {
|
|
401
|
+
const options = args.find(
|
|
402
|
+
(a) => a && typeof a === "object" && !(a instanceof URL),
|
|
403
|
+
);
|
|
404
|
+
return options?.method ?? "GET";
|
|
405
|
+
}
|
package/hook/hook.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import nodeModule from "node:module";
|
|
|
8
8
|
import { pathToFileURL, URL } from "node:url";
|
|
9
9
|
import { isMainThread } from "node:worker_threads";
|
|
10
10
|
import { clientPatches } from "./clients.mjs";
|
|
11
|
+
import { installEdge, sdkContext, toolContext } from "./edge.mjs";
|
|
11
12
|
import { rewriteLoaded, rewriteSource, urlToLoad } from "./rewrite.mjs";
|
|
12
13
|
|
|
13
14
|
const raw = process.env.MODELOS_SECURE_HOOK;
|
|
@@ -19,6 +20,8 @@ if (raw && isMainThread) {
|
|
|
19
20
|
const waysIn = new Map(
|
|
20
21
|
config.waysIn.filter((w) => w.function).map((w) => [w.function, w]),
|
|
21
22
|
);
|
|
23
|
+
const waysByName = new Map(config.waysIn.map((w) => [w.name, w]));
|
|
24
|
+
let controlPort = 0;
|
|
22
25
|
|
|
23
26
|
let state = freshState();
|
|
24
27
|
function freshState() {
|
|
@@ -29,6 +32,7 @@ if (raw && isMainThread) {
|
|
|
29
32
|
ran: [],
|
|
30
33
|
plantDelivered: false,
|
|
31
34
|
retrieved: [],
|
|
35
|
+
edge: [],
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -138,7 +142,7 @@ if (raw && isMainThread) {
|
|
|
138
142
|
return heldResult(name);
|
|
139
143
|
}
|
|
140
144
|
if (tool) state.ran.push(name);
|
|
141
|
-
const result = original.apply(self, args);
|
|
145
|
+
const result = toolContext.run(name, () => original.apply(self, args));
|
|
142
146
|
if (result && typeof result.then === "function")
|
|
143
147
|
return result.then((value) => afterRun(name, value));
|
|
144
148
|
return afterRun(name, result);
|
|
@@ -181,11 +185,13 @@ if (raw && isMainThread) {
|
|
|
181
185
|
];
|
|
182
186
|
}),
|
|
183
187
|
);
|
|
184
|
-
const result =
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
const result = sdkContext.run(true, () =>
|
|
189
|
+
original({
|
|
190
|
+
...options,
|
|
191
|
+
tools,
|
|
192
|
+
system: promptWithCanary(options.system),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
189
195
|
if (result && typeof result.then === "function") {
|
|
190
196
|
return result.then((value) => {
|
|
191
197
|
for (const step of value.steps ?? []) {
|
|
@@ -210,6 +216,15 @@ if (raw && isMainThread) {
|
|
|
210
216
|
...clientPatches({ canaryLine, seeModelCall }),
|
|
211
217
|
};
|
|
212
218
|
|
|
219
|
+
installEdge({
|
|
220
|
+
state: () => state,
|
|
221
|
+
canaryLine,
|
|
222
|
+
mode: (name) => toolsByName.get(name)?.mode,
|
|
223
|
+
wayIn: (name) => waysByName.get(name),
|
|
224
|
+
allow: config.edge?.allow ?? [],
|
|
225
|
+
controlPort: () => controlPort,
|
|
226
|
+
});
|
|
227
|
+
|
|
213
228
|
// Source rewriting, so nothing in the app's code changes.
|
|
214
229
|
const options = {
|
|
215
230
|
held: config.tools
|
|
@@ -257,22 +272,35 @@ if (raw && isMainThread) {
|
|
|
257
272
|
ran,
|
|
258
273
|
plantDelivered,
|
|
259
274
|
retrieved,
|
|
275
|
+
edge,
|
|
260
276
|
} = state;
|
|
261
277
|
state = freshState();
|
|
278
|
+
// The edge sees a tool call before its function runs. Where a hook then held the function
|
|
279
|
+
// itself, that record is the one kept, so a call counts once.
|
|
280
|
+
const byHook = new Set(
|
|
281
|
+
heldCalls.filter((h) => h.at !== "edge").map((h) => h.tool),
|
|
282
|
+
);
|
|
262
283
|
return send({
|
|
263
|
-
heldCalls
|
|
284
|
+
heldCalls: heldCalls.filter(
|
|
285
|
+
(h) => h.at !== "edge" || !byHook.has(h.tool),
|
|
286
|
+
),
|
|
264
287
|
modelCalls,
|
|
265
288
|
ranTools: ran,
|
|
266
289
|
plantDelivered,
|
|
267
290
|
retrieved,
|
|
291
|
+
edgeHeld: edge,
|
|
268
292
|
});
|
|
269
293
|
}
|
|
294
|
+
// A call the edge stopped lands here, and is answered as if it worked.
|
|
295
|
+
if (request.url === "/held")
|
|
296
|
+
return send({ status: "ok", id: `held-${Date.now().toString()}` });
|
|
270
297
|
if (request.url === "/hello") return send({ ok: true, hook: "node" });
|
|
271
298
|
response.writeHead(404);
|
|
272
299
|
response.end();
|
|
273
300
|
});
|
|
274
301
|
server.listen(0, "127.0.0.1", () => {
|
|
275
302
|
const { port } = server.address();
|
|
303
|
+
controlPort = port;
|
|
276
304
|
server.unref();
|
|
277
305
|
fetch(`${process.env.MODELOS_SECURE_RUNNER_URL}/ready`, {
|
|
278
306
|
method: "POST",
|
package/hook/hosts.d.mts
ADDED
package/hook/hosts.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The hosts a review lets an app reach: its own machine, model providers and the sign-in hosts
|
|
2
|
+
// their clients need, and what the person allowed in modelos-secure.yaml. Shared by the in-process
|
|
3
|
+
// edge and the runner's proxy, so both stop the same calls. ADR 0062.
|
|
4
|
+
|
|
5
|
+
const MODEL_HOSTS = [
|
|
6
|
+
/(^|\.)api\.openai\.com$/,
|
|
7
|
+
/\.openai\.azure\.com$/,
|
|
8
|
+
/(^|\.)api\.anthropic\.com$/,
|
|
9
|
+
/(^|\.)generativelanguage\.googleapis\.com$/,
|
|
10
|
+
/aiplatform\.googleapis\.com$/,
|
|
11
|
+
/^(oauth2|sts|iamcredentials)\.googleapis\.com$/,
|
|
12
|
+
/^bedrock-runtime\.[\w-]+\.amazonaws\.com$/,
|
|
13
|
+
/^(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)$/,
|
|
14
|
+
/^login\.microsoftonline\.com$/,
|
|
15
|
+
];
|
|
16
|
+
const LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0)$/;
|
|
17
|
+
|
|
18
|
+
/** Whether a call to this host may leave during an attempt. */
|
|
19
|
+
export function allowedHost(host, allow = []) {
|
|
20
|
+
const name = String(host).toLowerCase();
|
|
21
|
+
return (
|
|
22
|
+
LOOPBACK.test(name) ||
|
|
23
|
+
MODEL_HOSTS.some((p) => p.test(name)) ||
|
|
24
|
+
allow.some((a) => name === a || name.endsWith(`.${a}`))
|
|
25
|
+
);
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Whatever happens here, the app's own call goes ahead as if the hook were not there.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from modelos_secure import edge
|
|
6
7
|
import contextvars
|
|
7
8
|
import functools
|
|
8
9
|
import inspect
|
|
@@ -142,7 +143,11 @@ def _watch_call(original, prepare, read, stream_reader=None, always_streams=Fals
|
|
|
142
143
|
@functools.wraps(original)
|
|
143
144
|
async def wrapper(*args, **kwargs):
|
|
144
145
|
args, kwargs, watching = before(args, kwargs)
|
|
145
|
-
|
|
146
|
+
counted = edge.in_sdk.set(True)
|
|
147
|
+
try:
|
|
148
|
+
result = await original(*args, **kwargs)
|
|
149
|
+
finally:
|
|
150
|
+
edge.in_sdk.reset(counted)
|
|
146
151
|
return after(args, kwargs, result) if watching else result
|
|
147
152
|
|
|
148
153
|
else:
|
|
@@ -150,7 +155,11 @@ def _watch_call(original, prepare, read, stream_reader=None, always_streams=Fals
|
|
|
150
155
|
@functools.wraps(original)
|
|
151
156
|
def wrapper(*args, **kwargs):
|
|
152
157
|
args, kwargs, watching = before(args, kwargs)
|
|
153
|
-
|
|
158
|
+
counted = edge.in_sdk.set(True)
|
|
159
|
+
try:
|
|
160
|
+
result = original(*args, **kwargs)
|
|
161
|
+
finally:
|
|
162
|
+
edge.in_sdk.reset(counted)
|
|
154
163
|
if not watching:
|
|
155
164
|
return result
|
|
156
165
|
if inspect.isawaitable(result):
|
|
@@ -406,9 +415,11 @@ def _patch_langchain(module):
|
|
|
406
415
|
def wrapper(self, messages, *args, **kwargs):
|
|
407
416
|
messages = _safely(_prepare_langchain_messages, messages, fallback=messages)
|
|
408
417
|
token = _inside_framework.set(True)
|
|
418
|
+
counted = edge.in_sdk.set(True)
|
|
409
419
|
try:
|
|
410
420
|
result = original(self, messages, *args, **kwargs)
|
|
411
421
|
finally:
|
|
422
|
+
edge.in_sdk.reset(counted)
|
|
412
423
|
_inside_framework.reset(token)
|
|
413
424
|
_safely(_read_langchain, _langchain_model_name(self), result)
|
|
414
425
|
return result
|
|
@@ -421,9 +432,11 @@ def _patch_langchain(module):
|
|
|
421
432
|
async def wrapper(self, messages, *args, **kwargs):
|
|
422
433
|
messages = _safely(_prepare_langchain_messages, messages, fallback=messages)
|
|
423
434
|
token = _inside_framework.set(True)
|
|
435
|
+
counted = edge.in_sdk.set(True)
|
|
424
436
|
try:
|
|
425
437
|
result = await original(self, messages, *args, **kwargs)
|
|
426
438
|
finally:
|
|
439
|
+
edge.in_sdk.reset(counted)
|
|
427
440
|
_inside_framework.reset(token)
|
|
428
441
|
_safely(_read_langchain, _langchain_model_name(self), result)
|
|
429
442
|
return result
|