modelos-secure 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/assets/login-B21lxf7i.js +553 -0
- package/assets/login-CVPBOTvx.js +1 -0
- package/cli.js +19744 -0
- package/hook/clients.mjs +270 -0
- package/hook/hook.mjs +306 -0
- package/hook/loader.mjs +12 -0
- package/hook/rewrite.mjs +95 -0
- package/package.json +17 -3
- package/python/modelos_secure/__init__.py +0 -0
- package/python/modelos_secure/clients.py +444 -0
- package/python/modelos_secure/hook.py +542 -0
- package/python/requirements.txt +0 -0
- package/python/sitecustomize.py +13 -0
- package/wasm/tree-sitter-javascript.wasm +0 -0
- package/wasm/tree-sitter-python.wasm +0 -0
- package/wasm/tree-sitter-tsx.wasm +0 -0
- package/wasm/tree-sitter-typescript.wasm +0 -0
- package/wasm/web-tree-sitter.wasm +0 -0
package/hook/clients.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The plain model clients: the prompt canary going in, and what the model asked for coming out.
|
|
2
|
+
// Whatever happens here, the app's own call goes ahead as if the hook were not there.
|
|
3
|
+
|
|
4
|
+
function safely(work, fallback) {
|
|
5
|
+
try {
|
|
6
|
+
return work();
|
|
7
|
+
} catch {
|
|
8
|
+
return fallback;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseArgs(text) {
|
|
13
|
+
if (text && typeof text === "object") return text;
|
|
14
|
+
const parsed = safely(() => JSON.parse(text), undefined);
|
|
15
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
16
|
+
return parsed === undefined ? { raw: String(text) } : { value: parsed };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Adds a line to a message's content, whether it is text or a list of parts. */
|
|
20
|
+
function withLine(content, line) {
|
|
21
|
+
if (typeof content === "string") return `${content}\n${line}`;
|
|
22
|
+
if (Array.isArray(content)) return [...content, { type: "text", text: line }];
|
|
23
|
+
return content;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function canaryInMessages(messages, line) {
|
|
27
|
+
const index = messages.findIndex(
|
|
28
|
+
(m) => m?.role === "system" || m?.role === "developer",
|
|
29
|
+
);
|
|
30
|
+
if (index === -1) return messages;
|
|
31
|
+
const copy = [...messages];
|
|
32
|
+
copy[index] = {
|
|
33
|
+
...copy[index],
|
|
34
|
+
content: withLine(copy[index].content, line),
|
|
35
|
+
};
|
|
36
|
+
return copy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function chatWithCanary(body, line) {
|
|
40
|
+
if (!line || !Array.isArray(body?.messages)) return body;
|
|
41
|
+
return { ...body, messages: canaryInMessages(body.messages, line) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function responsesWithCanary(body, line) {
|
|
45
|
+
if (!line) return body;
|
|
46
|
+
if (typeof body?.instructions === "string")
|
|
47
|
+
return { ...body, instructions: `${body.instructions}\n${line}` };
|
|
48
|
+
if (Array.isArray(body?.input))
|
|
49
|
+
return { ...body, input: canaryInMessages(body.input, line) };
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readChat(completion) {
|
|
54
|
+
const calls = (completion.choices ?? []).flatMap((choice) =>
|
|
55
|
+
(choice.message?.tool_calls ?? []).flatMap((call) =>
|
|
56
|
+
call.function
|
|
57
|
+
? [
|
|
58
|
+
{
|
|
59
|
+
tool: call.function.name,
|
|
60
|
+
args: parseArgs(call.function.arguments),
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
: [],
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
calls,
|
|
68
|
+
inputTokens: completion.usage?.prompt_tokens,
|
|
69
|
+
outputTokens: completion.usage?.completion_tokens,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function chatStreamReader() {
|
|
74
|
+
const calls = new Map();
|
|
75
|
+
let usage;
|
|
76
|
+
return {
|
|
77
|
+
add(chunk) {
|
|
78
|
+
usage = chunk.usage ?? usage;
|
|
79
|
+
for (const choice of chunk.choices ?? []) {
|
|
80
|
+
for (const delta of choice.delta?.tool_calls ?? []) {
|
|
81
|
+
const call = calls.get(delta.index) ?? { tool: "", text: "" };
|
|
82
|
+
call.tool += delta.function?.name ?? "";
|
|
83
|
+
call.text += delta.function?.arguments ?? "";
|
|
84
|
+
calls.set(delta.index, call);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
finish: () => ({
|
|
89
|
+
calls: [...calls.values()].map((c) => ({
|
|
90
|
+
tool: c.tool,
|
|
91
|
+
args: parseArgs(c.text),
|
|
92
|
+
})),
|
|
93
|
+
inputTokens: usage?.prompt_tokens,
|
|
94
|
+
outputTokens: usage?.completion_tokens,
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readResponse(response) {
|
|
100
|
+
const calls = (response.output ?? [])
|
|
101
|
+
.filter((item) => item.type === "function_call")
|
|
102
|
+
.map((item) => ({ tool: item.name, args: parseArgs(item.arguments) }));
|
|
103
|
+
return {
|
|
104
|
+
calls,
|
|
105
|
+
inputTokens: response.usage?.input_tokens,
|
|
106
|
+
outputTokens: response.usage?.output_tokens,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function responsesStreamReader() {
|
|
111
|
+
let found = { calls: [] };
|
|
112
|
+
return {
|
|
113
|
+
add(event) {
|
|
114
|
+
if (event.type === "response.completed")
|
|
115
|
+
found = readResponse(event.response);
|
|
116
|
+
},
|
|
117
|
+
finish: () => found,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function genAiWithCanary(params, line) {
|
|
122
|
+
const instruction = params?.config?.systemInstruction;
|
|
123
|
+
if (!line || !instruction) return params;
|
|
124
|
+
let changed = instruction;
|
|
125
|
+
if (typeof instruction === "string") changed = `${instruction}\n${line}`;
|
|
126
|
+
else if (Array.isArray(instruction)) changed = [...instruction, line];
|
|
127
|
+
else if (Array.isArray(instruction.parts))
|
|
128
|
+
changed = { ...instruction, parts: [...instruction.parts, { text: line }] };
|
|
129
|
+
else if (typeof instruction.text === "string")
|
|
130
|
+
changed = { ...instruction, text: `${instruction.text}\n${line}` };
|
|
131
|
+
return {
|
|
132
|
+
...params,
|
|
133
|
+
config: { ...params.config, systemInstruction: changed },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readGenAi(response) {
|
|
138
|
+
const calls = (response.candidates?.[0]?.content?.parts ?? []).flatMap(
|
|
139
|
+
(part) =>
|
|
140
|
+
part.functionCall
|
|
141
|
+
? [{ tool: part.functionCall.name, args: part.functionCall.args ?? {} }]
|
|
142
|
+
: [],
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
calls,
|
|
146
|
+
inputTokens: response.usageMetadata?.promptTokenCount,
|
|
147
|
+
outputTokens: response.usageMetadata?.candidatesTokenCount,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function genAiStreamReader() {
|
|
152
|
+
const calls = [];
|
|
153
|
+
let inputTokens;
|
|
154
|
+
let outputTokens;
|
|
155
|
+
return {
|
|
156
|
+
add(chunk) {
|
|
157
|
+
const found = readGenAi(chunk);
|
|
158
|
+
calls.push(...found.calls);
|
|
159
|
+
inputTokens = found.inputTokens ?? inputTokens;
|
|
160
|
+
outputTokens = found.outputTokens ?? outputTokens;
|
|
161
|
+
},
|
|
162
|
+
finish: () => ({ calls, inputTokens, outputTokens }),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function clientPatches({ canaryLine, seeModelCall }) {
|
|
167
|
+
const report = (model, found) =>
|
|
168
|
+
seeModelCall(model, found.calls, {
|
|
169
|
+
inputTokens: found.inputTokens,
|
|
170
|
+
outputTokens: found.outputTokens,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** The app reads the stream as usual; what the model asked for is known once it has been read. */
|
|
174
|
+
function watchStream(stream, model, reader) {
|
|
175
|
+
// Taken once, because a generator's iterator is the generator itself: replacing its iterator
|
|
176
|
+
// method and then asking it again would call the replacement for ever.
|
|
177
|
+
const inner = stream[Symbol.asyncIterator]();
|
|
178
|
+
const watched = {
|
|
179
|
+
async next(...args) {
|
|
180
|
+
const step = await inner.next(...args);
|
|
181
|
+
if (step.done) safely(() => report(model, reader.finish()));
|
|
182
|
+
else safely(() => reader.add(step.value));
|
|
183
|
+
return step;
|
|
184
|
+
},
|
|
185
|
+
return: (...args) => inner.return(...args),
|
|
186
|
+
throw: (...args) => inner.throw(...args),
|
|
187
|
+
[Symbol.asyncIterator]() {
|
|
188
|
+
return watched;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
if (typeof stream.next === "function") return watched;
|
|
192
|
+
stream[Symbol.asyncIterator] = () => watched;
|
|
193
|
+
return stream;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function patchOpenAiCreate(prototype, withCanary, read, streamReader) {
|
|
197
|
+
if (!prototype?.create) return;
|
|
198
|
+
const original = prototype.create;
|
|
199
|
+
prototype.create = function (body, options) {
|
|
200
|
+
const prepared = safely(() => withCanary(body, canaryLine()), body);
|
|
201
|
+
const result = original.call(this, prepared, options);
|
|
202
|
+
const see = (data) => {
|
|
203
|
+
if (body?.stream && data?.[Symbol.asyncIterator])
|
|
204
|
+
return safely(
|
|
205
|
+
() => watchStream(data, body.model, streamReader()),
|
|
206
|
+
data,
|
|
207
|
+
);
|
|
208
|
+
safely(() => report(body?.model, read(data)));
|
|
209
|
+
return data;
|
|
210
|
+
};
|
|
211
|
+
// The client's own promise sends nothing until asked, and has methods of its own, such as
|
|
212
|
+
// withResponse. Its chaining method keeps all of that, where a plain then would not.
|
|
213
|
+
if (typeof result?._thenUnwrap === "function")
|
|
214
|
+
return result._thenUnwrap(see);
|
|
215
|
+
return typeof result?.then === "function"
|
|
216
|
+
? result.then(see)
|
|
217
|
+
: see(result);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function patchGenAi(Models) {
|
|
222
|
+
const prototype = Models?.prototype;
|
|
223
|
+
// generateContent is set up in the constructor. These two are what it calls, once for each
|
|
224
|
+
// real model call, so automatic function calling is counted call by call.
|
|
225
|
+
if (prototype?.generateContentInternal) {
|
|
226
|
+
const original = prototype.generateContentInternal;
|
|
227
|
+
prototype.generateContentInternal = async function (params) {
|
|
228
|
+
const prepared = safely(
|
|
229
|
+
() => genAiWithCanary(params, canaryLine()),
|
|
230
|
+
params,
|
|
231
|
+
);
|
|
232
|
+
const response = await original.call(this, prepared);
|
|
233
|
+
safely(() => report(params?.model, readGenAi(response)));
|
|
234
|
+
return response;
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (prototype?.generateContentStreamInternal) {
|
|
238
|
+
const original = prototype.generateContentStreamInternal;
|
|
239
|
+
prototype.generateContentStreamInternal = async function (params) {
|
|
240
|
+
const prepared = safely(
|
|
241
|
+
() => genAiWithCanary(params, canaryLine()),
|
|
242
|
+
params,
|
|
243
|
+
);
|
|
244
|
+
const stream = await original.call(this, prepared);
|
|
245
|
+
return safely(
|
|
246
|
+
() => watchStream(stream, params?.model, genAiStreamReader()),
|
|
247
|
+
stream,
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
patchOpenAiChat: (Completions) =>
|
|
255
|
+
patchOpenAiCreate(
|
|
256
|
+
Completions?.prototype,
|
|
257
|
+
chatWithCanary,
|
|
258
|
+
readChat,
|
|
259
|
+
chatStreamReader,
|
|
260
|
+
),
|
|
261
|
+
patchOpenAiResponses: (Responses) =>
|
|
262
|
+
patchOpenAiCreate(
|
|
263
|
+
Responses?.prototype,
|
|
264
|
+
responsesWithCanary,
|
|
265
|
+
readResponse,
|
|
266
|
+
responsesStreamReader,
|
|
267
|
+
),
|
|
268
|
+
patchGenAi,
|
|
269
|
+
};
|
|
270
|
+
}
|
package/hook/hook.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// The hooks for Node apps, loaded with `node --import modelos-secure/hook`. No dependencies,
|
|
2
|
+
// and written for Node 20: customers run this, not us. Outside a review it does nothing.
|
|
3
|
+
// docs/spec/05-scan-and-cli.md#5-start
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
// A default import, because a named one for registerHooks would not even link on Node 20.
|
|
7
|
+
import nodeModule from "node:module";
|
|
8
|
+
import { pathToFileURL, URL } from "node:url";
|
|
9
|
+
import { isMainThread } from "node:worker_threads";
|
|
10
|
+
import { clientPatches } from "./clients.mjs";
|
|
11
|
+
import { rewriteLoaded, rewriteSource, urlToLoad } from "./rewrite.mjs";
|
|
12
|
+
|
|
13
|
+
const raw = process.env.MODELOS_SECURE_HOOK;
|
|
14
|
+
|
|
15
|
+
// The loader thread on Node 20 can load this file too, and must not start a second control port.
|
|
16
|
+
if (raw && isMainThread) {
|
|
17
|
+
const config = JSON.parse(raw);
|
|
18
|
+
const toolsByName = new Map(config.tools.map((tool) => [tool.name, tool]));
|
|
19
|
+
const waysIn = new Map(
|
|
20
|
+
config.waysIn.filter((w) => w.function).map((w) => [w.function, w]),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
let state = freshState();
|
|
24
|
+
function freshState() {
|
|
25
|
+
return {
|
|
26
|
+
attempt: null,
|
|
27
|
+
held: [],
|
|
28
|
+
modelCalls: [],
|
|
29
|
+
ran: [],
|
|
30
|
+
plantDelivered: false,
|
|
31
|
+
retrieved: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function argsObject(name, args) {
|
|
36
|
+
const [first] = args;
|
|
37
|
+
if (
|
|
38
|
+
args.length === 1 &&
|
|
39
|
+
first &&
|
|
40
|
+
typeof first === "object" &&
|
|
41
|
+
!Array.isArray(first)
|
|
42
|
+
)
|
|
43
|
+
return { ...first };
|
|
44
|
+
if (
|
|
45
|
+
args.length >= 1 &&
|
|
46
|
+
first &&
|
|
47
|
+
typeof first === "object" &&
|
|
48
|
+
typeof args[1] === "object" &&
|
|
49
|
+
"toolCallId" in (args[1] ?? {})
|
|
50
|
+
)
|
|
51
|
+
return { ...first };
|
|
52
|
+
const params = toolsByName.get(name)?.params ?? [];
|
|
53
|
+
return Object.fromEntries(
|
|
54
|
+
args
|
|
55
|
+
.filter((a) => typeof a !== "function")
|
|
56
|
+
.map((a, i) => [params[i] ?? `arg${i}`, a]),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A realistic answer, so the app carries on as if the action happened.
|
|
61
|
+
function heldResult(name) {
|
|
62
|
+
return {
|
|
63
|
+
status: "ok",
|
|
64
|
+
id: `${name}-${Math.floor(Math.random() * 90000) + 10000}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function longestStringKey(object) {
|
|
69
|
+
let best;
|
|
70
|
+
for (const [key, value] of Object.entries(object)) {
|
|
71
|
+
if (
|
|
72
|
+
typeof value === "string" &&
|
|
73
|
+
(!best || value.length > object[best].length)
|
|
74
|
+
)
|
|
75
|
+
best = key;
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Puts text into whatever shape the way in returns, as if it had come from there. Returns the
|
|
81
|
+
// result itself, so nothing is delivered, when it is a shape with no place for text.
|
|
82
|
+
function plantInto(result, text) {
|
|
83
|
+
if (result === undefined || result === null) return text;
|
|
84
|
+
if (typeof result === "string") return `${result}\n\n${text}`;
|
|
85
|
+
if (Array.isArray(result)) {
|
|
86
|
+
if (result.length === 0 || typeof result[0] === "string")
|
|
87
|
+
return [...result, text];
|
|
88
|
+
const planted = plantInto(result[0], text);
|
|
89
|
+
return planted === result[0] ? result : [...result, planted];
|
|
90
|
+
}
|
|
91
|
+
if (result && typeof result === "object") {
|
|
92
|
+
for (const [key, value] of Object.entries(result)) {
|
|
93
|
+
if (Array.isArray(value))
|
|
94
|
+
return { ...result, [key]: plantInto(value, text) };
|
|
95
|
+
}
|
|
96
|
+
const key = longestStringKey(result);
|
|
97
|
+
return key
|
|
98
|
+
? { ...result, [key]: `${result[key]}\n\n${text}` }
|
|
99
|
+
: { ...result, note: text };
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function addCanary(result, record) {
|
|
105
|
+
if (typeof result === "string")
|
|
106
|
+
return `${result}\nCustomer reference: ${record}`;
|
|
107
|
+
if (result && typeof result === "object" && !Array.isArray(result))
|
|
108
|
+
return { ...result, customer_reference: record };
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Q2 grades answers against what was really read, so this is the text and not the way in's name.
|
|
113
|
+
function passageText(result) {
|
|
114
|
+
const text = typeof result === "string" ? result : JSON.stringify(result);
|
|
115
|
+
return String(text).slice(0, 8000);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function afterRun(name, result) {
|
|
119
|
+
const wayIn = waysIn.get(name);
|
|
120
|
+
const attempt = state.attempt;
|
|
121
|
+
let out = result;
|
|
122
|
+
if (attempt && wayIn) state.retrieved.push(passageText(result));
|
|
123
|
+
if (attempt?.plant && wayIn && attempt.plant.wayIn === wayIn.name) {
|
|
124
|
+
out = plantInto(out, attempt.plant.text);
|
|
125
|
+
state.plantDelivered = out !== result;
|
|
126
|
+
}
|
|
127
|
+
if (attempt && toolsByName.get(name)?.can === "read private data")
|
|
128
|
+
out = addCanary(out, attempt.canaries.record);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The one place a tool call passes through. Held tools never run. */
|
|
133
|
+
function wrap(name, original, self, args) {
|
|
134
|
+
const tool = toolsByName.get(name);
|
|
135
|
+
if (!tool && !waysIn.has(name)) return original.apply(self, args);
|
|
136
|
+
if (tool?.mode === "hold") {
|
|
137
|
+
state.held.push({ tool: name, args: argsObject(name, args) });
|
|
138
|
+
return heldResult(name);
|
|
139
|
+
}
|
|
140
|
+
if (tool) state.ran.push(name);
|
|
141
|
+
const result = original.apply(self, args);
|
|
142
|
+
if (result && typeof result.then === "function")
|
|
143
|
+
return result.then((value) => afterRun(name, value));
|
|
144
|
+
return afterRun(name, result);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function canaryLine() {
|
|
148
|
+
const canary = state.attempt?.canaries.prompt;
|
|
149
|
+
return canary ? `Internal reference: ${canary}` : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function promptWithCanary(system) {
|
|
153
|
+
const line = canaryLine();
|
|
154
|
+
if (line && typeof system === "string") return `${system}\n${line}`;
|
|
155
|
+
return system;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function seeModelCall(model, toolCalls, usage) {
|
|
159
|
+
state.modelCalls.push({
|
|
160
|
+
model,
|
|
161
|
+
toolCalls,
|
|
162
|
+
inputTokens: usage?.inputTokens ?? 0,
|
|
163
|
+
outputTokens: usage?.outputTokens ?? 0,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The Vercel AI SDK: tools carry their own execute, and steps say what the model asked for.
|
|
168
|
+
function wrapAiFunction(original) {
|
|
169
|
+
return function modelosSecureAi(options) {
|
|
170
|
+
const tools = Object.fromEntries(
|
|
171
|
+
Object.entries(options.tools ?? {}).map(([name, definition]) => {
|
|
172
|
+
if (!toolsByName.has(name) || !definition.execute)
|
|
173
|
+
return [name, definition];
|
|
174
|
+
return [
|
|
175
|
+
name,
|
|
176
|
+
{
|
|
177
|
+
...definition,
|
|
178
|
+
execute: (...args) =>
|
|
179
|
+
wrap(name, definition.execute, definition, args),
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
const result = original({
|
|
185
|
+
...options,
|
|
186
|
+
tools,
|
|
187
|
+
system: promptWithCanary(options.system),
|
|
188
|
+
});
|
|
189
|
+
if (result && typeof result.then === "function") {
|
|
190
|
+
return result.then((value) => {
|
|
191
|
+
for (const step of value.steps ?? []) {
|
|
192
|
+
const calls = (step.toolCalls ?? []).map((c) => ({
|
|
193
|
+
tool: c.toolName,
|
|
194
|
+
args: c.input ?? c.args ?? {},
|
|
195
|
+
}));
|
|
196
|
+
seeModelCall(options.model?.modelId, calls, step.usage);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
globalThis[Symbol.for("modelos-secure.hook")] = {
|
|
206
|
+
wrap,
|
|
207
|
+
wrapAiFunction,
|
|
208
|
+
seeModelCall,
|
|
209
|
+
state: () => state,
|
|
210
|
+
...clientPatches({ canaryLine, seeModelCall }),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Source rewriting, so nothing in the app's code changes.
|
|
214
|
+
const options = {
|
|
215
|
+
held: config.tools
|
|
216
|
+
.map((t) => t.name)
|
|
217
|
+
.concat(config.waysIn.map((w) => w.function).filter(Boolean)),
|
|
218
|
+
appFiles: config.files ?? [],
|
|
219
|
+
};
|
|
220
|
+
if (nodeModule.registerHooks) {
|
|
221
|
+
nodeModule.registerHooks({
|
|
222
|
+
load: (url, context, nextLoad) =>
|
|
223
|
+
rewriteLoaded(url, nextLoad(urlToLoad(url), context), options),
|
|
224
|
+
});
|
|
225
|
+
} else {
|
|
226
|
+
// Node 20: the same rewrite, through an off-thread loader for import, which never sees
|
|
227
|
+
// require, and through the compile step for require. Without that, a CommonJS tool would run.
|
|
228
|
+
nodeModule.register(new URL("./loader.mjs", import.meta.url).href, {
|
|
229
|
+
data: options,
|
|
230
|
+
});
|
|
231
|
+
const compile = nodeModule.prototype._compile;
|
|
232
|
+
nodeModule.prototype._compile = function (source, filename, ...rest) {
|
|
233
|
+
const url = pathToFileURL(filename).href;
|
|
234
|
+
const changed = rewriteSource(url, source, options);
|
|
235
|
+
return compile.call(this, changed, filename, ...rest);
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The control port the runner talks to. Loopback only, and it dies with the app.
|
|
240
|
+
function startControl() {
|
|
241
|
+
const server = createServer(async (request, response) => {
|
|
242
|
+
const chunks = [];
|
|
243
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
244
|
+
const send = (body) => {
|
|
245
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
246
|
+
response.end(JSON.stringify(body));
|
|
247
|
+
};
|
|
248
|
+
if (request.url === "/begin") {
|
|
249
|
+
state = freshState();
|
|
250
|
+
state.attempt = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
251
|
+
return send({ ok: true });
|
|
252
|
+
}
|
|
253
|
+
if (request.url === "/end") {
|
|
254
|
+
const {
|
|
255
|
+
held: heldCalls,
|
|
256
|
+
modelCalls,
|
|
257
|
+
ran,
|
|
258
|
+
plantDelivered,
|
|
259
|
+
retrieved,
|
|
260
|
+
} = state;
|
|
261
|
+
state = freshState();
|
|
262
|
+
return send({
|
|
263
|
+
heldCalls,
|
|
264
|
+
modelCalls,
|
|
265
|
+
ranTools: ran,
|
|
266
|
+
plantDelivered,
|
|
267
|
+
retrieved,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (request.url === "/hello") return send({ ok: true, hook: "node" });
|
|
271
|
+
response.writeHead(404);
|
|
272
|
+
response.end();
|
|
273
|
+
});
|
|
274
|
+
server.listen(0, "127.0.0.1", () => {
|
|
275
|
+
const { port } = server.address();
|
|
276
|
+
server.unref();
|
|
277
|
+
fetch(`${process.env.MODELOS_SECURE_RUNNER_URL}/ready`, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: { "content-type": "application/json" },
|
|
280
|
+
body: JSON.stringify({
|
|
281
|
+
instance: process.env.MODELOS_SECURE_INSTANCE,
|
|
282
|
+
port,
|
|
283
|
+
}),
|
|
284
|
+
}).catch(() => undefined);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Only the process that takes the app's port is the app. NODE_OPTIONS reaches every Node process
|
|
289
|
+
// the app starts, and npm starting the app is one too: see
|
|
290
|
+
// docs/journal/adr/0020-announce-on-the-app-port.md
|
|
291
|
+
const appPort = Number(process.env.PORT);
|
|
292
|
+
if (appPort) {
|
|
293
|
+
const listen = net.Server.prototype.listen;
|
|
294
|
+
let claimed = false;
|
|
295
|
+
net.Server.prototype.listen = function (...args) {
|
|
296
|
+
const port = typeof args[0] === "object" ? args[0]?.port : args[0];
|
|
297
|
+
if (!claimed && Number(port) === appPort) {
|
|
298
|
+
claimed = true;
|
|
299
|
+
startControl();
|
|
300
|
+
}
|
|
301
|
+
return listen.apply(this, args);
|
|
302
|
+
};
|
|
303
|
+
} else {
|
|
304
|
+
startControl();
|
|
305
|
+
}
|
|
306
|
+
}
|
package/hook/loader.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The off-thread loader for Node versions without registerHooks, registered from hook.mjs.
|
|
2
|
+
import { rewriteLoaded, urlToLoad } from "./rewrite.mjs";
|
|
3
|
+
|
|
4
|
+
let options = { held: [], appFiles: [] };
|
|
5
|
+
|
|
6
|
+
export function initialize(data) {
|
|
7
|
+
options = data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function load(url, context, nextLoad) {
|
|
11
|
+
return rewriteLoaded(url, await nextLoad(urlToLoad(url), context), options);
|
|
12
|
+
}
|
package/hook/rewrite.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Source rewriting, shared by both ways of loading the hooks: registerHooks on the main thread,
|
|
2
|
+
// and the Node 20 off-thread loader. It runs in either place, so it holds no state of its own and
|
|
3
|
+
// what it writes only reaches the hook through a global, on the thread that runs the module.
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const HOOK = `globalThis[Symbol.for("modelos-secure.hook")]`;
|
|
7
|
+
const MARK = "?modelos-secure-original";
|
|
8
|
+
const PACKAGE = `/node_modules/(?:\\.pnpm/[^/]+/node_modules/)?`;
|
|
9
|
+
|
|
10
|
+
function shimAi(url) {
|
|
11
|
+
const real = `${url}${MARK}`;
|
|
12
|
+
return [
|
|
13
|
+
`import * as real from ${JSON.stringify(real)};`,
|
|
14
|
+
`export * from ${JSON.stringify(real)};`,
|
|
15
|
+
`const hook = ${HOOK};`,
|
|
16
|
+
`export const generateText = hook.wrapAiFunction(real.generateText);`,
|
|
17
|
+
`export const streamText = hook.wrapAiFunction(real.streamText);`,
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SHIMS = [[new RegExp(`${PACKAGE}ai/dist/index\\.js$`), shimAi]];
|
|
22
|
+
|
|
23
|
+
// The plain clients keep their exports exactly as they are: one line at the end patches the class.
|
|
24
|
+
const PATCHES = [
|
|
25
|
+
[
|
|
26
|
+
new RegExp(
|
|
27
|
+
`${PACKAGE}openai/resources/chat/completions/completions\\.m?js$`,
|
|
28
|
+
),
|
|
29
|
+
"patchOpenAiChat(Completions)",
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
new RegExp(`${PACKAGE}openai/resources/responses/responses\\.m?js$`),
|
|
33
|
+
"patchOpenAiResponses(Responses)",
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
new RegExp(`${PACKAGE}@google/genai/dist/(?:node/)?index\\.(?:mjs|cjs)$`),
|
|
37
|
+
"patchGenAi(Models)",
|
|
38
|
+
],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Plain tool functions become a wrapper around the original, under the same name. */
|
|
42
|
+
function rewriteFunctions(source, names) {
|
|
43
|
+
let out = source;
|
|
44
|
+
for (const name of new Set(names)) {
|
|
45
|
+
const inner = `__modelos_secure_${name}`;
|
|
46
|
+
const call = (self) =>
|
|
47
|
+
`${HOOK}.wrap(${JSON.stringify(name)}, ${inner}, ${self}, a)`;
|
|
48
|
+
// function name( and async function name<T>(
|
|
49
|
+
out = out.replace(
|
|
50
|
+
new RegExp(
|
|
51
|
+
`(^|\\n)(\\s*)(export\\s+)?(async\\s+)?function\\s+${name}\\s*(?=[(<])`,
|
|
52
|
+
"g",
|
|
53
|
+
),
|
|
54
|
+
(_, start, indent, exported, isAsync) =>
|
|
55
|
+
`${start}${indent}${exported ?? ""}function ${name}(...a) { return ${call("this")}; }\n` +
|
|
56
|
+
`${indent}${isAsync ?? ""}function ${inner}`,
|
|
57
|
+
);
|
|
58
|
+
// const name = async (...) => and const name: Type = function (...), but not const name = (x)
|
|
59
|
+
out = out.replace(
|
|
60
|
+
new RegExp(
|
|
61
|
+
`(^|\\n)(\\s*)(export\\s+)?(?:const|let|var)\\s+${name}\\s*(:[^=\\n]+)?=\\s*(?=(?:async\\s+)?(?:function\\b|\\w+\\s*=>|<|\\((?:[^()]|\\([^()]*\\))*\\)\\s*(?::[^=]+)?=>))`,
|
|
62
|
+
"g",
|
|
63
|
+
),
|
|
64
|
+
(_, start, indent, exported, type) =>
|
|
65
|
+
`${start}${indent}${exported ?? ""}const ${name} = (...a) => ${call("undefined")};\n` +
|
|
66
|
+
`${indent}const ${inner}${type ?? ""} = `,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The URL to really load: the shims import the module they stand in for under a marked URL. */
|
|
73
|
+
export const urlToLoad = (url) => url.replace(MARK, "");
|
|
74
|
+
|
|
75
|
+
/** The source to compile: changed for the files that matter, and otherwise exactly as it came. */
|
|
76
|
+
export function rewriteSource(url, source, options) {
|
|
77
|
+
for (const [pattern, patch] of PATCHES) {
|
|
78
|
+
if (pattern.test(url)) return `${String(source)}\n;${HOOK}.${patch};\n`;
|
|
79
|
+
}
|
|
80
|
+
if (url.startsWith("file:") && options.appFiles.includes(fileURLToPath(url)))
|
|
81
|
+
return rewriteFunctions(String(source), options.held);
|
|
82
|
+
return source;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What should load in place of what Node found. options: { held: string[], appFiles: string[] } */
|
|
86
|
+
export function rewriteLoaded(url, loaded, options) {
|
|
87
|
+
if (url.includes(MARK)) return loaded;
|
|
88
|
+
for (const [pattern, shim] of SHIMS) {
|
|
89
|
+
if (pattern.test(url))
|
|
90
|
+
return { ...loaded, source: shim(url), format: "module" };
|
|
91
|
+
}
|
|
92
|
+
if (!loaded.source) return loaded;
|
|
93
|
+
const source = rewriteSource(url, loaded.source, options);
|
|
94
|
+
return source === loaded.source ? loaded : { ...loaded, source };
|
|
95
|
+
}
|