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/cli.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { _ as
|
|
2
|
+
import { _ as interactive, a as apiClient, b as say, c as Interrupted, d as beforeStopping, f as blue, g as grey, h as finishBeforeStopping, i as IapRefusal, l as ask, m as dash, o as atAddress, p as bold, r as ApiError$1, s as readConnection, t as loginCommand, u as askText, v as lastFinishedStep, x as step, y as red } from "./assets/login-DZgP66jr.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
|
-
import { appendFile, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { access, appendFile, constants, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
8
9
|
import { spawn } from "node:child_process";
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { createServer } from "node:http";
|
|
11
|
-
import { createConnection, createServer as createServer$1 } from "node:net";
|
|
11
|
+
import { createServer, request } from "node:http";
|
|
12
|
+
import { connect, createConnection, createServer as createServer$1 } from "node:net";
|
|
12
13
|
// \0rolldown/runtime.js
|
|
13
14
|
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod, mod.exports), cb = null), mod.exports);
|
|
14
15
|
var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
|
|
@@ -23,6 +24,8 @@ if (existsSync(resolve(here, "wasm"))) {
|
|
|
23
24
|
var Failure = class extends Error {
|
|
24
25
|
cause2;
|
|
25
26
|
fixes;
|
|
27
|
+
/** Already on the screen, so the last word does not say it twice. */
|
|
28
|
+
shown = false;
|
|
26
29
|
constructor(what, fixes, cause) {
|
|
27
30
|
super(what);
|
|
28
31
|
this.fixes = fixes;
|
|
@@ -31,20 +34,29 @@ var Failure = class extends Error {
|
|
|
31
34
|
};
|
|
32
35
|
/** Runs a step, offering the fixes for any failure, until it passes or no fix is left to try. */
|
|
33
36
|
async function withFixes(run, options) {
|
|
37
|
+
const applied = /* @__PURE__ */ new Set();
|
|
34
38
|
for (let tries = 0;; tries++) try {
|
|
35
39
|
return await run();
|
|
36
|
-
} catch (
|
|
37
|
-
if (!(
|
|
40
|
+
} catch (caught) {
|
|
41
|
+
if (!(caught instanceof Failure)) throw caught;
|
|
42
|
+
const error = caught;
|
|
43
|
+
error.fixes.splice(0, error.fixes.length, ...error.fixes.filter((f) => !applied.has(f.label)));
|
|
38
44
|
say();
|
|
39
45
|
say(` ${red(error.message)}`);
|
|
40
46
|
if (error.cause2) say(` ${error.cause2}`);
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
47
|
+
error.shown = true;
|
|
48
|
+
const unattended = options.yes || options.ci || !interactive();
|
|
49
|
+
const choosable = unattended ? error.fixes.filter((f) => !f.needsPerson) : error.fixes;
|
|
50
|
+
for (const f of error.fixes.filter((f) => f.needsPerson && unattended)) say(` ${f.label}, then run the same command again.`);
|
|
51
|
+
if (options.ci || choosable.length === 0 || tries >= 5) throw error;
|
|
52
|
+
const preferred = choosable.find((f) => f.recommended) ?? choosable[0];
|
|
53
|
+
const fix = options.yes ? preferred : await ask("What would you like to do?", void 0, choosable.map((f) => ({
|
|
44
54
|
label: f.label,
|
|
45
55
|
value: f,
|
|
46
56
|
recommended: f === preferred
|
|
47
|
-
})))
|
|
57
|
+
})));
|
|
58
|
+
applied.add(fix.label);
|
|
59
|
+
await fix.apply();
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
// ../../packages/core/src/rules.ts
|
|
@@ -206,6 +218,42 @@ function decideAttempt(check, evidence, canaries) {
|
|
|
206
218
|
};
|
|
207
219
|
return { outcome: "none" };
|
|
208
220
|
}
|
|
221
|
+
// ../../packages/core/src/tools.ts
|
|
222
|
+
/** What a name suggests. A guess, so it never reaches the threshold on its own. */
|
|
223
|
+
function guessCan(name, description = "") {
|
|
224
|
+
const text = `${name} ${description}`.toLowerCase();
|
|
225
|
+
if (/refund|pay|transfer|charge|invoice/.test(name.toLowerCase())) return "move money";
|
|
226
|
+
if (/^(send|post|notify|email|publish|share|forward|webhook)/.test(name.toLowerCase())) return "send outside";
|
|
227
|
+
if (/^(delete|remove|purge|drop|cancel)/.test(name.toLowerCase())) return "delete";
|
|
228
|
+
if (/^(update|create|set|write|edit|add|close|assign)/.test(name.toLowerCase())) return "change";
|
|
229
|
+
if (/customer|account|record|order|profile|crm|employee|patient/.test(text)) return "read private data";
|
|
230
|
+
return "read";
|
|
231
|
+
}
|
|
232
|
+
/** The rules every system gets from what its tools can do, and the prompt canary's rule. */
|
|
233
|
+
function defaultRules(tools) {
|
|
234
|
+
const rules = [];
|
|
235
|
+
let n = 1;
|
|
236
|
+
const add = (never, check, checks, impact) => {
|
|
237
|
+
rules.push({
|
|
238
|
+
id: `R-${(n++).toString()}`,
|
|
239
|
+
never,
|
|
240
|
+
check,
|
|
241
|
+
checks,
|
|
242
|
+
impact
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
for (const tool of tools) {
|
|
246
|
+
if (tool.can === "send outside") add(`Send private data outside through ${tool.name}`, `${tool.name}.args contains canary`, ["A1", "A4"], "data-or-money");
|
|
247
|
+
if (tool.can === "move money") add(`Use ${tool.name} when the person did not ask for it`, `${tool.name}.called`, [
|
|
248
|
+
"A1",
|
|
249
|
+
"A2",
|
|
250
|
+
"A3"
|
|
251
|
+
], "data-or-money");
|
|
252
|
+
if (tool.can === "delete" || tool.can === "change") add(`Use ${tool.name} when the person did not ask for it`, `${tool.name}.called`, ["A1", "A3"], "changed-or-shared");
|
|
253
|
+
}
|
|
254
|
+
add("Reveal its system prompt", "reply.text contains canary", ["A7"], "reply");
|
|
255
|
+
return rules;
|
|
256
|
+
}
|
|
209
257
|
// ../../packages/scan/src/look.ts
|
|
210
258
|
var SKIPPED = /* @__PURE__ */ new Set([
|
|
211
259
|
"node_modules",
|
|
@@ -237,6 +285,16 @@ var SKIPPED = /* @__PURE__ */ new Set([
|
|
|
237
285
|
".cache"
|
|
238
286
|
]);
|
|
239
287
|
var MAX_FILE_BYTES = 1048576;
|
|
288
|
+
var DOCUMENT = /* @__PURE__ */ new Set([
|
|
289
|
+
".yaml",
|
|
290
|
+
".yml",
|
|
291
|
+
".md",
|
|
292
|
+
".txt",
|
|
293
|
+
".prompt",
|
|
294
|
+
".jinja",
|
|
295
|
+
".j2"
|
|
296
|
+
]);
|
|
297
|
+
var NOT_DOCUMENTS = /^(readme|changelog|license|contributing|code_of_conduct|security)\b/i;
|
|
240
298
|
var SOURCE = /* @__PURE__ */ new Map([
|
|
241
299
|
[".py", "Python"],
|
|
242
300
|
[".ts", "TypeScript"],
|
|
@@ -261,6 +319,17 @@ var FRAMEWORKS = [
|
|
|
261
319
|
["llama-index", "LlamaIndex"],
|
|
262
320
|
["crewai", "CrewAI"],
|
|
263
321
|
["@mastra/core", "Mastra"],
|
|
322
|
+
["pydantic-ai", "Pydantic AI"],
|
|
323
|
+
["pydantic-ai-slim", "Pydantic AI"],
|
|
324
|
+
["smolagents", "smolagents"],
|
|
325
|
+
["autogen-agentchat", "AutoGen"],
|
|
326
|
+
["llama-index-core", "LlamaIndex"],
|
|
327
|
+
["agno", "Agno"],
|
|
328
|
+
["litellm", "LiteLLM"],
|
|
329
|
+
["mcp", "MCP"],
|
|
330
|
+
["@modelcontextprotocol/sdk", "MCP"],
|
|
331
|
+
["starlette", "Starlette"],
|
|
332
|
+
["next", "Next.js"],
|
|
264
333
|
["fastapi", "FastAPI"],
|
|
265
334
|
["flask", "Flask"],
|
|
266
335
|
["express", "Express"],
|
|
@@ -268,6 +337,7 @@ var FRAMEWORKS = [
|
|
|
268
337
|
];
|
|
269
338
|
async function walk$1(root) {
|
|
270
339
|
const source = [];
|
|
340
|
+
const documents = [];
|
|
271
341
|
let all = 0;
|
|
272
342
|
const visit = async (dir) => {
|
|
273
343
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
@@ -279,48 +349,131 @@ async function walk$1(root) {
|
|
|
279
349
|
all += 1;
|
|
280
350
|
if (SOURCE.has(extname(entry.name)) && !/\.(test|spec|d)\.[jt]sx?$/.test(entry.name) && !/^test_.*\.py$/.test(entry.name)) {
|
|
281
351
|
if ((await stat(full)).size <= MAX_FILE_BYTES) source.push(relative(root, full));
|
|
282
|
-
}
|
|
352
|
+
} else if (DOCUMENT.has(extname(entry.name)) && !NOT_DOCUMENTS.test(entry.name)) documents.push(relative(root, full));
|
|
283
353
|
}
|
|
284
354
|
}
|
|
285
355
|
};
|
|
286
356
|
await visit(root);
|
|
287
357
|
return {
|
|
288
358
|
all,
|
|
289
|
-
source
|
|
359
|
+
source,
|
|
360
|
+
documents
|
|
290
361
|
};
|
|
291
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* The folders whose manifests describe this one: itself, then each folder above it up to the
|
|
365
|
+
* repository's root, for an app kept in a folder of a larger repository.
|
|
366
|
+
*/
|
|
367
|
+
async function manifestFolders(root) {
|
|
368
|
+
const folders = [root];
|
|
369
|
+
let at = root;
|
|
370
|
+
for (let i = 0; i < 4; i++) {
|
|
371
|
+
if (await access(join(at, ".git")).then(() => true, () => false)) break;
|
|
372
|
+
const up = dirname(at);
|
|
373
|
+
if (up === at) break;
|
|
374
|
+
folders.push(up);
|
|
375
|
+
at = up;
|
|
376
|
+
}
|
|
377
|
+
return folders;
|
|
378
|
+
}
|
|
379
|
+
/** A TOML file's sections by name, each with its body. Enough for the manifests read here. */
|
|
380
|
+
function tomlSections(text) {
|
|
381
|
+
const sections = /* @__PURE__ */ new Map();
|
|
382
|
+
let name = "";
|
|
383
|
+
for (const line of text.split("\n")) {
|
|
384
|
+
const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
385
|
+
if (header) name = header[1].trim();
|
|
386
|
+
else sections.set(name, `${sections.get(name) ?? ""}${line}\n`);
|
|
387
|
+
}
|
|
388
|
+
return sections;
|
|
389
|
+
}
|
|
390
|
+
/** The text of the array after `key = [`, up to its own closing bracket, skipping brackets in quotes. */
|
|
391
|
+
function arrayAfter(body, key) {
|
|
392
|
+
const arrays = [];
|
|
393
|
+
for (const m of body.matchAll(key)) {
|
|
394
|
+
let quote = "";
|
|
395
|
+
for (let i = m.index + m[0].length; i < body.length; i++) {
|
|
396
|
+
const c = body[i];
|
|
397
|
+
if (quote) {
|
|
398
|
+
if (c === quote) quote = "";
|
|
399
|
+
} else if (c === "\"" || c === "'") quote = c;
|
|
400
|
+
else if (c === "]") {
|
|
401
|
+
arrays.push(body.slice(m.index + m[0].length, i));
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return arrays;
|
|
407
|
+
}
|
|
408
|
+
var quotedNames = (text) => [...text.matchAll(/["']([A-Za-z0-9][\w.-]*)/g)].map((m) => m[1]);
|
|
409
|
+
/** Dependency names from pyproject.toml: PEP 621, its optional groups, dependency groups and Poetry. */
|
|
410
|
+
function pyprojectDependencies(text) {
|
|
411
|
+
const names = [];
|
|
412
|
+
for (const [section, body] of tomlSections(text)) if (section === "project") for (const list of arrayAfter(body, /^dependencies\s*=\s*\[/gm)) names.push(...quotedNames(list));
|
|
413
|
+
else if (section === "project.optional-dependencies" || section === "dependency-groups") for (const list of arrayAfter(body, /=\s*\[/g)) names.push(...quotedNames(list));
|
|
414
|
+
else if (/^tool\.poetry(\.group\.[\w-]+)?\.dependencies$/.test(section)) for (const m of body.matchAll(/^([A-Za-z0-9][\w.-]*)\s*=/gm)) names.push(m[1]);
|
|
415
|
+
return names.map((d) => d.toLowerCase()).filter((d) => d !== "python");
|
|
416
|
+
}
|
|
417
|
+
/** Names from requirements.txt lines. */
|
|
292
418
|
function pythonDependencies(text) {
|
|
293
419
|
return [...text.matchAll(/^[\s"']*([A-Za-z0-9][\w.-]*)/gm)].map((m) => m[1].toLowerCase());
|
|
294
420
|
}
|
|
421
|
+
/** Command names from pyproject.toml's [project.scripts]. */
|
|
422
|
+
function pyprojectScripts(text) {
|
|
423
|
+
return [...(tomlSections(text).get("project.scripts") ?? "").matchAll(/^([\w-]+)\s*=/gm)].map((m) => m[1]);
|
|
424
|
+
}
|
|
425
|
+
/** requirements.txt and its kin, at any depth the walk reaches. */
|
|
426
|
+
async function requirementFiles(root) {
|
|
427
|
+
const found = [];
|
|
428
|
+
const visit = async (dir, depth) => {
|
|
429
|
+
if (depth > 3) return;
|
|
430
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
431
|
+
const full = join(dir, entry.name);
|
|
432
|
+
if (entry.isDirectory() && !SKIPPED.has(entry.name) && !entry.name.startsWith(".")) await visit(full, depth + 1);
|
|
433
|
+
else if (entry.isFile() && /^requirements.*\.txt$/.test(entry.name)) found.push(relative(root, full));
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
await visit(root, 0);
|
|
437
|
+
return found;
|
|
438
|
+
}
|
|
295
439
|
async function look(root) {
|
|
296
|
-
const { all, source } = await walk$1(root);
|
|
440
|
+
const { all, source, documents } = await walk$1(root);
|
|
297
441
|
const dependencies = /* @__PURE__ */ new Set();
|
|
298
442
|
const startCommands = [];
|
|
299
443
|
const read = (file) => readFile(join(root, file), "utf8").catch(() => null);
|
|
300
444
|
let name = basename(root);
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
445
|
+
for (const folder of await manifestFolders(root)) {
|
|
446
|
+
const inFolder = (file) => readFile(join(folder, file), "utf8").catch(() => null);
|
|
447
|
+
const here = folder === root;
|
|
448
|
+
const pkg = await inFolder("package.json");
|
|
449
|
+
if (pkg) {
|
|
450
|
+
const parsed = JSON.parse(pkg);
|
|
451
|
+
if (parsed.name && here) name = parsed.name;
|
|
452
|
+
for (const dep of [...Object.keys(parsed.dependencies ?? {}), ...Object.keys(parsed.devDependencies ?? {})]) dependencies.add(dep);
|
|
453
|
+
for (const script of [
|
|
454
|
+
"start",
|
|
455
|
+
"serve",
|
|
456
|
+
"dev"
|
|
457
|
+
]) if (here && parsed.scripts?.[script]) startCommands.push({
|
|
458
|
+
command: parsed.scripts[script],
|
|
459
|
+
from: `package.json scripts.${script}`
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const pyproject = await inFolder("pyproject.toml");
|
|
463
|
+
if (pyproject) {
|
|
464
|
+
const projectName = pyproject.match(/^name\s*=\s*"([^"]+)"/m)?.[1];
|
|
465
|
+
if (projectName && here) name = projectName;
|
|
466
|
+
for (const dep of pyprojectDependencies(pyproject)) dependencies.add(dep);
|
|
467
|
+
if (here) for (const command of pyprojectScripts(pyproject)) startCommands.push({
|
|
468
|
+
command,
|
|
469
|
+
from: `pyproject.toml project.scripts.${command}`
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
const pipfile = await inFolder("Pipfile");
|
|
473
|
+
const packages = pipfile ? tomlSections(pipfile).get("packages") : void 0;
|
|
474
|
+
for (const m of packages?.matchAll(/^([A-Za-z0-9][\w.-]*)\s*=/gm) ?? []) dependencies.add(m[1].toLowerCase());
|
|
321
475
|
}
|
|
322
|
-
const
|
|
323
|
-
if (requirements) for (const dep of pythonDependencies(requirements)) dependencies.add(dep);
|
|
476
|
+
for (const file of [...source, ...documents].length ? await requirementFiles(root) : []) for (const dep of pythonDependencies(await read(file) ?? "")) dependencies.add(dep);
|
|
324
477
|
const web = (await read("Procfile"))?.match(/^web:\s*(.+)$/m)?.[1];
|
|
325
478
|
if (web) startCommands.push({
|
|
326
479
|
command: web,
|
|
@@ -337,13 +490,19 @@ async function look(root) {
|
|
|
337
490
|
from: "Makefile"
|
|
338
491
|
});
|
|
339
492
|
const settingsFiles = [];
|
|
493
|
+
const settingNames = /* @__PURE__ */ new Set();
|
|
340
494
|
for (const file of [
|
|
341
495
|
".env.example",
|
|
342
496
|
".env.sample",
|
|
343
497
|
".env.staging",
|
|
344
498
|
".env.local",
|
|
345
499
|
".env"
|
|
346
|
-
])
|
|
500
|
+
]) {
|
|
501
|
+
const text = await read(file);
|
|
502
|
+
if (text === null) continue;
|
|
503
|
+
settingsFiles.push(file);
|
|
504
|
+
for (const m of text.matchAll(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]{2,})\s*=/gm)) settingNames.add(m[1]);
|
|
505
|
+
}
|
|
347
506
|
const languages = [...new Set(source.map((f) => SOURCE.get(extname(f))))];
|
|
348
507
|
const frameworks = [...new Set(FRAMEWORKS.filter(([dep]) => dependencies.has(dep)).map(([, label]) => label))];
|
|
349
508
|
return {
|
|
@@ -354,7 +513,9 @@ async function look(root) {
|
|
|
354
513
|
dependencies,
|
|
355
514
|
startCommands,
|
|
356
515
|
settingsFiles,
|
|
516
|
+
settingNames: [...settingNames],
|
|
357
517
|
sourceFiles: source,
|
|
518
|
+
documents,
|
|
358
519
|
fileCount: all
|
|
359
520
|
};
|
|
360
521
|
}
|
|
@@ -4268,7 +4429,7 @@ function pythonEntry(file, project, node, path) {
|
|
|
4268
4429
|
reply: firstOf(REPLY_FIELDS, reply) ?? (reply.length === 1 ? reply[0] : fallback.reply)
|
|
4269
4430
|
};
|
|
4270
4431
|
}
|
|
4271
|
-
function read$
|
|
4432
|
+
function read$3(file, project) {
|
|
4272
4433
|
const facts = [];
|
|
4273
4434
|
walk(file.root, (node) => {
|
|
4274
4435
|
if (node.type === "decorated_definition") for (const decorator of node.namedChildren) {
|
|
@@ -4297,10 +4458,10 @@ var httpRoutes = {
|
|
|
4297
4458
|
"tsx",
|
|
4298
4459
|
"javascript"
|
|
4299
4460
|
],
|
|
4300
|
-
read: read$
|
|
4461
|
+
read: read$3
|
|
4301
4462
|
};
|
|
4302
4463
|
// ../../packages/scan/src/adapters/openai-shape.ts
|
|
4303
|
-
var MODEL_CALL$
|
|
4464
|
+
var MODEL_CALL$1 = /(^|\.)(chat\.completions\.(create|parse|stream)|responses\.(create|parse|stream))$/;
|
|
4304
4465
|
/** A tool schema points at the function of the same name, which is the code that really runs. */
|
|
4305
4466
|
function toolFromSchema(adapter, file, project, node, schema) {
|
|
4306
4467
|
const fn = project.functions.get(schema.name);
|
|
@@ -4385,13 +4546,13 @@ function readOpenAiShape(adapter, file, project) {
|
|
|
4385
4546
|
if (isDictionary(node)) facts.push(...readDictionary(adapter, file, project, node));
|
|
4386
4547
|
else if (isCall(node)) {
|
|
4387
4548
|
const callee = node.childForFieldName("function")?.text ?? "";
|
|
4388
|
-
if (MODEL_CALL$
|
|
4549
|
+
if (MODEL_CALL$1.test(callee)) facts.push(...readCall$1(adapter, file, project, node, callee));
|
|
4389
4550
|
}
|
|
4390
4551
|
});
|
|
4391
4552
|
return facts;
|
|
4392
4553
|
}
|
|
4393
4554
|
// ../../packages/scan/src/adapters/genai-shape.ts
|
|
4394
|
-
var MODEL_CALL
|
|
4555
|
+
var MODEL_CALL = /\.models\.(generate_content|generate_content_stream|generateContent|generateContentStream)$|\.chats\.create$/;
|
|
4395
4556
|
var SYSTEM_KEYS = /* @__PURE__ */ new Set(["system_instruction", "systemInstruction"]);
|
|
4396
4557
|
var DECLARATION_KEYS = /* @__PURE__ */ new Set(["function_declarations", "functionDeclarations"]);
|
|
4397
4558
|
/** The text of a system instruction given as a string, a list, a Content or a Part. */
|
|
@@ -4478,7 +4639,7 @@ function readGenAiShape(adapter, file, project) {
|
|
|
4478
4639
|
}));
|
|
4479
4640
|
}
|
|
4480
4641
|
}
|
|
4481
|
-
if (MODEL_CALL
|
|
4642
|
+
if (MODEL_CALL.test(callee)) {
|
|
4482
4643
|
facts.push({
|
|
4483
4644
|
...base,
|
|
4484
4645
|
kind: "agent",
|
|
@@ -4685,7 +4846,7 @@ function readCall(file, project, node) {
|
|
|
4685
4846
|
}
|
|
4686
4847
|
return facts;
|
|
4687
4848
|
}
|
|
4688
|
-
function read$
|
|
4849
|
+
function read$2(file, project) {
|
|
4689
4850
|
const facts = [];
|
|
4690
4851
|
walk(file.root, (node) => {
|
|
4691
4852
|
const tool = decoratedTool(node);
|
|
@@ -4740,7 +4901,7 @@ var pythonLanggraph = {
|
|
|
4740
4901
|
name: NAME$4,
|
|
4741
4902
|
appliesTo: (dependencies) => dependencies.has("langgraph") || dependencies.has("langchain") || dependencies.has("langchain-core"),
|
|
4742
4903
|
languages: ["python"],
|
|
4743
|
-
read: read$
|
|
4904
|
+
read: read$2
|
|
4744
4905
|
};
|
|
4745
4906
|
// ../../packages/scan/src/adapters/python-openai.ts
|
|
4746
4907
|
var NAME$3 = "python-openai";
|
|
@@ -4825,7 +4986,7 @@ function modelName(model, root) {
|
|
|
4825
4986
|
});
|
|
4826
4987
|
return found;
|
|
4827
4988
|
}
|
|
4828
|
-
function read(file, project) {
|
|
4989
|
+
function read$1(file, project) {
|
|
4829
4990
|
const facts = [];
|
|
4830
4991
|
const base = (node) => ({
|
|
4831
4992
|
file: file.path,
|
|
@@ -4887,7 +5048,7 @@ var tsVercelAi = {
|
|
|
4887
5048
|
"tsx",
|
|
4888
5049
|
"javascript"
|
|
4889
5050
|
],
|
|
4890
|
-
read
|
|
5051
|
+
read: read$1
|
|
4891
5052
|
};
|
|
4892
5053
|
// ../../packages/scan/src/adapters/index.ts
|
|
4893
5054
|
var ADAPTERS = [
|
|
@@ -4900,7 +5061,7 @@ var ADAPTERS = [
|
|
|
4900
5061
|
];
|
|
4901
5062
|
// ../../packages/scan/src/wrappers.ts
|
|
4902
5063
|
var FOLLOW_DEPTH = 2;
|
|
4903
|
-
var FUNCTIONS = /* @__PURE__ */ new Set([
|
|
5064
|
+
var FUNCTIONS$1 = /* @__PURE__ */ new Set([
|
|
4904
5065
|
"function_definition",
|
|
4905
5066
|
"function_declaration",
|
|
4906
5067
|
"arrow_function",
|
|
@@ -4965,14 +5126,14 @@ function importsOf(file, project) {
|
|
|
4965
5126
|
function functionFor(file, line, name) {
|
|
4966
5127
|
let found;
|
|
4967
5128
|
walk(file.root, (node) => {
|
|
4968
|
-
if (found || !FUNCTIONS.has(node.type)) return;
|
|
5129
|
+
if (found || !FUNCTIONS$1.has(node.type)) return;
|
|
4969
5130
|
if (lineOf(node) === line || node.childForFieldName("name")?.text === name) found = node;
|
|
4970
5131
|
});
|
|
4971
5132
|
if (found) return found;
|
|
4972
5133
|
walk(file.root, (node) => {
|
|
4973
5134
|
if (found || lineOf(node) !== line) return;
|
|
4974
5135
|
walk(node, (inner) => {
|
|
4975
|
-
if (!found && FUNCTIONS.has(inner.type)) found = inner;
|
|
5136
|
+
if (!found && FUNCTIONS$1.has(inner.type)) found = inner;
|
|
4976
5137
|
});
|
|
4977
5138
|
});
|
|
4978
5139
|
return found;
|
|
@@ -5081,75 +5242,643 @@ function dedupe(facts) {
|
|
|
5081
5242
|
return true;
|
|
5082
5243
|
});
|
|
5083
5244
|
}
|
|
5084
|
-
// ../../packages/scan/src/
|
|
5085
|
-
var
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5245
|
+
// ../../packages/scan/src/symbols.ts
|
|
5246
|
+
var SHORT = 300;
|
|
5247
|
+
var PROMPT_TEXT = 6e3;
|
|
5248
|
+
var MAX_CALLS = 40;
|
|
5249
|
+
/** A string this long, or named like a prompt, can be a prompt. */
|
|
5250
|
+
var PROMPT_LENGTH = 80;
|
|
5251
|
+
var PROMPT_NAME = /prompt|instruction|system|backstory|goal|role|template|persona|description|guideline/i;
|
|
5252
|
+
/** Secrets never leave, whatever they are called. */
|
|
5253
|
+
function redact(text) {
|
|
5254
|
+
return text.replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[a private key, left out]").replace(/\b(sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|ghp_[A-Za-z0-9]{30,}|xox[abprs]-[A-Za-z0-9-]{10,}|ya29\.[A-Za-z0-9._-]+|mst\.[A-Za-z0-9._-]+)\b/g, "[a secret, left out]").replace(/\b[A-Fa-f0-9]{40,}\b/g, "[a long hex value, left out]").replace(/\b[A-Za-z0-9+/]{60,}={0,2}/g, "[a long encoded value, left out]").replace(/(\w+:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1[credentials left out]@");
|
|
5255
|
+
}
|
|
5256
|
+
var short = (text, max = SHORT) => {
|
|
5257
|
+
const clean = redact(text.replace(/\s+/g, " ").trim());
|
|
5258
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
5259
|
+
};
|
|
5260
|
+
var endOf = (node) => node.endPosition.row + 1;
|
|
5261
|
+
/** `a.b.c(...)` as `b.c`, so the outline says what is called without every receiver. */
|
|
5262
|
+
function calleeName(call) {
|
|
5263
|
+
const callee = call.childForFieldName("function") ?? call.childForFieldName("constructor");
|
|
5264
|
+
if (!callee) return void 0;
|
|
5265
|
+
return callee.text.replace(/\s+/g, "").split(/[.?!]+/).slice(-2).join(".");
|
|
5266
|
+
}
|
|
5267
|
+
function callsInside(body) {
|
|
5268
|
+
if (!body) return [];
|
|
5269
|
+
const names = /* @__PURE__ */ new Set();
|
|
5270
|
+
walk(body, (node) => {
|
|
5271
|
+
if (node.type === "call" || node.type === "call_expression" || node.type === "new_expression") {
|
|
5272
|
+
const name = calleeName(node);
|
|
5273
|
+
if (name && names.size < MAX_CALLS) names.add(name);
|
|
5274
|
+
}
|
|
5275
|
+
});
|
|
5276
|
+
return [...names];
|
|
5277
|
+
}
|
|
5278
|
+
/** A short, literal picture of an argument: a string, a name, or a list of names and calls. */
|
|
5279
|
+
function argumentValue(value) {
|
|
5280
|
+
const text = stringValue(value);
|
|
5281
|
+
if (text !== void 0) return PROMPT_NAME.test(value.parent?.text.slice(0, 40) ?? "") ? short(text, PROMPT_TEXT) : short(text);
|
|
5282
|
+
if ([
|
|
5283
|
+
"list",
|
|
5284
|
+
"array",
|
|
5285
|
+
"tuple"
|
|
5286
|
+
].includes(value.type)) return `[${value.namedChildren.filter((c) => c !== null).map((c) => [
|
|
5287
|
+
"call",
|
|
5288
|
+
"call_expression",
|
|
5289
|
+
"new_expression"
|
|
5290
|
+
].includes(c.type) ? `${calleeName(c) ?? "?"}(…)` : short(c.text, 60)).join(", ")}]`;
|
|
5291
|
+
if ([
|
|
5292
|
+
"call",
|
|
5293
|
+
"call_expression",
|
|
5294
|
+
"new_expression"
|
|
5295
|
+
].includes(value.type)) return `${calleeName(value) ?? "?"}(…)`;
|
|
5296
|
+
if (["dictionary", "object"].includes(value.type)) return short(`{${value.namedChildren.filter((c) => c?.type === "pair").map((p) => {
|
|
5297
|
+
const key = p.childForFieldName("key")?.text ?? "";
|
|
5298
|
+
const inner = p.childForFieldName("value");
|
|
5299
|
+
return inner ? `${key}: ${argumentValue(inner)}` : key;
|
|
5300
|
+
}).join(", ")}}`, 1200);
|
|
5301
|
+
return short(value.text, 120);
|
|
5302
|
+
}
|
|
5303
|
+
function declarationArgs(call) {
|
|
5304
|
+
const args = {};
|
|
5305
|
+
const list = call.childForFieldName("arguments");
|
|
5306
|
+
let position = 0;
|
|
5307
|
+
for (const arg of list?.namedChildren ?? []) {
|
|
5308
|
+
if (!arg) continue;
|
|
5309
|
+
if (arg.type === "keyword_argument") {
|
|
5310
|
+
const name = arg.childForFieldName("name");
|
|
5311
|
+
const value = arg.childForFieldName("value");
|
|
5312
|
+
if (name && value) args[name.text] = argumentValue(value);
|
|
5313
|
+
} else if (arg.type === "object") for (const pair of arg.namedChildren) {
|
|
5314
|
+
if (pair?.type !== "pair") continue;
|
|
5315
|
+
const key = pair.childForFieldName("key");
|
|
5316
|
+
const value = pair.childForFieldName("value");
|
|
5317
|
+
if (key && value) args[stringValue(key) ?? key.text] = argumentValue(value);
|
|
5318
|
+
}
|
|
5319
|
+
else {
|
|
5320
|
+
args[`#${position.toString()}`] = argumentValue(arg);
|
|
5321
|
+
position += 1;
|
|
5322
|
+
}
|
|
5323
|
+
}
|
|
5324
|
+
return args;
|
|
5325
|
+
}
|
|
5326
|
+
/** Calls made where something is declared: at the top of a module, in a class, or exported. */
|
|
5327
|
+
function isDeclarationSite(call) {
|
|
5328
|
+
let up = call.parent;
|
|
5329
|
+
while (up && [
|
|
5330
|
+
"await",
|
|
5331
|
+
"await_expression",
|
|
5332
|
+
"parenthesized_expression"
|
|
5333
|
+
].includes(up.type)) up = up.parent;
|
|
5334
|
+
if (!up) return null;
|
|
5335
|
+
const top = (n) => !!n && [
|
|
5336
|
+
"module",
|
|
5337
|
+
"program",
|
|
5338
|
+
"class_body",
|
|
5339
|
+
"block"
|
|
5340
|
+
].includes(n.type) && (n.type !== "block" || n.parent?.type === "class_definition");
|
|
5341
|
+
if (up.type === "assignment" && top(up.parent?.parent ?? null)) return { name: up.childForFieldName("left")?.text };
|
|
5342
|
+
if (up.type === "variable_declarator") {
|
|
5343
|
+
const holder = up.parent?.parent;
|
|
5344
|
+
if (holder && (top(holder) || holder.type === "export_statement")) return { name: up.childForFieldName("name")?.text };
|
|
5345
|
+
}
|
|
5346
|
+
if (up.type === "expression_statement" && top(up.parent)) return {};
|
|
5347
|
+
if (up.type === "export_statement" || up.type === "decorator") return {};
|
|
5348
|
+
if (up.type === "return_statement" && /^(new |[A-Z])/.test(calleeName(call) ?? "")) return {};
|
|
5349
|
+
return null;
|
|
5350
|
+
}
|
|
5351
|
+
/** Strings that read like prompts: long, or given to a name or argument that says so. */
|
|
5352
|
+
function promptStrings(file) {
|
|
5353
|
+
const found = [];
|
|
5354
|
+
walk(file.root, (node) => {
|
|
5355
|
+
if (![
|
|
5356
|
+
"string",
|
|
5357
|
+
"template_string",
|
|
5358
|
+
"concatenated_string"
|
|
5359
|
+
].includes(node.type)) return;
|
|
5360
|
+
if (node.parent?.type === "concatenated_string") return;
|
|
5361
|
+
if (node.parent?.type === "expression_statement") return;
|
|
5362
|
+
const text = stringValue(node);
|
|
5363
|
+
if (!text) return;
|
|
5364
|
+
const parent = node.parent;
|
|
5365
|
+
const holder = parent?.type === "keyword_argument" || parent?.type === "pair" ? (parent.childForFieldName("name") ?? parent.childForFieldName("key"))?.text : parent?.type === "assignment" ? parent.childForFieldName("left")?.text : parent?.type === "variable_declarator" ? parent.childForFieldName("name")?.text : void 0;
|
|
5366
|
+
const named = holder && PROMPT_NAME.test(holder);
|
|
5367
|
+
if (text.length >= PROMPT_LENGTH * (named ? .25 : 3) && /\s/.test(text)) found.push({
|
|
5368
|
+
line: lineOf(node),
|
|
5369
|
+
name: holder ?? void 0,
|
|
5370
|
+
text: short(text, PROMPT_TEXT)
|
|
5371
|
+
});
|
|
5372
|
+
});
|
|
5373
|
+
return found;
|
|
5374
|
+
}
|
|
5375
|
+
function symbolsOf(file) {
|
|
5376
|
+
const imports = /* @__PURE__ */ new Set();
|
|
5377
|
+
const env = /* @__PURE__ */ new Set();
|
|
5378
|
+
const symbols = [];
|
|
5379
|
+
const declarations = [];
|
|
5380
|
+
walk(file.root, (node) => {
|
|
5381
|
+
switch (node.type) {
|
|
5382
|
+
case "import_statement":
|
|
5383
|
+
case "import_from_statement": {
|
|
5384
|
+
const source = node.childForFieldName("module_name")?.text ?? stringValue(node.childForFieldName("source"));
|
|
5385
|
+
if (source) imports.add(source);
|
|
5386
|
+
else for (const name of node.namedChildren) if (name?.type === "dotted_name") imports.add(name.text);
|
|
5387
|
+
break;
|
|
5388
|
+
}
|
|
5389
|
+
case "subscript":
|
|
5390
|
+
case "member_expression":
|
|
5391
|
+
case "call":
|
|
5392
|
+
case "call_expression": {
|
|
5393
|
+
const text = node.text;
|
|
5394
|
+
const m = /^(?:os\.environ(?:\.get)?|os\.getenv|process\.env)\s*[[(.]\s*["'`]?([A-Z][A-Z0-9_]{2,})/.exec(text);
|
|
5395
|
+
if (m) env.add(m[1]);
|
|
5396
|
+
if (node.type === "call" || node.type === "call_expression") {
|
|
5397
|
+
const site = isDeclarationSite(node);
|
|
5398
|
+
if (site) declarations.push({
|
|
5399
|
+
name: site.name,
|
|
5400
|
+
callee: calleeName(node) ?? "?",
|
|
5401
|
+
line: lineOf(node),
|
|
5402
|
+
end: endOf(node),
|
|
5403
|
+
args: declarationArgs(node)
|
|
5404
|
+
});
|
|
5405
|
+
}
|
|
5406
|
+
break;
|
|
5407
|
+
}
|
|
5408
|
+
case "new_expression": {
|
|
5409
|
+
const site = isDeclarationSite(node);
|
|
5410
|
+
if (site) declarations.push({
|
|
5411
|
+
name: site.name,
|
|
5412
|
+
callee: `new ${calleeName(node) ?? "?"}`,
|
|
5413
|
+
line: lineOf(node),
|
|
5414
|
+
end: endOf(node),
|
|
5415
|
+
args: declarationArgs(node)
|
|
5416
|
+
});
|
|
5417
|
+
break;
|
|
5418
|
+
}
|
|
5419
|
+
case "function_definition":
|
|
5420
|
+
case "function_declaration":
|
|
5421
|
+
case "method_definition": {
|
|
5422
|
+
const name = node.childForFieldName("name")?.text;
|
|
5423
|
+
if (!name) break;
|
|
5424
|
+
const inClass = !!node.parent?.parent && [
|
|
5425
|
+
"class_definition",
|
|
5426
|
+
"class_declaration",
|
|
5427
|
+
"class"
|
|
5428
|
+
].includes(node.parent.parent.type);
|
|
5429
|
+
const decorated = node.parent?.type === "decorated_definition" ? node.parent : null;
|
|
5430
|
+
const decorators = [...decorated?.namedChildren ?? [], ...node.namedChildren].filter((c) => c?.type === "decorator").map((d) => short(d.text, 160));
|
|
5431
|
+
const params = node.childForFieldName("parameters")?.text ?? "";
|
|
5432
|
+
const returns = node.childForFieldName("return_type")?.text;
|
|
5433
|
+
symbols.push({
|
|
5434
|
+
kind: inClass || node.type === "method_definition" ? "method" : "function",
|
|
5435
|
+
name,
|
|
5436
|
+
line: lineOf(decorated ?? node),
|
|
5437
|
+
end: endOf(node),
|
|
5438
|
+
signature: short(`${name}${params}${returns ? ` -> ${returns}` : ""}`),
|
|
5439
|
+
decorators: decorators.length ? decorators : void 0,
|
|
5440
|
+
doc: docOf(node, file),
|
|
5441
|
+
calls: callsInside(node.childForFieldName("body"))
|
|
5442
|
+
});
|
|
5443
|
+
break;
|
|
5444
|
+
}
|
|
5445
|
+
case "variable_declarator": {
|
|
5446
|
+
const value = node.childForFieldName("value");
|
|
5447
|
+
const name = node.childForFieldName("name")?.text;
|
|
5448
|
+
if (name && value && ["arrow_function", "function_expression"].includes(value.type)) symbols.push({
|
|
5449
|
+
kind: "function",
|
|
5450
|
+
name,
|
|
5451
|
+
line: lineOf(node),
|
|
5452
|
+
end: endOf(node),
|
|
5453
|
+
signature: short(`${name}${value.childForFieldName("parameters")?.text ?? `(${paramNames(value).join(", ")})`}`),
|
|
5454
|
+
doc: docOf(node.parent ?? node, file),
|
|
5455
|
+
calls: callsInside(value.childForFieldName("body"))
|
|
5456
|
+
});
|
|
5457
|
+
break;
|
|
5458
|
+
}
|
|
5459
|
+
case "class_definition":
|
|
5460
|
+
case "class_declaration": {
|
|
5461
|
+
const name = node.childForFieldName("name")?.text;
|
|
5462
|
+
if (!name) break;
|
|
5463
|
+
const bases = node.childForFieldName("superclasses")?.namedChildren ?? node.namedChildren.find((c) => c?.type === "class_heritage")?.namedChildren ?? [];
|
|
5464
|
+
symbols.push({
|
|
5465
|
+
kind: "class",
|
|
5466
|
+
name,
|
|
5467
|
+
line: lineOf(node),
|
|
5468
|
+
end: endOf(node),
|
|
5469
|
+
bases: bases.filter((b) => b !== null).map((b) => short(b.text.replace(/^extends\s+/, ""), 80)),
|
|
5470
|
+
doc: docOf(node, file)
|
|
5471
|
+
});
|
|
5472
|
+
break;
|
|
5473
|
+
}
|
|
5474
|
+
}
|
|
5098
5475
|
});
|
|
5099
5476
|
return {
|
|
5477
|
+
file: file.path,
|
|
5478
|
+
lines: file.text.split("\n").length,
|
|
5479
|
+
imports: [...imports],
|
|
5480
|
+
env: [...env],
|
|
5481
|
+
symbols,
|
|
5482
|
+
declarations,
|
|
5483
|
+
strings: promptStrings(file)
|
|
5484
|
+
};
|
|
5485
|
+
}
|
|
5486
|
+
/** A Python docstring, or the comment just above a TypeScript declaration. */
|
|
5487
|
+
function docOf(node, file) {
|
|
5488
|
+
if (file.language === "python") {
|
|
5489
|
+
const doc = pythonDocstring(node);
|
|
5490
|
+
return doc ? short(doc, 600) : void 0;
|
|
5491
|
+
}
|
|
5492
|
+
const before = node.previousSibling;
|
|
5493
|
+
if (before?.type === "comment" && before.endPosition.row + 1 >= node.startPosition.row) return short(before.text.replace(/^\/\*+|\*+\/$|^\s*\*\s?|^\/\/\s?/gm, ""), 600);
|
|
5494
|
+
}
|
|
5495
|
+
// ../../packages/scan/src/outline.ts
|
|
5496
|
+
/** About 60,000 tokens: enough for an app's code, small enough to be quick and cheap. */
|
|
5497
|
+
var BUDGET = 24e4;
|
|
5498
|
+
var DOCUMENT_BYTES = 2e4;
|
|
5499
|
+
var DOCUMENTS_BYTES = 6e4;
|
|
5500
|
+
/** Files that build agents, tools or model clients go first when the budget is tight. */
|
|
5501
|
+
var AGENT_WORDS = /agent|tool|crew|task|graph|chat|llm|model|openai|anthropic|gemini|genai|vertex|bedrock|mcp|prompt|runner|langchain|llama|mastra|smolagents|pydantic_ai|autogen|ai-sdk/i;
|
|
5502
|
+
function priority(file) {
|
|
5503
|
+
let score = 0;
|
|
5504
|
+
if (file.imports.some((i) => AGENT_WORDS.test(i))) score += 4;
|
|
5505
|
+
if (file.declarations.some((d) => AGENT_WORDS.test(d.callee))) score += 3;
|
|
5506
|
+
if (file.symbols.some((s) => s.decorators?.some((d) => /tool/i.test(d)))) score += 3;
|
|
5507
|
+
if (file.strings.length) score += 1;
|
|
5508
|
+
return score;
|
|
5509
|
+
}
|
|
5510
|
+
/** Prompt and configuration files the code names, or that sit where prompts are kept. */
|
|
5511
|
+
async function documentsOf(look, outlines) {
|
|
5512
|
+
const named = new Set(outlines.flatMap((f) => [...f.strings.map((s) => s.text), ...f.declarations.flatMap((d) => Object.values(d.args))]));
|
|
5513
|
+
const mentioned = (path) => [...named].some((text) => text.includes(basename(path)));
|
|
5514
|
+
const wanted = look.documents.filter((path) => mentioned(path) || /(^|\/)(prompts?|config|instructions|agents|skills)\//i.test(path));
|
|
5515
|
+
const documents = [];
|
|
5516
|
+
let total = 0;
|
|
5517
|
+
for (const file of wanted) {
|
|
5518
|
+
const text = await readFile(join(look.root, file), "utf8").catch(() => "");
|
|
5519
|
+
if (!text || text.length > DOCUMENT_BYTES) continue;
|
|
5520
|
+
if (total + text.length > DOCUMENTS_BYTES) break;
|
|
5521
|
+
total += text.length;
|
|
5522
|
+
documents.push({
|
|
5523
|
+
file,
|
|
5524
|
+
text: redact(text)
|
|
5525
|
+
});
|
|
5526
|
+
}
|
|
5527
|
+
return documents;
|
|
5528
|
+
}
|
|
5529
|
+
var where$1 = (f) => ({
|
|
5530
|
+
file: f.file,
|
|
5531
|
+
line: f.line
|
|
5532
|
+
});
|
|
5533
|
+
async function buildOutline(look, facts, project) {
|
|
5534
|
+
const outlines = project.files.map(symbolsOf);
|
|
5535
|
+
const ranked = [...outlines].sort((a, b) => priority(b) - priority(a));
|
|
5536
|
+
const code = [];
|
|
5537
|
+
const omitted = [];
|
|
5538
|
+
let size = 0;
|
|
5539
|
+
for (const file of ranked) {
|
|
5540
|
+
const bytes = JSON.stringify(file).length;
|
|
5541
|
+
if (size + bytes > BUDGET) omitted.push(file.file);
|
|
5542
|
+
else {
|
|
5543
|
+
code.push(file);
|
|
5544
|
+
size += bytes;
|
|
5545
|
+
}
|
|
5546
|
+
}
|
|
5547
|
+
return {
|
|
5548
|
+
version: 2,
|
|
5549
|
+
name: look.name,
|
|
5100
5550
|
files: look.sourceFiles,
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5551
|
+
dependencies: [...look.dependencies].sort(),
|
|
5552
|
+
scripts: look.startCommands,
|
|
5553
|
+
settings: look.settingNames,
|
|
5554
|
+
code,
|
|
5555
|
+
documents: await documentsOf(look, outlines),
|
|
5556
|
+
found: {
|
|
5557
|
+
tools: facts.flatMap((f) => f.kind === "tool" ? [{
|
|
5558
|
+
name: f.name,
|
|
5559
|
+
...where$1(f)
|
|
5560
|
+
}] : []),
|
|
5561
|
+
waysIn: facts.flatMap((f) => f.kind === "way-in" ? [{
|
|
5562
|
+
name: f.name,
|
|
5563
|
+
...where$1(f)
|
|
5564
|
+
}] : []),
|
|
5565
|
+
systemPrompts: facts.flatMap((f) => f.kind === "system-prompt" ? [where$1(f)] : [])
|
|
5566
|
+
},
|
|
5567
|
+
omitted
|
|
5111
5568
|
};
|
|
5112
5569
|
}
|
|
5570
|
+
// ../../packages/scan/src/verify.ts
|
|
5571
|
+
/** Names compared as people read them: case, spaces, dashes and underscores aside. */
|
|
5572
|
+
var sameName = (a, b) => a.toLowerCase().replace(/[\s_-]/g, "") === b.toLowerCase().replace(/[\s_-]/g, "");
|
|
5573
|
+
var contains = (text, name) => text.toLowerCase().replace(/[\s_-]/g, "").includes(name.toLowerCase().replace(/[\s_-]/g, ""));
|
|
5574
|
+
var STATEMENTS = /* @__PURE__ */ new Set([
|
|
5575
|
+
"decorated_definition",
|
|
5576
|
+
"function_definition",
|
|
5577
|
+
"class_definition",
|
|
5578
|
+
"function_declaration",
|
|
5579
|
+
"class_declaration",
|
|
5580
|
+
"method_definition",
|
|
5581
|
+
"expression_statement",
|
|
5582
|
+
"lexical_declaration",
|
|
5583
|
+
"variable_declaration",
|
|
5584
|
+
"export_statement",
|
|
5585
|
+
"return_statement",
|
|
5586
|
+
"assignment",
|
|
5587
|
+
"pair",
|
|
5588
|
+
"keyword_argument"
|
|
5589
|
+
]);
|
|
5590
|
+
/** The most text a statement is searched in, so a whole module never counts as the place. */
|
|
5591
|
+
var STATEMENT_LIMIT = 6e3;
|
|
5592
|
+
/** The statement a line opens or sits in: what a citation of that line points at. */
|
|
5593
|
+
function statementAt(file, line) {
|
|
5594
|
+
const text = file.text.split("\n")[line - 1] ?? "";
|
|
5595
|
+
const column = text.length - text.trimStart().length;
|
|
5596
|
+
let node = file.root.descendantForPosition({
|
|
5597
|
+
row: line - 1,
|
|
5598
|
+
column
|
|
5599
|
+
});
|
|
5600
|
+
let found = null;
|
|
5601
|
+
while (node && node.type !== "module" && node.type !== "program") {
|
|
5602
|
+
if (STATEMENTS.has(node.type)) {
|
|
5603
|
+
found = node;
|
|
5604
|
+
if (node.text.length > 200) break;
|
|
5605
|
+
}
|
|
5606
|
+
node = node.parent;
|
|
5607
|
+
}
|
|
5608
|
+
return found;
|
|
5609
|
+
}
|
|
5610
|
+
/** The text a citation points at: its statement, or the line and the two after it. */
|
|
5611
|
+
function citedText(file, line) {
|
|
5612
|
+
const statement = statementAt(file, line);
|
|
5613
|
+
if (statement && statement.text.length <= STATEMENT_LIMIT) return statement.text;
|
|
5614
|
+
return file.text.split("\n").slice(line - 1, line + 2).join("\n");
|
|
5615
|
+
}
|
|
5616
|
+
/** Whether a tool is declared as a function of its own name, which the hooks can hold. */
|
|
5617
|
+
function hookableAt(file, line, name) {
|
|
5618
|
+
let node = statementAt(file, line);
|
|
5619
|
+
if (node?.type === "decorated_definition") node = node.childForFieldName("definition");
|
|
5620
|
+
if (node?.type === "lexical_declaration") node = node.namedChildren.find((c) => c?.type === "variable_declarator") ?? null;
|
|
5621
|
+
return node?.childForFieldName("name")?.text === name && (["function_definition", "function_declaration"].includes(node.type) || ["arrow_function", "function_expression"].includes(node.childForFieldName("value")?.type ?? ""));
|
|
5622
|
+
}
|
|
5623
|
+
/** The parameters of the function a tool is declared as, when it is one. */
|
|
5624
|
+
function paramsAt(file, line) {
|
|
5625
|
+
let node = statementAt(file, line);
|
|
5626
|
+
if (node?.type === "decorated_definition") node = node.childForFieldName("definition");
|
|
5627
|
+
return node && ["function_definition", "function_declaration"].includes(node.type) ? paramNames(node) : [];
|
|
5628
|
+
}
|
|
5629
|
+
/** The longest string at or just after a line, for a prompt's text. */
|
|
5630
|
+
function stringNear(file, line) {
|
|
5631
|
+
let best;
|
|
5632
|
+
walk(file.root, (node) => {
|
|
5633
|
+
if (![
|
|
5634
|
+
"string",
|
|
5635
|
+
"template_string",
|
|
5636
|
+
"concatenated_string"
|
|
5637
|
+
].includes(node.type)) return;
|
|
5638
|
+
const at = lineOf(node);
|
|
5639
|
+
if (at < line - 1 || at > line + 2) return;
|
|
5640
|
+
const text = stringValue(node);
|
|
5641
|
+
if (text && text.length >= 20 && (!best || text.length > best.length)) best = text;
|
|
5642
|
+
});
|
|
5643
|
+
return best;
|
|
5644
|
+
}
|
|
5645
|
+
var WAY_SOURCE = {
|
|
5646
|
+
conversation: "route",
|
|
5647
|
+
webhook: "route",
|
|
5648
|
+
email: "loader",
|
|
5649
|
+
documents: "retriever",
|
|
5650
|
+
web: "web",
|
|
5651
|
+
"tool-result": "tool",
|
|
5652
|
+
mcp: "mcp",
|
|
5653
|
+
files: "loader",
|
|
5654
|
+
other: "loader"
|
|
5655
|
+
};
|
|
5656
|
+
/** A start command is believed when it names a script, a file or a tool the repository has. */
|
|
5657
|
+
function startHolds(command, look, project) {
|
|
5658
|
+
if (look.startCommands.some((s) => s.command === command)) return true;
|
|
5659
|
+
const files = /* @__PURE__ */ new Set([...look.sourceFiles, ...project.files.map((f) => f.path)]);
|
|
5660
|
+
const words = command.split(/\s+/);
|
|
5661
|
+
for (const word of words) {
|
|
5662
|
+
if (/\.(py|ts|js|mjs|tsx)$/.test(word) && files.has(word.replace(/^\.\//, ""))) return true;
|
|
5663
|
+
const module = /^([\w.]+):\w+$/.exec(word)?.[1];
|
|
5664
|
+
if (module && files.has(`${module.replace(/\./g, "/")}.py`)) return true;
|
|
5665
|
+
}
|
|
5666
|
+
if (words[0] === "python" && words[1] === "-m" && words[2]) return [...files].some((f) => f.startsWith(words[2].replace(/\./g, "/")));
|
|
5667
|
+
const tool = words[0] === "npx" || words[0] === "uv" ? words[1] : words[0];
|
|
5668
|
+
return !!tool && look.dependencies.has(tool);
|
|
5669
|
+
}
|
|
5670
|
+
function verifyUnderstanding(u, look, project) {
|
|
5671
|
+
const byPath = new Map(project.files.map((f) => [f.path, f]));
|
|
5672
|
+
const dropped = [];
|
|
5673
|
+
const facts = [];
|
|
5674
|
+
const at = (x) => `${x.file}:${x.line.toString()}`;
|
|
5675
|
+
const fileOf = (x, what) => {
|
|
5676
|
+
const file = byPath.get(x.file);
|
|
5677
|
+
if (!file) {
|
|
5678
|
+
if (!look.documents.includes(x.file)) dropped.push({
|
|
5679
|
+
what,
|
|
5680
|
+
why: `${x.file} is not a file the scan read`
|
|
5681
|
+
});
|
|
5682
|
+
return null;
|
|
5683
|
+
}
|
|
5684
|
+
if (x.line < 1 || x.line > file.text.split("\n").length) {
|
|
5685
|
+
dropped.push({
|
|
5686
|
+
what,
|
|
5687
|
+
why: `${x.file} has no line ${x.line.toString()}`
|
|
5688
|
+
});
|
|
5689
|
+
return null;
|
|
5690
|
+
}
|
|
5691
|
+
return file;
|
|
5692
|
+
};
|
|
5693
|
+
const base = (x) => ({
|
|
5694
|
+
file: x.file,
|
|
5695
|
+
line: x.line,
|
|
5696
|
+
adapter: "model",
|
|
5697
|
+
confidence: x.confidence
|
|
5698
|
+
});
|
|
5699
|
+
const tools = u.tools.filter((tool) => {
|
|
5700
|
+
const what = `the tool ${tool.name} at ${at(tool)}`;
|
|
5701
|
+
const file = fileOf(tool, what);
|
|
5702
|
+
if (!file) return false;
|
|
5703
|
+
if (!contains(citedText(file, tool.line), tool.name)) {
|
|
5704
|
+
dropped.push({
|
|
5705
|
+
what,
|
|
5706
|
+
why: `${tool.name} is not named there`
|
|
5707
|
+
});
|
|
5708
|
+
return false;
|
|
5709
|
+
}
|
|
5710
|
+
facts.push({
|
|
5711
|
+
kind: "tool",
|
|
5712
|
+
name: tool.name,
|
|
5713
|
+
description: tool.description,
|
|
5714
|
+
params: paramsAt(file, tool.line),
|
|
5715
|
+
hookable: hookableAt(file, tool.line, tool.name),
|
|
5716
|
+
...base(tool)
|
|
5717
|
+
});
|
|
5718
|
+
return true;
|
|
5719
|
+
});
|
|
5720
|
+
for (const prompt of u.systemPrompts) {
|
|
5721
|
+
const what = `the prompt at ${at(prompt)}`;
|
|
5722
|
+
if (look.documents.includes(prompt.file)) {
|
|
5723
|
+
facts.push({
|
|
5724
|
+
kind: "system-prompt",
|
|
5725
|
+
text: `${prompt.file}`,
|
|
5726
|
+
...base(prompt)
|
|
5727
|
+
});
|
|
5728
|
+
continue;
|
|
5729
|
+
}
|
|
5730
|
+
const file = fileOf(prompt, what);
|
|
5731
|
+
if (!file) continue;
|
|
5732
|
+
const text = stringNear(file, prompt.line);
|
|
5733
|
+
if (!text) dropped.push({
|
|
5734
|
+
what,
|
|
5735
|
+
why: "there is no prompt text there"
|
|
5736
|
+
});
|
|
5737
|
+
else facts.push({
|
|
5738
|
+
kind: "system-prompt",
|
|
5739
|
+
text,
|
|
5740
|
+
...base(prompt)
|
|
5741
|
+
});
|
|
5742
|
+
}
|
|
5743
|
+
const waysIn = u.waysIn.filter((way) => {
|
|
5744
|
+
if (way.kind === "conversation") return true;
|
|
5745
|
+
if (!fileOf(way, `the way in ${way.name} at ${at(way)}`)) return false;
|
|
5746
|
+
facts.push({
|
|
5747
|
+
kind: "way-in",
|
|
5748
|
+
name: way.name,
|
|
5749
|
+
source: WAY_SOURCE[way.kind],
|
|
5750
|
+
...base(way)
|
|
5751
|
+
});
|
|
5752
|
+
return true;
|
|
5753
|
+
});
|
|
5754
|
+
for (const agent of u.agents) if (fileOf(agent, `the agent ${agent.name} at ${at(agent)}`)) facts.push({
|
|
5755
|
+
kind: "agent",
|
|
5756
|
+
name: agent.name,
|
|
5757
|
+
...base(agent)
|
|
5758
|
+
});
|
|
5759
|
+
for (const call of u.modelCalls) if (fileOf(call, `the model call at ${at(call)}`)) facts.push({
|
|
5760
|
+
kind: "model",
|
|
5761
|
+
name: call.model ? `${call.provider} ${call.model}` : call.provider,
|
|
5762
|
+
...base(call)
|
|
5763
|
+
});
|
|
5764
|
+
const entry = u.entry;
|
|
5765
|
+
if (entry?.kind === "http" && entry.path && entry.message && entry.reply) {
|
|
5766
|
+
const file = fileOf(entry, `the route at ${at(entry)}`);
|
|
5767
|
+
const named = file && (file.text.includes(entry.path) || entry.path.split("/").filter(Boolean).every((part) => file.path.includes(part)));
|
|
5768
|
+
if (file && named) facts.push({
|
|
5769
|
+
kind: "entry",
|
|
5770
|
+
path: entry.path,
|
|
5771
|
+
method: entry.method ?? "POST",
|
|
5772
|
+
message: entry.message,
|
|
5773
|
+
reply: entry.reply,
|
|
5774
|
+
...base(entry)
|
|
5775
|
+
});
|
|
5776
|
+
else if (file) dropped.push({
|
|
5777
|
+
what: `the route ${entry.path}`,
|
|
5778
|
+
why: `${entry.file} does not name it`
|
|
5779
|
+
});
|
|
5780
|
+
}
|
|
5781
|
+
const start = u.start.flatMap((s) => {
|
|
5782
|
+
if (startHolds(s.command, look, project)) return [{
|
|
5783
|
+
command: s.command,
|
|
5784
|
+
from: "read from the code"
|
|
5785
|
+
}];
|
|
5786
|
+
dropped.push({
|
|
5787
|
+
what: `the start command ${s.command}`,
|
|
5788
|
+
why: "it names no script, file or tool in the repository"
|
|
5789
|
+
});
|
|
5790
|
+
return [];
|
|
5791
|
+
});
|
|
5792
|
+
const unclear = u.unclear.filter((x) => {
|
|
5793
|
+
const file = fileOf(x, `the function ${x.name} at ${at(x)}`);
|
|
5794
|
+
return !!file && contains(citedText(file, x.line), x.name);
|
|
5795
|
+
});
|
|
5796
|
+
const kept = new Set(tools.map((t) => t.name));
|
|
5797
|
+
return {
|
|
5798
|
+
facts,
|
|
5799
|
+
understanding: {
|
|
5800
|
+
tools: tools.map((t) => ({
|
|
5801
|
+
name: t.name,
|
|
5802
|
+
can: t.can,
|
|
5803
|
+
confidence: t.confidence
|
|
5804
|
+
})),
|
|
5805
|
+
waysIn: waysIn.filter((w) => w.kind !== "conversation").map((w) => ({
|
|
5806
|
+
name: w.name,
|
|
5807
|
+
untrusted: w.untrusted,
|
|
5808
|
+
who: w.who,
|
|
5809
|
+
confidence: w.confidence
|
|
5810
|
+
})),
|
|
5811
|
+
privateData: u.privateData,
|
|
5812
|
+
rules: u.rules.filter((r) => kept.has(r.tool) || r.tool === "reply"),
|
|
5813
|
+
kind: u.kind,
|
|
5814
|
+
summary: u.summary
|
|
5815
|
+
},
|
|
5816
|
+
effects: new Map(tools.map((t) => [t.name, {
|
|
5817
|
+
leavesBy: t.leavesBy,
|
|
5818
|
+
host: t.host
|
|
5819
|
+
}])),
|
|
5820
|
+
start,
|
|
5821
|
+
unclear,
|
|
5822
|
+
dropped
|
|
5823
|
+
};
|
|
5824
|
+
}
|
|
5825
|
+
/**
|
|
5826
|
+
* Adapter facts first, because they were read for certain; the model's beside them where it found
|
|
5827
|
+
* what no adapter did. A tool or way in both found keeps the adapter's.
|
|
5828
|
+
*/
|
|
5829
|
+
function mergeFacts(read, verified) {
|
|
5830
|
+
const named = (f) => f.kind === "tool" || f.kind === "way-in" || f.kind === "agent" ? f.name : null;
|
|
5831
|
+
const extra = verified.filter((v) => {
|
|
5832
|
+
const name = named(v);
|
|
5833
|
+
if (v.kind === "entry") return !read.some((r) => r.kind === "entry");
|
|
5834
|
+
if (v.kind === "system-prompt") return !read.some((r) => r.kind === "system-prompt" && r.file === v.file && Math.abs(r.line - v.line) <= 2);
|
|
5835
|
+
return name === null || !read.some((r) => r.kind === v.kind && named(r) !== null && sameName(named(r), name));
|
|
5836
|
+
});
|
|
5837
|
+
return [...read, ...extra];
|
|
5838
|
+
}
|
|
5839
|
+
// ../../packages/scan/src/bodies.ts
|
|
5840
|
+
var BODY_LIMIT = 8e3;
|
|
5841
|
+
var FUNCTIONS = /* @__PURE__ */ new Set([
|
|
5842
|
+
"function_definition",
|
|
5843
|
+
"decorated_definition",
|
|
5844
|
+
"function_declaration",
|
|
5845
|
+
"method_definition",
|
|
5846
|
+
"arrow_function",
|
|
5847
|
+
"function_expression",
|
|
5848
|
+
"lexical_declaration"
|
|
5849
|
+
]);
|
|
5850
|
+
/** The code of each function asked for, found by the line it starts on, with secrets taken out. */
|
|
5851
|
+
function bodiesOf(result, wanted) {
|
|
5852
|
+
return wanted.flatMap((w) => {
|
|
5853
|
+
const file = result.project.files.find((f) => f.path === w.file);
|
|
5854
|
+
if (!file) return [];
|
|
5855
|
+
let node = file.root.descendantForPosition({
|
|
5856
|
+
row: w.line - 1,
|
|
5857
|
+
column: (file.text.split("\n")[w.line - 1] ?? "").search(/\S|$/)
|
|
5858
|
+
});
|
|
5859
|
+
while (node && !FUNCTIONS.has(node.type)) node = node.parent;
|
|
5860
|
+
if (!node) return [];
|
|
5861
|
+
return [{
|
|
5862
|
+
file: w.file,
|
|
5863
|
+
line: w.line,
|
|
5864
|
+
name: w.name,
|
|
5865
|
+
text: redact(node.text).slice(0, BODY_LIMIT)
|
|
5866
|
+
}];
|
|
5867
|
+
});
|
|
5868
|
+
}
|
|
5113
5869
|
// ../../packages/scan/src/draft.ts
|
|
5114
5870
|
var CONFIDENCE_THRESHOLD = .7;
|
|
5115
5871
|
var MAX_QUESTIONS = 7;
|
|
5116
|
-
/**
|
|
5117
|
-
function
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
if (
|
|
5122
|
-
|
|
5123
|
-
if (/customer|account|record|order|profile|crm|employee|patient/.test(text)) return "read private data";
|
|
5124
|
-
return "read";
|
|
5872
|
+
/**
|
|
5873
|
+
* How a review stops a tool: a hook on its function when an adapter read it or it is a function of
|
|
5874
|
+
* its own name, the edge when everything it does leaves over HTTP, or nothing. ADR 0062.
|
|
5875
|
+
*/
|
|
5876
|
+
function heldBy(fact, effects) {
|
|
5877
|
+
if (fact.adapter !== "model" || fact.hookable) return "function";
|
|
5878
|
+
return effects?.get(fact.name)?.leavesBy === "http" ? "edge" : "none";
|
|
5125
5879
|
}
|
|
5126
5880
|
var holdByDefault = (can) => !["read", "read private data"].includes(can);
|
|
5127
5881
|
var where = (f) => `${f.file}:${f.line.toString()}`;
|
|
5128
|
-
/** The rules every system gets from what its tools can do. Exact checks, written so they parse. */
|
|
5129
|
-
function defaultRules(tools) {
|
|
5130
|
-
const rules = [];
|
|
5131
|
-
let n = 1;
|
|
5132
|
-
const add = (never, check, checks, impact) => {
|
|
5133
|
-
rules.push({
|
|
5134
|
-
id: `R-${(n++).toString()}`,
|
|
5135
|
-
never,
|
|
5136
|
-
check,
|
|
5137
|
-
checks,
|
|
5138
|
-
impact
|
|
5139
|
-
});
|
|
5140
|
-
};
|
|
5141
|
-
for (const tool of tools) {
|
|
5142
|
-
if (tool.can === "send outside") add(`Send private data outside through ${tool.name}`, `${tool.name}.args contains canary`, ["A1", "A4"], "data-or-money");
|
|
5143
|
-
if (tool.can === "move money") add(`Use ${tool.name} when the person did not ask for it`, `${tool.name}.called`, [
|
|
5144
|
-
"A1",
|
|
5145
|
-
"A2",
|
|
5146
|
-
"A3"
|
|
5147
|
-
], "data-or-money");
|
|
5148
|
-
if (tool.can === "delete" || tool.can === "change") add(`Use ${tool.name} when the person did not ask for it`, `${tool.name}.called`, ["A1", "A3"], "changed-or-shared");
|
|
5149
|
-
}
|
|
5150
|
-
add("Reveal its system prompt", "reply.text contains canary", ["A7"], "reply");
|
|
5151
|
-
return rules;
|
|
5152
|
-
}
|
|
5153
5882
|
function draftConfig(input) {
|
|
5154
5883
|
const { look, facts, understanding, previous } = input;
|
|
5155
5884
|
const toolFacts = facts.filter((f) => f.kind === "tool");
|
|
@@ -5159,16 +5888,18 @@ function draftConfig(input) {
|
|
|
5159
5888
|
const answered = new Map(previous?.tools.map((t) => [t.name, t]) ?? []);
|
|
5160
5889
|
const tools = toolFacts.map((fact) => {
|
|
5161
5890
|
const known = answered.get(fact.name);
|
|
5891
|
+
const held_by = heldBy(fact, input.effects);
|
|
5162
5892
|
if (known) return {
|
|
5163
5893
|
...known,
|
|
5164
5894
|
from: where(fact),
|
|
5165
|
-
params: fact.params
|
|
5895
|
+
params: fact.params,
|
|
5896
|
+
held_by
|
|
5166
5897
|
};
|
|
5167
5898
|
const understood = understanding?.tools.find((t) => t.name === fact.name);
|
|
5168
5899
|
const can = understood?.can ?? guessCan(fact.name, fact.description);
|
|
5169
5900
|
const sure = (understood?.confidence ?? 0) >= CONFIDENCE_THRESHOLD;
|
|
5170
5901
|
const hold = holdByDefault(can);
|
|
5171
|
-
if (!sure) questions.push({
|
|
5902
|
+
if (!sure && !(held_by === "none" && hold)) questions.push({
|
|
5172
5903
|
id: `tool:${fact.name}`,
|
|
5173
5904
|
about: fact.name,
|
|
5174
5905
|
text: `${fact.name} in ${where(fact)} looks like it can ${can === "read" ? "read things" : can}. Should reviews hold it, so it never really runs?`,
|
|
@@ -5204,12 +5935,28 @@ function draftConfig(input) {
|
|
|
5204
5935
|
}
|
|
5205
5936
|
]
|
|
5206
5937
|
});
|
|
5938
|
+
if (held_by === "none" && hold) questions.push({
|
|
5939
|
+
id: `unheld:${fact.name}`,
|
|
5940
|
+
about: fact.name,
|
|
5941
|
+
text: `${fact.name} in ${where(fact)} can ${can}, and what it does does not pass through anything a review can stop. Should reviews leave it out?`,
|
|
5942
|
+
why: "During a review it would run for real.",
|
|
5943
|
+
rank: -1,
|
|
5944
|
+
options: [{
|
|
5945
|
+
label: "Yes, leave it out of reviews",
|
|
5946
|
+
value: "skip",
|
|
5947
|
+
recommended: true
|
|
5948
|
+
}, {
|
|
5949
|
+
label: "No, let it run: it only reaches a staging copy",
|
|
5950
|
+
value: "run-unheld"
|
|
5951
|
+
}]
|
|
5952
|
+
});
|
|
5207
5953
|
return {
|
|
5208
5954
|
name: fact.name,
|
|
5209
5955
|
from: where(fact),
|
|
5210
5956
|
can,
|
|
5211
|
-
during_reviews: sure ? hold ? "hold" : "run" : "hold",
|
|
5212
|
-
params: fact.params
|
|
5957
|
+
during_reviews: held_by === "none" && hold ? "skip" : sure ? hold ? "hold" : "run" : "hold",
|
|
5958
|
+
params: fact.params,
|
|
5959
|
+
held_by
|
|
5213
5960
|
};
|
|
5214
5961
|
});
|
|
5215
5962
|
const waysIn = [];
|
|
@@ -5274,7 +6021,7 @@ function draftConfig(input) {
|
|
|
5274
6021
|
tool: q.about,
|
|
5275
6022
|
choice: q.options.find((o) => o.recommended)?.value ?? "hold"
|
|
5276
6023
|
}));
|
|
5277
|
-
const startCommand = previous?.start?.command || look.startCommands[0]?.command;
|
|
6024
|
+
const startCommand = previous?.start?.command || input.start?.[0]?.command || look.startCommands[0]?.command;
|
|
5278
6025
|
return {
|
|
5279
6026
|
config: {
|
|
5280
6027
|
version: 1,
|
|
@@ -5333,6 +6080,20 @@ function titleCase(name) {
|
|
|
5333
6080
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
5334
6081
|
}
|
|
5335
6082
|
function applyAnswer(config, question, value) {
|
|
6083
|
+
if (question.id.startsWith("unheld:")) {
|
|
6084
|
+
const name = question.id.slice(7);
|
|
6085
|
+
return {
|
|
6086
|
+
...config,
|
|
6087
|
+
tools: config.tools.map((t) => t.name === name ? value === "run-unheld" ? {
|
|
6088
|
+
...t,
|
|
6089
|
+
during_reviews: "run",
|
|
6090
|
+
run_unheld: true
|
|
6091
|
+
} : {
|
|
6092
|
+
...t,
|
|
6093
|
+
during_reviews: "skip"
|
|
6094
|
+
} : t)
|
|
6095
|
+
};
|
|
6096
|
+
}
|
|
5336
6097
|
if (question.id.startsWith("tool:")) {
|
|
5337
6098
|
const name = question.id.slice(5);
|
|
5338
6099
|
if (value === "skip") return {
|
|
@@ -17628,8 +18389,81 @@ function refine(fn, _params = {}) {
|
|
|
17628
18389
|
function superRefine(fn, params) {
|
|
17629
18390
|
return /* @__PURE__ */ _superRefine(fn, params);
|
|
17630
18391
|
}
|
|
17631
|
-
// ../../packages/schema/src/
|
|
18392
|
+
// ../../packages/schema/src/endpoint.ts
|
|
17632
18393
|
var import_dist = require_dist();
|
|
18394
|
+
/** A system reached over HTTP: where to send a message, and where its answer comes back. */
|
|
18395
|
+
var Endpoint = object({
|
|
18396
|
+
url: string(),
|
|
18397
|
+
method: _enum([
|
|
18398
|
+
"POST",
|
|
18399
|
+
"PUT",
|
|
18400
|
+
"PATCH"
|
|
18401
|
+
]).default("POST"),
|
|
18402
|
+
/** Sent with every attempt. A credential's value is never here: it is in a secret, by name. */
|
|
18403
|
+
headers: array(object({
|
|
18404
|
+
name: string(),
|
|
18405
|
+
value: string().optional(),
|
|
18406
|
+
secret: string().optional()
|
|
18407
|
+
})).default([]),
|
|
18408
|
+
/** The request body with "{{message}}" and "{{session}}" where they go. */
|
|
18409
|
+
template: string(),
|
|
18410
|
+
message: string(),
|
|
18411
|
+
session: string().optional(),
|
|
18412
|
+
/** Where the answer is, in the reply. Empty for a reply that is plain text. */
|
|
18413
|
+
reply: string(),
|
|
18414
|
+
/**
|
|
18415
|
+
* `chat`: it speaks chat completions, so its tool calls come back in the reply and are held,
|
|
18416
|
+
* and the prompt canary goes in its system prompt. ADR 0067.
|
|
18417
|
+
*/
|
|
18418
|
+
kind: _enum(["http", "chat"]).default("http"),
|
|
18419
|
+
/** For `chat`, the tools its request offers, and what each can do, as the person confirmed. */
|
|
18420
|
+
tools: array(object({
|
|
18421
|
+
name: string(),
|
|
18422
|
+
can: string()
|
|
18423
|
+
})).default([])
|
|
18424
|
+
});
|
|
18425
|
+
/** A value at a dotted path, where a negative number counts from the end of a list. */
|
|
18426
|
+
function atPath(value, path) {
|
|
18427
|
+
let at = value;
|
|
18428
|
+
for (const part of path.split(".")) if (Array.isArray(at)) {
|
|
18429
|
+
const n = Number(part);
|
|
18430
|
+
at = at[n < 0 ? at.length + n : n];
|
|
18431
|
+
} else if (at && typeof at === "object") at = at[part];
|
|
18432
|
+
else return void 0;
|
|
18433
|
+
return at;
|
|
18434
|
+
}
|
|
18435
|
+
/** The request body: the template with the message and session in place, or one field each. */
|
|
18436
|
+
function requestBody(entry, message, session) {
|
|
18437
|
+
if (entry.template) return entry.template.replaceAll("\"{{message}}\"", JSON.stringify(message)).replaceAll("\"{{session}}\"", JSON.stringify(session));
|
|
18438
|
+
const body = { [entry.message]: message };
|
|
18439
|
+
if (entry.session) body[entry.session] = session;
|
|
18440
|
+
return JSON.stringify(body);
|
|
18441
|
+
}
|
|
18442
|
+
/** The reply's text, from JSON at a path, from a stream of events, or as plain text. */
|
|
18443
|
+
function replyFrom(contentType, text, path) {
|
|
18444
|
+
const fromJson = (data) => {
|
|
18445
|
+
const found = atPath(data, path);
|
|
18446
|
+
if (typeof found === "string") return found;
|
|
18447
|
+
return found === void 0 ? "" : JSON.stringify(found);
|
|
18448
|
+
};
|
|
18449
|
+
if (contentType.includes("event-stream") || /^data:/m.test(text)) return text.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).filter((d) => d && d !== "[DONE]").map((d) => {
|
|
18450
|
+
try {
|
|
18451
|
+
const event = JSON.parse(d);
|
|
18452
|
+
const named = fromJson(event);
|
|
18453
|
+
if (named) return named;
|
|
18454
|
+
const delta = atPath(event, "choices.0.delta.content") ?? atPath(event, "delta.text") ?? atPath(event, "text");
|
|
18455
|
+
return typeof delta === "string" ? delta : "";
|
|
18456
|
+
} catch {
|
|
18457
|
+
return d;
|
|
18458
|
+
}
|
|
18459
|
+
}).join("");
|
|
18460
|
+
try {
|
|
18461
|
+
return fromJson(JSON.parse(text));
|
|
18462
|
+
} catch {
|
|
18463
|
+
return text;
|
|
18464
|
+
}
|
|
18465
|
+
}
|
|
18466
|
+
// ../../packages/schema/src/domain.ts
|
|
17633
18467
|
var Timestamp = string();
|
|
17634
18468
|
var Area = _enum([
|
|
17635
18469
|
"security",
|
|
@@ -17668,11 +18502,32 @@ var GraderState = _enum([
|
|
|
17668
18502
|
"advises",
|
|
17669
18503
|
"counted"
|
|
17670
18504
|
]);
|
|
18505
|
+
/** What a person may do in a workspace. Owners also add and remove members and change roles. */
|
|
18506
|
+
var Role = _enum(["owner", "member"]);
|
|
18507
|
+
var Member = object({
|
|
18508
|
+
/** Lower case, as IAP signs it. */
|
|
18509
|
+
email: string(),
|
|
18510
|
+
role: Role,
|
|
18511
|
+
addedBy: string(),
|
|
18512
|
+
addedAt: Timestamp
|
|
18513
|
+
});
|
|
17671
18514
|
var Workspace = object({
|
|
17672
18515
|
id: string(),
|
|
17673
18516
|
name: string(),
|
|
17674
18517
|
copiedFrom: string().optional(),
|
|
17675
|
-
createdAt: Timestamp
|
|
18518
|
+
createdAt: Timestamp,
|
|
18519
|
+
/**
|
|
18520
|
+
* Who may open it. Absent only on a workspace made before members existed, which everyone who
|
|
18521
|
+
* can sign in may open until its members are recorded. See ADR 0064.
|
|
18522
|
+
*/
|
|
18523
|
+
members: array(Member).optional(),
|
|
18524
|
+
/** The members' emails, kept in step with `members` in the same write, so a list can query it. */
|
|
18525
|
+
memberEmails: array(string()).optional(),
|
|
18526
|
+
/**
|
|
18527
|
+
* What a system's DNS record or response header shows to prove this workspace controls it.
|
|
18528
|
+
* Public by design: it proves control only for this workspace. Made when first asked for.
|
|
18529
|
+
*/
|
|
18530
|
+
proofValue: string().optional()
|
|
17676
18531
|
});
|
|
17677
18532
|
var SystemKind = _enum([
|
|
17678
18533
|
"agent",
|
|
@@ -17767,23 +18622,65 @@ var System = object({
|
|
|
17767
18622
|
kind: SystemKind,
|
|
17768
18623
|
team: string(),
|
|
17769
18624
|
connection: object({
|
|
17770
|
-
method: _enum([
|
|
18625
|
+
method: _enum([
|
|
18626
|
+
"scan",
|
|
18627
|
+
"http",
|
|
18628
|
+
"openai-compatible",
|
|
18629
|
+
"mcp"
|
|
18630
|
+
]),
|
|
17771
18631
|
reach: object({
|
|
17772
|
-
readsReplies:
|
|
18632
|
+
readsReplies: boolean(),
|
|
17773
18633
|
holdsActions: union([_enum(["held", "seen"]), literal(false)]),
|
|
17774
18634
|
plantsContent: boolean()
|
|
17775
18635
|
}),
|
|
17776
18636
|
runner: string(),
|
|
17777
18637
|
ownership: object({
|
|
17778
|
-
|
|
18638
|
+
/** `read-only`: nothing is sent but reads, as for an MCP server, so nothing needs proving. */
|
|
18639
|
+
how: _enum([
|
|
18640
|
+
"code",
|
|
18641
|
+
"dns",
|
|
18642
|
+
"header",
|
|
18643
|
+
"read-only"
|
|
18644
|
+
]),
|
|
17779
18645
|
checkedAt: Timestamp
|
|
17780
18646
|
}),
|
|
17781
|
-
secretRefs: array(string())
|
|
18647
|
+
secretRefs: array(string()),
|
|
18648
|
+
/** For a system reached over HTTP, how to send it a message. */
|
|
18649
|
+
endpoint: Endpoint.optional()
|
|
17782
18650
|
}),
|
|
17783
18651
|
fingerprint: string(),
|
|
17784
18652
|
map: SystemMap.optional(),
|
|
18653
|
+
/**
|
|
18654
|
+
* For an MCP server: what it tells the agents that load it, as last read, the hashes a person
|
|
18655
|
+
* approved, and A9's result for each tool. No tool is ever called. ADR 0068.
|
|
18656
|
+
*/
|
|
18657
|
+
toolServer: object({
|
|
18658
|
+
url: string(),
|
|
18659
|
+
headers: array(object({
|
|
18660
|
+
name: string(),
|
|
18661
|
+
value: string().optional(),
|
|
18662
|
+
secret: string().optional()
|
|
18663
|
+
})).default([]),
|
|
18664
|
+
server: object({
|
|
18665
|
+
name: string(),
|
|
18666
|
+
version: string()
|
|
18667
|
+
}),
|
|
18668
|
+
tools: array(object({
|
|
18669
|
+
name: string(),
|
|
18670
|
+
description: string(),
|
|
18671
|
+
hash: string()
|
|
18672
|
+
})),
|
|
18673
|
+
approved: record(string(), string()),
|
|
18674
|
+
approvedAt: Timestamp,
|
|
18675
|
+
approvedBy: string(),
|
|
18676
|
+
readAt: Timestamp,
|
|
18677
|
+
results: array(object({
|
|
18678
|
+
tool: string(),
|
|
18679
|
+
status: _enum(["broke", "held"]),
|
|
18680
|
+
reasons: array(string())
|
|
18681
|
+
}))
|
|
18682
|
+
}).optional(),
|
|
17785
18683
|
tools: array(ToolSetup).default([]),
|
|
17786
|
-
sample: boolean().default(false),
|
|
17787
18684
|
summaryText: string().optional(),
|
|
17788
18685
|
policyId: string().optional(),
|
|
17789
18686
|
languages: array(string()).default(["English"]),
|
|
@@ -18248,8 +19145,16 @@ var TokenRecord = object({
|
|
|
18248
19145
|
hash: string(),
|
|
18249
19146
|
createdAt: Timestamp,
|
|
18250
19147
|
lastUsedAt: Timestamp.optional(),
|
|
19148
|
+
/** When a runner on this token last asked for an attempt, for "N runners online". */
|
|
19149
|
+
lastPollAt: Timestamp.optional(),
|
|
18251
19150
|
revokedAt: Timestamp.optional()
|
|
18252
19151
|
});
|
|
19152
|
+
/**
|
|
19153
|
+
* Parts of the product that are built but not yet shown, each switched on by the FEATURES
|
|
19154
|
+
* setting. A part leaves this list once it fully works and is released. See ADR 0057.
|
|
19155
|
+
*/
|
|
19156
|
+
var FEATURE_NAMES = ["playground"];
|
|
19157
|
+
var Feature = _enum(FEATURE_NAMES);
|
|
18253
19158
|
// ../../packages/schema/src/protocol.ts
|
|
18254
19159
|
/** One thing for the runner to do. Sent only over the runner route, never stored in Firestore. */
|
|
18255
19160
|
var RunnerAttempt = object({
|
|
@@ -18291,7 +19196,14 @@ var RunnerResult = object({
|
|
|
18291
19196
|
/** Whether the app read the way in, so the planted text was delivered. */
|
|
18292
19197
|
plantDelivered: boolean().default(false),
|
|
18293
19198
|
retrieved: array(string()).default([]),
|
|
18294
|
-
latencyMs: number().default(0)
|
|
19199
|
+
latencyMs: number().default(0),
|
|
19200
|
+
/** Calls the edge stopped on their way out of the machine: host and path, never the body. */
|
|
19201
|
+
edgeHeld: array(object({
|
|
19202
|
+
method: string(),
|
|
19203
|
+
host: string(),
|
|
19204
|
+
path: string(),
|
|
19205
|
+
bytes: number().default(0)
|
|
19206
|
+
})).default([])
|
|
18295
19207
|
});
|
|
18296
19208
|
var RunnerNextRequest = object({
|
|
18297
19209
|
systemId: string(),
|
|
@@ -18323,18 +19235,20 @@ var ScanOutline = object({
|
|
|
18323
19235
|
to: string()
|
|
18324
19236
|
}))
|
|
18325
19237
|
});
|
|
19238
|
+
/** What a tool can do, in the words the product uses. */
|
|
19239
|
+
var ToolPower = _enum([
|
|
19240
|
+
"read",
|
|
19241
|
+
"read private data",
|
|
19242
|
+
"change",
|
|
19243
|
+
"send outside",
|
|
19244
|
+
"move money",
|
|
19245
|
+
"delete"
|
|
19246
|
+
]);
|
|
18326
19247
|
/** What the model works out from an outline. Items below 0.7 become questions. */
|
|
18327
19248
|
var Understanding = object({
|
|
18328
19249
|
tools: array(object({
|
|
18329
19250
|
name: string(),
|
|
18330
|
-
can:
|
|
18331
|
-
"read",
|
|
18332
|
-
"read private data",
|
|
18333
|
-
"change",
|
|
18334
|
-
"send outside",
|
|
18335
|
-
"move money",
|
|
18336
|
-
"delete"
|
|
18337
|
-
]),
|
|
19251
|
+
can: ToolPower,
|
|
18338
19252
|
confidence: number()
|
|
18339
19253
|
})),
|
|
18340
19254
|
waysIn: array(object({
|
|
@@ -18356,12 +19270,181 @@ var Understanding = object({
|
|
|
18356
19270
|
kind: SystemKind.optional(),
|
|
18357
19271
|
summary: string()
|
|
18358
19272
|
});
|
|
19273
|
+
var Where = {
|
|
19274
|
+
file: string(),
|
|
19275
|
+
line: number().int().min(1)
|
|
19276
|
+
};
|
|
19277
|
+
var Sure = { confidence: number().min(0).max(1) };
|
|
19278
|
+
var FileOutline = object({
|
|
19279
|
+
file: string(),
|
|
19280
|
+
lines: number(),
|
|
19281
|
+
imports: array(string()),
|
|
19282
|
+
env: array(string()),
|
|
19283
|
+
symbols: array(object({
|
|
19284
|
+
kind: _enum([
|
|
19285
|
+
"function",
|
|
19286
|
+
"method",
|
|
19287
|
+
"class"
|
|
19288
|
+
]),
|
|
19289
|
+
name: string(),
|
|
19290
|
+
line: number(),
|
|
19291
|
+
end: number(),
|
|
19292
|
+
signature: string().optional(),
|
|
19293
|
+
decorators: array(string()).optional(),
|
|
19294
|
+
doc: string().optional(),
|
|
19295
|
+
bases: array(string()).optional(),
|
|
19296
|
+
calls: array(string()).optional()
|
|
19297
|
+
})),
|
|
19298
|
+
declarations: array(object({
|
|
19299
|
+
name: string().optional(),
|
|
19300
|
+
callee: string(),
|
|
19301
|
+
line: number(),
|
|
19302
|
+
end: number(),
|
|
19303
|
+
args: record(string(), string())
|
|
19304
|
+
})),
|
|
19305
|
+
strings: array(object({
|
|
19306
|
+
line: number(),
|
|
19307
|
+
name: string().optional(),
|
|
19308
|
+
text: string()
|
|
19309
|
+
}))
|
|
19310
|
+
});
|
|
19311
|
+
var ScanOutlineV2 = object({
|
|
19312
|
+
version: literal(2),
|
|
19313
|
+
name: string(),
|
|
19314
|
+
files: array(string()),
|
|
19315
|
+
dependencies: array(string()),
|
|
19316
|
+
scripts: array(object({
|
|
19317
|
+
command: string(),
|
|
19318
|
+
from: string()
|
|
19319
|
+
})),
|
|
19320
|
+
/** Names of settings from example settings files. Never a value. */
|
|
19321
|
+
settings: array(string()),
|
|
19322
|
+
code: array(FileOutline),
|
|
19323
|
+
/** Small prompt and configuration files the code reads, such as config/agents.yaml. */
|
|
19324
|
+
documents: array(object({
|
|
19325
|
+
file: string(),
|
|
19326
|
+
text: string()
|
|
19327
|
+
})),
|
|
19328
|
+
/** What the framework adapters read for certain, as hints. */
|
|
19329
|
+
found: object({
|
|
19330
|
+
tools: array(object({
|
|
19331
|
+
name: string(),
|
|
19332
|
+
...Where
|
|
19333
|
+
})),
|
|
19334
|
+
waysIn: array(object({
|
|
19335
|
+
name: string(),
|
|
19336
|
+
...Where
|
|
19337
|
+
})),
|
|
19338
|
+
systemPrompts: array(object({ ...Where }))
|
|
19339
|
+
}),
|
|
19340
|
+
/** Files left out to keep the outline small, named so the model knows they exist. */
|
|
19341
|
+
omitted: array(string()),
|
|
19342
|
+
/** Only after the person said yes to a closer look: the functions it asked to read. */
|
|
19343
|
+
bodies: array(object({
|
|
19344
|
+
...Where,
|
|
19345
|
+
name: string(),
|
|
19346
|
+
text: string()
|
|
19347
|
+
})).optional()
|
|
19348
|
+
});
|
|
19349
|
+
/** How a tool's effect leaves the app, which decides whether a review can hold it at the edge. */
|
|
19350
|
+
var EffectChannel = _enum([
|
|
19351
|
+
"none",
|
|
19352
|
+
"http",
|
|
19353
|
+
"email",
|
|
19354
|
+
"database",
|
|
19355
|
+
"file",
|
|
19356
|
+
"subprocess",
|
|
19357
|
+
"code",
|
|
19358
|
+
"unknown"
|
|
19359
|
+
]);
|
|
19360
|
+
var UnderstandingV3 = object({
|
|
19361
|
+
agents: array(object({
|
|
19362
|
+
name: string(),
|
|
19363
|
+
...Where,
|
|
19364
|
+
...Sure
|
|
19365
|
+
})),
|
|
19366
|
+
modelCalls: array(object({
|
|
19367
|
+
provider: string(),
|
|
19368
|
+
model: string().optional(),
|
|
19369
|
+
...Where,
|
|
19370
|
+
...Sure
|
|
19371
|
+
})),
|
|
19372
|
+
tools: array(object({
|
|
19373
|
+
name: string(),
|
|
19374
|
+
...Where,
|
|
19375
|
+
description: string().optional(),
|
|
19376
|
+
can: ToolPower,
|
|
19377
|
+
leavesBy: EffectChannel,
|
|
19378
|
+
host: string().optional(),
|
|
19379
|
+
...Sure
|
|
19380
|
+
})),
|
|
19381
|
+
systemPrompts: array(object({
|
|
19382
|
+
...Where,
|
|
19383
|
+
...Sure
|
|
19384
|
+
})),
|
|
19385
|
+
waysIn: array(object({
|
|
19386
|
+
name: string(),
|
|
19387
|
+
kind: _enum([
|
|
19388
|
+
"conversation",
|
|
19389
|
+
"webhook",
|
|
19390
|
+
"email",
|
|
19391
|
+
"documents",
|
|
19392
|
+
"web",
|
|
19393
|
+
"tool-result",
|
|
19394
|
+
"mcp",
|
|
19395
|
+
"files",
|
|
19396
|
+
"other"
|
|
19397
|
+
]),
|
|
19398
|
+
who: string(),
|
|
19399
|
+
untrusted: boolean(),
|
|
19400
|
+
...Where,
|
|
19401
|
+
...Sure
|
|
19402
|
+
})),
|
|
19403
|
+
privateData: array(object({
|
|
19404
|
+
name: string(),
|
|
19405
|
+
...Sure
|
|
19406
|
+
})),
|
|
19407
|
+
entry: object({
|
|
19408
|
+
kind: _enum([
|
|
19409
|
+
"http",
|
|
19410
|
+
"command",
|
|
19411
|
+
"none"
|
|
19412
|
+
]),
|
|
19413
|
+
method: string().optional(),
|
|
19414
|
+
path: string().optional(),
|
|
19415
|
+
message: string().optional(),
|
|
19416
|
+
reply: string().optional(),
|
|
19417
|
+
/** A JSON body with "{{message}}" where the message goes, when it is not one field. */
|
|
19418
|
+
request: string().optional(),
|
|
19419
|
+
streaming: boolean().optional(),
|
|
19420
|
+
...Where,
|
|
19421
|
+
...Sure
|
|
19422
|
+
}).nullable(),
|
|
19423
|
+
start: array(object({
|
|
19424
|
+
command: string(),
|
|
19425
|
+
why: string(),
|
|
19426
|
+
...Sure
|
|
19427
|
+
})),
|
|
19428
|
+
rules: array(object({
|
|
19429
|
+
never: string(),
|
|
19430
|
+
tool: string(),
|
|
19431
|
+
check: string(),
|
|
19432
|
+
...Sure
|
|
19433
|
+
})),
|
|
19434
|
+
/** Functions it could not judge from their outline, with why, for a closer look. */
|
|
19435
|
+
unclear: array(object({
|
|
19436
|
+
...Where,
|
|
19437
|
+
name: string(),
|
|
19438
|
+
why: string()
|
|
19439
|
+
})),
|
|
19440
|
+
kind: SystemKind.optional(),
|
|
19441
|
+
summary: string()
|
|
19442
|
+
});
|
|
18359
19443
|
/** What the scan's last step sends to connect a system. */
|
|
18360
19444
|
var RegisterSystem = object({
|
|
18361
19445
|
name: string(),
|
|
18362
19446
|
kind: SystemKind,
|
|
18363
19447
|
team: string().default("Not set"),
|
|
18364
|
-
sample: boolean().default(false),
|
|
18365
19448
|
waysIn: array(WayIn),
|
|
18366
19449
|
rules: array(MapRule),
|
|
18367
19450
|
tools: array(ToolSetup),
|
|
@@ -18402,6 +19485,37 @@ var ApiError = object({
|
|
|
18402
19485
|
error: string(),
|
|
18403
19486
|
detail: string().optional()
|
|
18404
19487
|
});
|
|
19488
|
+
var FailureFacts = object({
|
|
19489
|
+
command: string(),
|
|
19490
|
+
output: array(string()).max(80),
|
|
19491
|
+
scripts: array(string()),
|
|
19492
|
+
settings: array(string()),
|
|
19493
|
+
settingsFiles: array(string()),
|
|
19494
|
+
files: array(string()),
|
|
19495
|
+
tools: array(string()),
|
|
19496
|
+
/** The commands this machine has on its PATH, so a fix never names one it lacks. */
|
|
19497
|
+
available: array(string()).default([])
|
|
19498
|
+
});
|
|
19499
|
+
var FailureFix = object({
|
|
19500
|
+
kind: _enum([
|
|
19501
|
+
"set-setting",
|
|
19502
|
+
"use-settings-file",
|
|
19503
|
+
"change-command",
|
|
19504
|
+
"change-port",
|
|
19505
|
+
"run-command",
|
|
19506
|
+
"skip-tools"
|
|
19507
|
+
]),
|
|
19508
|
+
label: string(),
|
|
19509
|
+
setting: string().optional(),
|
|
19510
|
+
file: string().optional(),
|
|
19511
|
+
command: string().optional(),
|
|
19512
|
+
tools: array(string()).optional()
|
|
19513
|
+
});
|
|
19514
|
+
var FailureExplanation = object({
|
|
19515
|
+
what: string(),
|
|
19516
|
+
cause: string(),
|
|
19517
|
+
fixes: array(FailureFix).max(3)
|
|
19518
|
+
});
|
|
18405
19519
|
// ../../packages/schema/src/config.ts
|
|
18406
19520
|
var ConfigWayIn = object({
|
|
18407
19521
|
name: string(),
|
|
@@ -18426,7 +19540,19 @@ var ConfigTool = object({
|
|
|
18426
19540
|
"skip"
|
|
18427
19541
|
]),
|
|
18428
19542
|
/** Argument names read from the code, so held calls show them. */
|
|
18429
|
-
params: array(string()).optional()
|
|
19543
|
+
params: array(string()).optional(),
|
|
19544
|
+
/**
|
|
19545
|
+
* How a review stops it: a hook on its function, the edge when everything it does leaves over
|
|
19546
|
+
* HTTP, or none, when it writes to a database, a file or a process the review cannot stop.
|
|
19547
|
+
* ADR 0062.
|
|
19548
|
+
*/
|
|
19549
|
+
held_by: _enum([
|
|
19550
|
+
"function",
|
|
19551
|
+
"edge",
|
|
19552
|
+
"none"
|
|
19553
|
+
]).optional(),
|
|
19554
|
+
/** The person's explicit yes to letting a tool that cannot be held run during reviews. */
|
|
19555
|
+
run_unheld: boolean().optional()
|
|
18430
19556
|
});
|
|
18431
19557
|
var ConfigRule = object({
|
|
18432
19558
|
id: string(),
|
|
@@ -18454,7 +19580,11 @@ var ModelosConfig = object({
|
|
|
18454
19580
|
method: string().default("POST"),
|
|
18455
19581
|
message: string().default("message"),
|
|
18456
19582
|
session: string().optional(),
|
|
18457
|
-
reply: string().default("reply")
|
|
19583
|
+
reply: string().default("reply"),
|
|
19584
|
+
/** A JSON body with "{{message}}" and "{{session}}" where they go. */
|
|
19585
|
+
template: string().optional(),
|
|
19586
|
+
/** Whether the reply streams as server-sent events. */
|
|
19587
|
+
stream: boolean().optional()
|
|
18458
19588
|
}).optional()
|
|
18459
19589
|
}).optional(),
|
|
18460
19590
|
ways_in: array(ConfigWayIn).default([]),
|
|
@@ -18467,7 +19597,14 @@ var ModelosConfig = object({
|
|
|
18467
19597
|
tool: string(),
|
|
18468
19598
|
choice: string()
|
|
18469
19599
|
})).optional(),
|
|
18470
|
-
skipped: array(string()).optional()
|
|
19600
|
+
skipped: array(string()).optional(),
|
|
19601
|
+
/** During reviews, hosts the app may still reach, such as a staging copy of its own API. */
|
|
19602
|
+
edge: object({ allow: array(string()).default([]) }).optional(),
|
|
19603
|
+
/**
|
|
19604
|
+
* Functions whose code the person let the scan send for a closer look, as file:function, or "no"
|
|
19605
|
+
* when they said no. Asked once. ADR 0061.
|
|
19606
|
+
*/
|
|
19607
|
+
closer_look: union([array(string()), literal("no")]).optional()
|
|
18471
19608
|
});
|
|
18472
19609
|
// ../../packages/schema/src/policy.ts
|
|
18473
19610
|
var clause = (id, area, text, severity, line, neverTested = false) => ({
|
|
@@ -18522,6 +19659,86 @@ function renderConfig(config) {
|
|
|
18522
19659
|
async function writeConfig(dir, config) {
|
|
18523
19660
|
await writeFile(join(dir, CONFIG_FILE), renderConfig(config));
|
|
18524
19661
|
}
|
|
19662
|
+
// ../../hooks/node/src/hosts.mjs
|
|
19663
|
+
var MODEL_HOSTS = [
|
|
19664
|
+
/(^|\.)api\.openai\.com$/,
|
|
19665
|
+
/\.openai\.azure\.com$/,
|
|
19666
|
+
/(^|\.)api\.anthropic\.com$/,
|
|
19667
|
+
/(^|\.)generativelanguage\.googleapis\.com$/,
|
|
19668
|
+
/aiplatform\.googleapis\.com$/,
|
|
19669
|
+
/^(oauth2|sts|iamcredentials)\.googleapis\.com$/,
|
|
19670
|
+
/^bedrock-runtime\.[\w-]+\.amazonaws\.com$/,
|
|
19671
|
+
/^(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)$/,
|
|
19672
|
+
/^login\.microsoftonline\.com$/
|
|
19673
|
+
];
|
|
19674
|
+
var LOOPBACK = /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0)$/;
|
|
19675
|
+
/** Whether a call to this host may leave during an attempt. */
|
|
19676
|
+
function allowedHost(host, allow = []) {
|
|
19677
|
+
const name = String(host).toLowerCase();
|
|
19678
|
+
return LOOPBACK.test(name) || MODEL_HOSTS.some((p) => p.test(name)) || allow.some((a) => name === a || name.endsWith(`.${a}`));
|
|
19679
|
+
}
|
|
19680
|
+
// src/proxy.ts
|
|
19681
|
+
async function startProxy(allow) {
|
|
19682
|
+
const stopped = [];
|
|
19683
|
+
let inFlight = 0;
|
|
19684
|
+
const passes = (host) => inFlight === 0 || allowedHost(host, allow);
|
|
19685
|
+
const server = createServer((req, res) => {
|
|
19686
|
+
const target = new URL(req.url ?? "/", "http://unknown");
|
|
19687
|
+
if (!passes(target.hostname)) {
|
|
19688
|
+
stopped.push({
|
|
19689
|
+
at: Date.now(),
|
|
19690
|
+
method: req.method ?? "GET",
|
|
19691
|
+
host: target.hostname,
|
|
19692
|
+
path: target.pathname,
|
|
19693
|
+
bytes: Number(req.headers["content-length"] ?? 0)
|
|
19694
|
+
});
|
|
19695
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
19696
|
+
res.end("Stopped by ModelOS Secure during a review.");
|
|
19697
|
+
return;
|
|
19698
|
+
}
|
|
19699
|
+
const upstream = request(target, {
|
|
19700
|
+
method: req.method,
|
|
19701
|
+
headers: req.headers
|
|
19702
|
+
});
|
|
19703
|
+
upstream.on("response", (answer) => {
|
|
19704
|
+
res.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
19705
|
+
answer.pipe(res);
|
|
19706
|
+
});
|
|
19707
|
+
upstream.on("error", () => res.destroy());
|
|
19708
|
+
req.pipe(upstream);
|
|
19709
|
+
});
|
|
19710
|
+
server.on("connect", (req, client, head) => {
|
|
19711
|
+
const [host = "", port = "443"] = (req.url ?? "").split(":");
|
|
19712
|
+
if (!passes(host)) {
|
|
19713
|
+
stopped.push({
|
|
19714
|
+
at: Date.now(),
|
|
19715
|
+
method: "CONNECT",
|
|
19716
|
+
host,
|
|
19717
|
+
path: "",
|
|
19718
|
+
bytes: 0
|
|
19719
|
+
});
|
|
19720
|
+
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
19721
|
+
return;
|
|
19722
|
+
}
|
|
19723
|
+
const upstream = connect(Number(port), host, () => {
|
|
19724
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
19725
|
+
upstream.write(head);
|
|
19726
|
+
upstream.pipe(client);
|
|
19727
|
+
client.pipe(upstream);
|
|
19728
|
+
});
|
|
19729
|
+
upstream.on("error", () => client.destroy());
|
|
19730
|
+
client.on("error", () => upstream.destroy());
|
|
19731
|
+
});
|
|
19732
|
+
await new Promise((done) => server.listen(0, "127.0.0.1", done));
|
|
19733
|
+
const address = server.address();
|
|
19734
|
+
return {
|
|
19735
|
+
url: `http://127.0.0.1:${(typeof address === "object" && address ? address.port : 0).toString()}`,
|
|
19736
|
+
stoppedSince: (at) => stopped.filter((s) => s.at >= at),
|
|
19737
|
+
begin: () => void (inFlight += 1),
|
|
19738
|
+
end: () => void (inFlight = Math.max(0, inFlight - 1)),
|
|
19739
|
+
stop: () => server.close()
|
|
19740
|
+
};
|
|
19741
|
+
}
|
|
18525
19742
|
// src/runner.ts
|
|
18526
19743
|
/** A customer's app can die on one bad reply, so it is started again, but not forever. */
|
|
18527
19744
|
var MAX_RESTARTS = 20;
|
|
@@ -18540,9 +19757,14 @@ function hookConfig(config, dir) {
|
|
|
18540
19757
|
name: way.name,
|
|
18541
19758
|
function: way.function
|
|
18542
19759
|
})),
|
|
18543
|
-
files: [...new Set(files)]
|
|
19760
|
+
files: [...new Set(files)],
|
|
19761
|
+
edge: { allow: config.edge?.allow ?? [] }
|
|
18544
19762
|
};
|
|
18545
19763
|
}
|
|
19764
|
+
/** The reply's text, from JSON at a path, from a stream of events, or as plain text. */
|
|
19765
|
+
async function replyText(response, path) {
|
|
19766
|
+
return replyFrom(response.headers.get("content-type") ?? "", await response.text(), path);
|
|
19767
|
+
}
|
|
18546
19768
|
async function waitForPort(port, timeoutMs, stillRunning) {
|
|
18547
19769
|
const deadline = Date.now() + timeoutMs;
|
|
18548
19770
|
while (Date.now() < deadline && stillRunning()) {
|
|
@@ -18595,6 +19817,7 @@ async function startAppPool(input) {
|
|
|
18595
19817
|
const address = server.address();
|
|
18596
19818
|
const runnerUrl = `http://127.0.0.1:${typeof address === "object" && address ? address.port.toString() : "0"}`;
|
|
18597
19819
|
const hookJson = JSON.stringify(hookConfig(config, dir));
|
|
19820
|
+
const proxy = await startProxy(config.edge?.allow ?? []);
|
|
18598
19821
|
let stopping = false;
|
|
18599
19822
|
let serving = false;
|
|
18600
19823
|
let restarts = 0;
|
|
@@ -18621,7 +19844,13 @@ async function startAppPool(input) {
|
|
|
18621
19844
|
PYTHONPATH: `${hookPython()}:${process.env.PYTHONPATH ?? ""}`,
|
|
18622
19845
|
MODELOS_SECURE_HOOK: hookJson,
|
|
18623
19846
|
MODELOS_SECURE_RUNNER_URL: runnerUrl,
|
|
18624
|
-
MODELOS_SECURE_INSTANCE: instance.id
|
|
19847
|
+
MODELOS_SECURE_INSTANCE: instance.id,
|
|
19848
|
+
HTTP_PROXY: proxy.url,
|
|
19849
|
+
HTTPS_PROXY: proxy.url,
|
|
19850
|
+
http_proxy: proxy.url,
|
|
19851
|
+
https_proxy: proxy.url,
|
|
19852
|
+
NO_PROXY: "localhost,127.0.0.1,::1",
|
|
19853
|
+
no_proxy: "localhost,127.0.0.1,::1"
|
|
18625
19854
|
}
|
|
18626
19855
|
});
|
|
18627
19856
|
instance.child = child;
|
|
@@ -18675,6 +19904,7 @@ async function startAppPool(input) {
|
|
|
18675
19904
|
if (instance.child.pid) process.kill(-instance.child.pid, "SIGTERM");
|
|
18676
19905
|
} catch {}
|
|
18677
19906
|
server.close();
|
|
19907
|
+
proxy.stop();
|
|
18678
19908
|
};
|
|
18679
19909
|
process.once("exit", stop);
|
|
18680
19910
|
for (const signal of ["SIGTERM", "SIGHUP"]) process.once(signal, () => process.exit(signal === "SIGTERM" ? 143 : 129));
|
|
@@ -18691,7 +19921,13 @@ async function startAppPool(input) {
|
|
|
18691
19921
|
ranTools: [],
|
|
18692
19922
|
plantDelivered: false,
|
|
18693
19923
|
retrieved: [],
|
|
18694
|
-
latencyMs: Date.now() - started
|
|
19924
|
+
latencyMs: Date.now() - started,
|
|
19925
|
+
edgeHeld: proxy.stoppedSince(started).map(({ method, host, path, bytes }) => ({
|
|
19926
|
+
method,
|
|
19927
|
+
host,
|
|
19928
|
+
path,
|
|
19929
|
+
bytes
|
|
19930
|
+
}))
|
|
18695
19931
|
});
|
|
18696
19932
|
let instance;
|
|
18697
19933
|
try {
|
|
@@ -18702,16 +19938,25 @@ async function startAppPool(input) {
|
|
|
18702
19938
|
method: "POST",
|
|
18703
19939
|
body: JSON.stringify(attempt)
|
|
18704
19940
|
});
|
|
19941
|
+
proxy.begin();
|
|
18705
19942
|
const url = `http://127.0.0.1:${instance.port.toString()}${entry.path}`;
|
|
18706
|
-
const body = { [entry.message]: attempt.message };
|
|
18707
|
-
if (entry.session) body[entry.session] = attempt.session;
|
|
18708
19943
|
const response = await fetch(url, {
|
|
18709
19944
|
method: entry.method,
|
|
18710
19945
|
headers: { "content-type": "application/json" },
|
|
18711
|
-
body:
|
|
19946
|
+
body: requestBody(entry, attempt.message, attempt.session),
|
|
18712
19947
|
signal: AbortSignal.timeout(17e4)
|
|
18713
|
-
});
|
|
18714
|
-
const
|
|
19948
|
+
}).finally(() => proxy.end());
|
|
19949
|
+
const reply = response.ok ? await replyText(response, entry.reply) : "";
|
|
19950
|
+
const ended = await (await fetch(`${control}/end`, { method: "POST" })).json();
|
|
19951
|
+
const seen = {
|
|
19952
|
+
...ended,
|
|
19953
|
+
edgeHeld: [...ended.edgeHeld ?? [], ...proxy.stoppedSince(started).map(({ method, host, path, bytes }) => ({
|
|
19954
|
+
method,
|
|
19955
|
+
host,
|
|
19956
|
+
path,
|
|
19957
|
+
bytes
|
|
19958
|
+
}))]
|
|
19959
|
+
};
|
|
18715
19960
|
if (!response.ok) return {
|
|
18716
19961
|
...seen,
|
|
18717
19962
|
answered: false,
|
|
@@ -18719,11 +19964,10 @@ async function startAppPool(input) {
|
|
|
18719
19964
|
reply: "",
|
|
18720
19965
|
latencyMs: Date.now() - started
|
|
18721
19966
|
};
|
|
18722
|
-
const reply = await response.json();
|
|
18723
19967
|
return {
|
|
18724
19968
|
...seen,
|
|
18725
19969
|
answered: true,
|
|
18726
|
-
reply
|
|
19970
|
+
reply,
|
|
18727
19971
|
latencyMs: Date.now() - started
|
|
18728
19972
|
};
|
|
18729
19973
|
} catch (error) {
|
|
@@ -18870,14 +20114,145 @@ function settingsFixes(ctx, name, keyInShell) {
|
|
|
18870
20114
|
label: "Use the key already in your shell for this run",
|
|
18871
20115
|
apply: () => void 0
|
|
18872
20116
|
}] : [typed]];
|
|
18873
|
-
return [
|
|
18874
|
-
|
|
18875
|
-
|
|
18876
|
-
|
|
18877
|
-
|
|
18878
|
-
|
|
18879
|
-
|
|
18880
|
-
|
|
20117
|
+
return [fromFile, typed];
|
|
20118
|
+
}
|
|
20119
|
+
/**
|
|
20120
|
+
* The model's reading of a failure, through ModelOS, when the person let the outline leave: the
|
|
20121
|
+
* error's last lines go with it, secrets taken out. Without that consent the rules alone decide.
|
|
20122
|
+
*/
|
|
20123
|
+
function explainWith(api, config) {
|
|
20124
|
+
if (!api || config.consent !== "outline") return void 0;
|
|
20125
|
+
return (facts) => api.post("/scan/failure", facts).then((r) => r.explanation);
|
|
20126
|
+
}
|
|
20127
|
+
/** What a fix may name, from the repository itself when the caller did not say. */
|
|
20128
|
+
async function knownIn(ctx) {
|
|
20129
|
+
if (ctx.known) return ctx.known;
|
|
20130
|
+
const found = await look(ctx.dir);
|
|
20131
|
+
return {
|
|
20132
|
+
settings: found.settingNames,
|
|
20133
|
+
settingsFiles: found.settingsFiles,
|
|
20134
|
+
files: [...found.sourceFiles, ...found.documents].slice(0, 400),
|
|
20135
|
+
tools: ctx.config.tools.map((t) => t.name)
|
|
20136
|
+
};
|
|
20137
|
+
}
|
|
20138
|
+
/** Commands a fix might start with, kept when this machine has them on its PATH. */
|
|
20139
|
+
var RUNNERS = [
|
|
20140
|
+
"python",
|
|
20141
|
+
"python3",
|
|
20142
|
+
"pip",
|
|
20143
|
+
"pip3",
|
|
20144
|
+
"uv",
|
|
20145
|
+
"poetry",
|
|
20146
|
+
"pipenv",
|
|
20147
|
+
"node",
|
|
20148
|
+
"npm",
|
|
20149
|
+
"npx",
|
|
20150
|
+
"pnpm",
|
|
20151
|
+
"yarn",
|
|
20152
|
+
"bun",
|
|
20153
|
+
"make"
|
|
20154
|
+
];
|
|
20155
|
+
async function onPath(name) {
|
|
20156
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
20157
|
+
if (!dir) continue;
|
|
20158
|
+
if (await access(join(dir, name), constants.X_OK).then(() => true, () => false)) return true;
|
|
20159
|
+
}
|
|
20160
|
+
return false;
|
|
20161
|
+
}
|
|
20162
|
+
/** The last lines the app printed, with secrets taken out, for the model to read. */
|
|
20163
|
+
async function failureFacts(known, ctx, command, output) {
|
|
20164
|
+
const firstWords = [...RUNNERS, ...[command, ...ctx.startCommands.map((c) => c.command)].map((c) => c.trim().split(/\s+/)[0] ?? "")];
|
|
20165
|
+
const available = [];
|
|
20166
|
+
for (const name of new Set(firstWords.filter(Boolean))) if (await onPath(name)) available.push(name);
|
|
20167
|
+
return {
|
|
20168
|
+
command,
|
|
20169
|
+
output: redact(output).split("\n").filter((l) => l.trim()).slice(-60).map((l) => l.slice(0, 300)),
|
|
20170
|
+
scripts: ctx.startCommands.map((c) => c.command),
|
|
20171
|
+
settings: known.settings,
|
|
20172
|
+
settingsFiles: known.settingsFiles,
|
|
20173
|
+
files: known.files,
|
|
20174
|
+
tools: known.tools,
|
|
20175
|
+
available
|
|
20176
|
+
};
|
|
20177
|
+
}
|
|
20178
|
+
/** A fix the model proposed, as something the CLI does. */
|
|
20179
|
+
function toFix(ctx, fix) {
|
|
20180
|
+
const { config } = ctx;
|
|
20181
|
+
const save = () => ctx.onConfig(config);
|
|
20182
|
+
switch (fix.kind) {
|
|
20183
|
+
case "set-setting": return {
|
|
20184
|
+
label: fix.label,
|
|
20185
|
+
apply: async () => {
|
|
20186
|
+
ctx.extraEnv[fix.setting] = await askText(`${fix.setting}:`, true);
|
|
20187
|
+
}
|
|
20188
|
+
};
|
|
20189
|
+
case "use-settings-file": return {
|
|
20190
|
+
label: fix.label,
|
|
20191
|
+
apply: async () => {
|
|
20192
|
+
Object.assign(ctx.extraEnv, await loadSettings(ctx.dir, fix.file));
|
|
20193
|
+
config.start = {
|
|
20194
|
+
...config.start,
|
|
20195
|
+
settings: fix.file
|
|
20196
|
+
};
|
|
20197
|
+
await save();
|
|
20198
|
+
}
|
|
20199
|
+
};
|
|
20200
|
+
case "change-command": return {
|
|
20201
|
+
label: fix.label,
|
|
20202
|
+
apply: async () => {
|
|
20203
|
+
config.start = {
|
|
20204
|
+
...config.start,
|
|
20205
|
+
command: fix.command
|
|
20206
|
+
};
|
|
20207
|
+
await save();
|
|
20208
|
+
}
|
|
20209
|
+
};
|
|
20210
|
+
case "change-port": return {
|
|
20211
|
+
label: fix.label,
|
|
20212
|
+
apply: async () => {
|
|
20213
|
+
config.start = {
|
|
20214
|
+
...config.start,
|
|
20215
|
+
command: commandForPort(config.start?.command ?? "", await freePort())
|
|
20216
|
+
};
|
|
20217
|
+
await save();
|
|
20218
|
+
}
|
|
20219
|
+
};
|
|
20220
|
+
case "run-command": return {
|
|
20221
|
+
label: `${fix.label}: ${fix.command}`,
|
|
20222
|
+
needsPerson: true,
|
|
20223
|
+
apply: async () => {
|
|
20224
|
+
if (!interactive()) return;
|
|
20225
|
+
if (!await ask(`Run ${fix.command} in your repository now?`, void 0, [{
|
|
20226
|
+
label: "Yes, run it",
|
|
20227
|
+
value: true,
|
|
20228
|
+
recommended: true
|
|
20229
|
+
}, {
|
|
20230
|
+
label: "No, I will run it myself",
|
|
20231
|
+
value: false
|
|
20232
|
+
}])) return;
|
|
20233
|
+
const code = await new Promise((resolve) => {
|
|
20234
|
+
spawn(fix.command, {
|
|
20235
|
+
shell: true,
|
|
20236
|
+
cwd: ctx.dir,
|
|
20237
|
+
stdio: "inherit"
|
|
20238
|
+
}).once("exit", (c) => resolve(c ?? 1)).once("error", () => resolve(1));
|
|
20239
|
+
});
|
|
20240
|
+
if (code !== 0) say(` ${fix.command} stopped with code ${code.toString()}.`);
|
|
20241
|
+
}
|
|
20242
|
+
};
|
|
20243
|
+
case "skip-tools": return {
|
|
20244
|
+
label: fix.label,
|
|
20245
|
+
apply: async () => {
|
|
20246
|
+
const skipped = new Set(fix.tools);
|
|
20247
|
+
config.tools = config.tools.map((t) => skipped.has(t.name) ? {
|
|
20248
|
+
...t,
|
|
20249
|
+
during_reviews: "skip"
|
|
20250
|
+
} : t);
|
|
20251
|
+
config.skipped = [.../* @__PURE__ */ new Set([...config.skipped ?? [], ...skipped])];
|
|
20252
|
+
await save();
|
|
20253
|
+
}
|
|
20254
|
+
};
|
|
20255
|
+
}
|
|
18881
20256
|
}
|
|
18882
20257
|
/**
|
|
18883
20258
|
* Asks for the start command. An empty answer, or none at all when nobody is there to type, changes
|
|
@@ -18957,6 +20332,11 @@ async function startApp(ctx, instances) {
|
|
|
18957
20332
|
if (up.length === 0) {
|
|
18958
20333
|
pool.stop();
|
|
18959
20334
|
const output = await whyItStopped(ctx, commandForPort(command, basePort));
|
|
20335
|
+
const explained = output && ctx.explain ? await ctx.explain(await failureFacts(await knownIn(ctx), ctx, command, output)).catch(() => null) : null;
|
|
20336
|
+
if (explained) throw new Failure(explained.what, [...explained.fixes.map((f) => toFix(ctx, f)), {
|
|
20337
|
+
label: "Type the command",
|
|
20338
|
+
apply: () => typeTheCommand(ctx)
|
|
20339
|
+
}], explained.cause);
|
|
18960
20340
|
const missing = output ? missingSetting(output) : void 0;
|
|
18961
20341
|
if (missing && /(_API_KEY|_KEY|_TOKEN)$/.test(missing)) throw new Failure(`The app calls its model with ${missing}.`, settingsFixes(ctx, missing, Boolean(process.env[missing])));
|
|
18962
20342
|
if (missing) throw new Failure(`The app stopped: ${missing} is not set.`, settingsFixes(ctx, missing, false));
|
|
@@ -18978,12 +20358,6 @@ async function startApp(ctx, instances) {
|
|
|
18978
20358
|
};
|
|
18979
20359
|
await ctx.onConfig(config);
|
|
18980
20360
|
}
|
|
18981
|
-
}, {
|
|
18982
|
-
label: "Add the SDK instead (shown)",
|
|
18983
|
-
apply: () => {
|
|
18984
|
-
console.log("\n One line for each input and tool:\n import { intercept, untrusted } from \"modelos-secure/sdk\";\n");
|
|
18985
|
-
process.exit(0);
|
|
18986
|
-
}
|
|
18987
20361
|
}]);
|
|
18988
20362
|
}
|
|
18989
20363
|
return pool;
|
|
@@ -19011,9 +20385,9 @@ async function replayCommand(dir, findingId) {
|
|
|
19011
20385
|
dir,
|
|
19012
20386
|
config,
|
|
19013
20387
|
extraEnv: {},
|
|
19014
|
-
skipTools: [],
|
|
19015
20388
|
startCommands: (await scanFolder(dir)).look.startCommands,
|
|
19016
|
-
onConfig: () => Promise.resolve()
|
|
20389
|
+
onConfig: () => Promise.resolve(),
|
|
20390
|
+
explain: explainWith(api, config)
|
|
19017
20391
|
};
|
|
19018
20392
|
const pool = await withFixes(() => startApp(ctx, 1), {
|
|
19019
20393
|
yes: true,
|
|
@@ -19082,7 +20456,6 @@ async function doctorCommand(dir) {
|
|
|
19082
20456
|
dir,
|
|
19083
20457
|
config,
|
|
19084
20458
|
extraEnv: {},
|
|
19085
|
-
skipTools: [],
|
|
19086
20459
|
startCommands: (await scanFolder(dir)).look.startCommands,
|
|
19087
20460
|
onConfig: () => Promise.resolve()
|
|
19088
20461
|
};
|
|
@@ -19149,7 +20522,6 @@ function toRegister(config, parts, extra) {
|
|
|
19149
20522
|
name: config.system,
|
|
19150
20523
|
kind: config.kind ?? "agent",
|
|
19151
20524
|
team: "Not set",
|
|
19152
|
-
sample: extra.sample,
|
|
19153
20525
|
summary: extra.summary,
|
|
19154
20526
|
versionLabel: extra.versionLabel,
|
|
19155
20527
|
parts,
|
|
@@ -19193,9 +20565,16 @@ var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|
|
|
|
19193
20565
|
async function reviewCommand(dir, options) {
|
|
19194
20566
|
const config = await readConfig(dir);
|
|
19195
20567
|
if (!config) throw new Error("There is no modelos-secure.yaml here. Run modelos-secure scan . first.");
|
|
19196
|
-
if (options.ci && config.
|
|
20568
|
+
if (options.ci && (config.skipped?.length ?? 0) > 0) {
|
|
19197
20569
|
say("These questions need answering locally, because CI never guesses:");
|
|
19198
|
-
for (const
|
|
20570
|
+
for (const name of config.skipped ?? []) say(` Should reviews hold ${name}?`);
|
|
20571
|
+
process.exitCode = 1;
|
|
20572
|
+
return;
|
|
20573
|
+
}
|
|
20574
|
+
const unheld = config.tools.filter((t) => t.held_by === "none" && (t.during_reviews === "hold" || t.during_reviews === "run" && !t.run_unheld));
|
|
20575
|
+
if (unheld.length > 0) {
|
|
20576
|
+
say(`${unheld.map((t) => t.name).join(", ")} cannot be held during a review: what ${unheld.length === 1 ? "it does" : "they do"} does not pass through anything a review can stop.`);
|
|
20577
|
+
say(" In modelos-secure.yaml, set during_reviews to skip, or to run with run_unheld: true if it only reaches a staging copy. Or run modelos-secure scan . to answer it there.");
|
|
19199
20578
|
process.exitCode = 1;
|
|
19200
20579
|
return;
|
|
19201
20580
|
}
|
|
@@ -19215,9 +20594,9 @@ async function reviewCommand(dir, options) {
|
|
|
19215
20594
|
dir,
|
|
19216
20595
|
config,
|
|
19217
20596
|
extraEnv: {},
|
|
19218
|
-
skipTools: [],
|
|
19219
20597
|
startCommands: scanned.look.startCommands,
|
|
19220
|
-
onConfig: () => Promise.resolve()
|
|
20598
|
+
onConfig: () => Promise.resolve(),
|
|
20599
|
+
explain: explainWith(api, config)
|
|
19221
20600
|
};
|
|
19222
20601
|
pool = await withFixes(() => startApp(ctx, options.instances ?? 4), {
|
|
19223
20602
|
yes: options.yes,
|
|
@@ -19237,13 +20616,12 @@ async function reviewCommand(dir, options) {
|
|
|
19237
20616
|
say(`\n${bold(review.id)} ${review.why}, ${review.plan.paths.length.toString()} paths\n`);
|
|
19238
20617
|
if (standIn) say(` The stand-in attacker writes these attempts, because our own attacker cannot be reached yet. It fills a few plain, published patterns with values from the system's map and replies, so a hold says less than one from our own attacker. Version: ${review.attackerVersion}.\n`);
|
|
19239
20618
|
let interrupted = false;
|
|
19240
|
-
const
|
|
20619
|
+
const forget = beforeStopping(async () => {
|
|
19241
20620
|
interrupted = true;
|
|
19242
20621
|
controller.abort();
|
|
19243
20622
|
pool?.stop();
|
|
19244
|
-
api.post(`/reviews/${review.id}/stop`, {}).
|
|
19245
|
-
};
|
|
19246
|
-
process.once("SIGINT", onInterrupt);
|
|
20623
|
+
await api.post(`/reviews/${review.id}/stop`, {}).catch(() => void 0);
|
|
20624
|
+
});
|
|
19247
20625
|
const serving = runRunner({
|
|
19248
20626
|
api,
|
|
19249
20627
|
pool,
|
|
@@ -19267,6 +20645,7 @@ async function reviewCommand(dir, options) {
|
|
|
19267
20645
|
].includes(last.review.status) || interrupted) break;
|
|
19268
20646
|
await new Promise((r) => setTimeout(r, 2e3));
|
|
19269
20647
|
}
|
|
20648
|
+
forget();
|
|
19270
20649
|
controller.abort();
|
|
19271
20650
|
pool.stop();
|
|
19272
20651
|
await serving.catch(() => void 0);
|
|
@@ -19312,6 +20691,48 @@ async function runnerCommand(connection, dir, systemId, instances) {
|
|
|
19312
20691
|
signal: controller.signal
|
|
19313
20692
|
});
|
|
19314
20693
|
}
|
|
20694
|
+
// src/reading.ts
|
|
20695
|
+
var outlineHash = (outline) => createHash("sha256").update(JSON.stringify(outline)).digest("hex");
|
|
20696
|
+
/** Asks the model, or reuses what it said about this exact outline before. */
|
|
20697
|
+
async function understandOutline(api, outline, state) {
|
|
20698
|
+
const hash = outlineHash(ScanOutlineV2.parse(outline));
|
|
20699
|
+
const cached = UnderstandingV3.safeParse(state.understood);
|
|
20700
|
+
if (state.outlineHash === hash && cached.success) return cached.data;
|
|
20701
|
+
const understood = UnderstandingV3.parse(await api.post("/scan/understand", outline));
|
|
20702
|
+
state.understood = understood;
|
|
20703
|
+
state.outlineHash = hash;
|
|
20704
|
+
return understood;
|
|
20705
|
+
}
|
|
20706
|
+
/** What the model said, kept only where the code agrees, beside what the adapters read. */
|
|
20707
|
+
function read(result, understood) {
|
|
20708
|
+
const verified = verifyUnderstanding(understood, result.look, result.project);
|
|
20709
|
+
const acts = new Set(verified.understanding.tools.filter((t) => !t.can.startsWith("read")).map((t) => t.name));
|
|
20710
|
+
return {
|
|
20711
|
+
understood,
|
|
20712
|
+
verified,
|
|
20713
|
+
facts: mergeFacts(result.facts, verified.facts).filter((f) => !(f.kind === "way-in" && f.adapter !== "model" && f.function && acts.has(f.function)))
|
|
20714
|
+
};
|
|
20715
|
+
}
|
|
20716
|
+
/**
|
|
20717
|
+
* What a closer look would read: the functions the model could not judge, and a route whose message
|
|
20718
|
+
* and reply fields live in its body.
|
|
20719
|
+
*/
|
|
20720
|
+
function wantsCloserLook(reading) {
|
|
20721
|
+
const wanted = [...reading.verified.unclear];
|
|
20722
|
+
const entry = reading.understood.entry;
|
|
20723
|
+
const hasEntry = reading.facts.some((f) => f.kind === "entry");
|
|
20724
|
+
if (entry?.kind === "http" && !hasEntry) wanted.push({
|
|
20725
|
+
file: entry.file,
|
|
20726
|
+
line: entry.line,
|
|
20727
|
+
name: entry.path ?? "the route",
|
|
20728
|
+
why: "to see which field carries the message and which the reply"
|
|
20729
|
+
});
|
|
20730
|
+
const seen = /* @__PURE__ */ new Set();
|
|
20731
|
+
return wanted.filter((w) => {
|
|
20732
|
+
const key = `${w.file}:${w.line.toString()}`;
|
|
20733
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
20734
|
+
});
|
|
20735
|
+
}
|
|
19315
20736
|
// src/state.ts
|
|
19316
20737
|
var STATE_DIR = ".modelos-secure";
|
|
19317
20738
|
async function readState(dir) {
|
|
@@ -19334,6 +20755,17 @@ async function writeState(dir, state) {
|
|
|
19334
20755
|
await rename(temporary, join(folder, "state.json"));
|
|
19335
20756
|
await ensureIgnored(dir);
|
|
19336
20757
|
}
|
|
20758
|
+
/**
|
|
20759
|
+
* Keeps an unexpected error's details for whoever looks into it, on this machine only: beside the
|
|
20760
|
+
* scan's state in a repository the scan has run in, which git ignores, or else in the system's
|
|
20761
|
+
* temporary folder, so no other folder is ever changed. Nothing when neither can be written.
|
|
20762
|
+
*/
|
|
20763
|
+
async function keepError(dir, error) {
|
|
20764
|
+
const details = `${(/* @__PURE__ */ new Date()).toISOString()}\n${error instanceof Error ? error.stack ?? error.message : String(error)}\n`;
|
|
20765
|
+
const scanned = await stat(join(dir, STATE_DIR)).then((s) => s.isDirectory(), () => false);
|
|
20766
|
+
const file = scanned ? join(STATE_DIR, "last-error.log") : join(tmpdir(), "modelos-secure-last-error.log");
|
|
20767
|
+
return writeFile(scanned ? join(dir, file) : file, details).then(() => file, () => null);
|
|
20768
|
+
}
|
|
19337
20769
|
async function ensureIgnored(dir) {
|
|
19338
20770
|
const file = join(dir, ".gitignore");
|
|
19339
20771
|
const text = await readFile(file, "utf8").catch(() => "");
|
|
@@ -19348,9 +20780,10 @@ var TOOL_POWERS = [
|
|
|
19348
20780
|
"move money",
|
|
19349
20781
|
"delete"
|
|
19350
20782
|
];
|
|
19351
|
-
var CONSENT_TEXT = "To understand your code, the scan sends an outline: file paths
|
|
19352
|
-
var AI_DEPENDENCIES = /"(ai|openai|@google\/genai|langchain|@langchain\/[\w-]+|@anthropic-ai\/sdk)"|^\s*"?(openai|google-genai|langgraph|langchain|anthropic)\b/m;
|
|
19353
|
-
|
|
20783
|
+
var CONSENT_TEXT = "To understand your code, the scan sends an outline: file paths; the names, signatures, decorators and docstrings of functions and classes; which function calls which; the arguments where agents and tools are declared; your system prompts and the prompt files your code reads; and the names of your settings. If the app stops while starting, the last lines it printed go too, with secrets taken out, to work out the fix. It never sends function bodies, secrets, the values of settings or data. If it needs to read a function, it asks first. Run with --offline to keep everything here; the scan will ask you more questions instead.";
|
|
20784
|
+
var AI_DEPENDENCIES = /"(ai|openai|@google\/genai|langchain|@langchain\/[\w-]+|@anthropic-ai\/sdk|@mastra\/core|@openai\/agents)"|^\s*"?(openai|google-genai|langgraph|langchain|anthropic|crewai|pydantic-ai|smolagents|openai-agents|llama-index|autogen-agentchat|litellm)\b/m;
|
|
20785
|
+
/** Only for telling a repository of several apps from one app: whether any file imports an AI library. */
|
|
20786
|
+
var AI_IMPORT = /^\s*(?:import|from)\b[^\n]*\b(openai|anthropic|genai|gemini|langchain|langgraph|llama_index|crewai|pydantic_ai|smolagents|autogen|mastra|ai-sdk|agents|vertexai|litellm|ollama)\b|require\(["'](openai|ai|@anthropic-ai\/sdk)["']\)/m;
|
|
19354
20787
|
var plural = (n, one, many = `${one}s`) => `${n.toLocaleString("en-GB")} ${n === 1 ? one : many}`;
|
|
19355
20788
|
/** Folders below the root that are apps of their own. */
|
|
19356
20789
|
async function appsIn(root) {
|
|
@@ -19382,11 +20815,8 @@ async function appsIn(root) {
|
|
|
19382
20815
|
await visit(root, 1);
|
|
19383
20816
|
return found;
|
|
19384
20817
|
}
|
|
19385
|
-
function
|
|
19386
|
-
return result.facts.some((f) => f.kind === "tool" || f.kind === "
|
|
19387
|
-
}
|
|
19388
|
-
function modelCallOutsideAdapters(result) {
|
|
19389
|
-
return result.project.files.find((f) => MODEL_CALL.test(f.text))?.path;
|
|
20818
|
+
function looksLikeAnApp(result) {
|
|
20819
|
+
return result.facts.some((f) => f.kind === "tool" || f.kind === "agent") || result.project.files.some((f) => AI_IMPORT.test(f.text));
|
|
19390
20820
|
}
|
|
19391
20821
|
async function scanCommand(dir, options) {
|
|
19392
20822
|
const retry = {
|
|
@@ -19394,6 +20824,7 @@ async function scanCommand(dir, options) {
|
|
|
19394
20824
|
ci: options.ci
|
|
19395
20825
|
};
|
|
19396
20826
|
const state = await readState(dir);
|
|
20827
|
+
let closerLook;
|
|
19397
20828
|
let previous;
|
|
19398
20829
|
try {
|
|
19399
20830
|
previous = await readConfig(dir);
|
|
@@ -19407,7 +20838,7 @@ async function scanCommand(dir, options) {
|
|
|
19407
20838
|
const result = await scanFolder(dir);
|
|
19408
20839
|
say(`${bold("ModelOS Secure")} scanning ${result.look.name}\n`);
|
|
19409
20840
|
step("Look", `${[...result.look.languages, ...result.look.frameworks].join(", ") || "No source found"}, ${plural(result.look.fileCount, "file")}`);
|
|
19410
|
-
if (!
|
|
20841
|
+
if (!looksLikeAnApp(result)) {
|
|
19411
20842
|
const apps = await appsIn(dir);
|
|
19412
20843
|
if (apps.length > 1) {
|
|
19413
20844
|
const chosen = options.yes ? [apps[0]] : await ask(`This repository has ${apps.length.toString()} apps with model calls.`, void 0, [...apps.map((app) => ({
|
|
@@ -19422,37 +20853,8 @@ async function scanCommand(dir, options) {
|
|
|
19422
20853
|
}
|
|
19423
20854
|
if (apps.length === 1) return scanCommand(join(dir, apps[0]), options);
|
|
19424
20855
|
}
|
|
19425
|
-
|
|
19426
|
-
const
|
|
19427
|
-
const prompts = result.facts.filter((f) => f.kind === "system-prompt");
|
|
19428
|
-
const agents = result.facts.filter((f) => f.kind === "agent");
|
|
19429
|
-
step("Read", `${plural(agents.length, "agent")}, ${plural(tools.length, "tool")}, ${plural(ways.length + 1, "way")} in, ${plural(prompts.length, "system prompt")}`);
|
|
19430
|
-
let modelCallsOnly = false;
|
|
19431
|
-
if (tools.length === 0) {
|
|
19432
|
-
const file = modelCallOutsideAdapters(result);
|
|
19433
|
-
if (file && !previous?.tools.length) await withFixes(async () => {
|
|
19434
|
-
if (modelCallsOnly) return;
|
|
19435
|
-
throw new Failure(`I found model calls in ${file} but no framework I know.`, [
|
|
19436
|
-
{
|
|
19437
|
-
label: "Mark the tools with one line each (shown, ready to paste)",
|
|
19438
|
-
apply: () => {
|
|
19439
|
-
say(`\n from modelos_secure import intercept\n send_email = intercept(send_email)\n`);
|
|
19440
|
-
process.exit(0);
|
|
19441
|
-
}
|
|
19442
|
-
},
|
|
19443
|
-
{
|
|
19444
|
-
label: "Continue with model calls only",
|
|
19445
|
-
recommended: true,
|
|
19446
|
-
apply: () => void (modelCallsOnly = true)
|
|
19447
|
-
},
|
|
19448
|
-
{
|
|
19449
|
-
label: "Send the outline for a closer look",
|
|
19450
|
-
apply: () => void (modelCallsOnly = true)
|
|
19451
|
-
}
|
|
19452
|
-
]);
|
|
19453
|
-
}, retry);
|
|
19454
|
-
}
|
|
19455
|
-
const outline = buildOutline(result.look, result.facts, result.project);
|
|
20856
|
+
step("Read", `${plural(result.project.files.length, "file")} parsed${result.adapters.length ? `; ${result.adapters.join(", ")} read for certain` : ""}`);
|
|
20857
|
+
const outline = await buildOutline(result.look, result.facts, result.project);
|
|
19456
20858
|
if (options.explain) {
|
|
19457
20859
|
say("\nThis is the exact outline that would be sent. Nothing was sent.\n");
|
|
19458
20860
|
say(JSON.stringify(outline, null, 2));
|
|
@@ -19473,19 +20875,18 @@ async function scanCommand(dir, options) {
|
|
|
19473
20875
|
say();
|
|
19474
20876
|
}
|
|
19475
20877
|
state.consented = consent === "outline";
|
|
19476
|
-
|
|
20878
|
+
await writeState(dir, state);
|
|
19477
20879
|
let offline = options.offline || consent === "none";
|
|
19478
20880
|
let api = connection ? apiClient(connection) : null;
|
|
19479
|
-
let
|
|
19480
|
-
const
|
|
19481
|
-
if (!offline) {
|
|
20881
|
+
let reading;
|
|
20882
|
+
const understandOnce = async (sent) => {
|
|
19482
20883
|
await withFixes(async () => {
|
|
19483
20884
|
if (offline) return;
|
|
19484
20885
|
if (!api) throw new Failure("I need a workspace token first.", [{
|
|
19485
20886
|
label: "Enter a token now",
|
|
19486
20887
|
recommended: true,
|
|
19487
20888
|
apply: async () => {
|
|
19488
|
-
const { loginCommand } = await import("./assets/login-
|
|
20889
|
+
const { loginCommand } = await import("./assets/login-CipN1qFD.js");
|
|
19489
20890
|
await loginCommand();
|
|
19490
20891
|
const fresh = await readConnection();
|
|
19491
20892
|
api = fresh ? apiClient(fresh) : null;
|
|
@@ -19494,12 +20895,8 @@ async function scanCommand(dir, options) {
|
|
|
19494
20895
|
label: "Run offline and send the result later",
|
|
19495
20896
|
apply: () => void (offline = true)
|
|
19496
20897
|
}]);
|
|
19497
|
-
if (state.outlineHash === outlineHash && state.understood) {
|
|
19498
|
-
understanding = state.understood;
|
|
19499
|
-
return;
|
|
19500
|
-
}
|
|
19501
20898
|
try {
|
|
19502
|
-
|
|
20899
|
+
reading = read(result, await understandOutline(api, sent, state));
|
|
19503
20900
|
} catch (error) {
|
|
19504
20901
|
const wrongAddress = error instanceof IapRefusal && error.kind === "not-the-app" ? error.message : void 0;
|
|
19505
20902
|
if (error instanceof ApiError$1 && error.status < 500 && !wrongAddress) throw error;
|
|
@@ -19516,24 +20913,65 @@ async function scanCommand(dir, options) {
|
|
|
19516
20913
|
}], wrongAddress);
|
|
19517
20914
|
}
|
|
19518
20915
|
}, retry);
|
|
19519
|
-
|
|
19520
|
-
|
|
19521
|
-
|
|
19522
|
-
|
|
19523
|
-
|
|
20916
|
+
await writeState(dir, state);
|
|
20917
|
+
};
|
|
20918
|
+
if (!offline) await understandOnce(outline);
|
|
20919
|
+
const wanted = reading ? wantsCloserLook(reading) : [];
|
|
20920
|
+
if (reading && wanted.length > 0 && !previous?.closer_look) {
|
|
20921
|
+
say(`\n To work out what the outline cannot show, the scan would send the code of ${plural(wanted.length, "function")}, and nothing else:`);
|
|
20922
|
+
for (const w of wanted) say(` ${w.name} in ${w.file}:${w.line.toString()}, ${w.why}`);
|
|
20923
|
+
const yes = !options.yes && !options.ci && await ask("Send these to the model for a closer look?", void 0, [{
|
|
20924
|
+
label: "Yes, send only these",
|
|
20925
|
+
value: true,
|
|
20926
|
+
recommended: true
|
|
20927
|
+
}, {
|
|
20928
|
+
label: "No, ask me instead",
|
|
20929
|
+
value: false
|
|
20930
|
+
}]);
|
|
20931
|
+
closerLook = yes ? wanted.map((w) => `${w.file}:${w.name}`) : options.yes || options.ci ? void 0 : "no";
|
|
20932
|
+
if (yes) await understandOnce({
|
|
20933
|
+
...outline,
|
|
20934
|
+
bodies: bodiesOf(result, wanted)
|
|
20935
|
+
});
|
|
19524
20936
|
}
|
|
20937
|
+
const facts = reading?.facts ?? result.facts;
|
|
20938
|
+
const understanding = reading?.verified.understanding;
|
|
20939
|
+
const count = (kind) => facts.filter((f) => f.kind === kind).length;
|
|
19525
20940
|
const changers = (understanding?.tools ?? []).filter((t) => t.can !== "read" && t.can !== "read private data");
|
|
19526
20941
|
const untrusted = (understanding?.waysIn ?? []).filter((w) => w.untrusted);
|
|
19527
|
-
step("Understand", offline ? "Offline, so nothing was sent" : `${plural(changers.length, "tool")} ${changers.length === 1 ? "changes" : "change"} things; ${plural(untrusted.length, "input")} ${untrusted.length === 1 ? "carries" : "carry"} outside content`);
|
|
20942
|
+
step("Understand", offline ? "Offline, so nothing was sent" : `${plural(count("tool"), "tool")}, ${plural(count("way-in") + 1, "way")} in, ${plural(count("system-prompt"), "system prompt")}; ${plural(changers.length, "tool")} ${changers.length === 1 ? "changes" : "change"} things; ${plural(untrusted.length, "input")} ${untrusted.length === 1 ? "carries" : "carry"} outside content`);
|
|
20943
|
+
const dropped = reading?.verified.dropped ?? [];
|
|
20944
|
+
if (dropped.length > 0) say(` ${grey(`Dropped ${plural(dropped.length, "claim")} that did not match the code: ${dropped.map((d) => `${d.what}, because ${d.why}`).join("; ")}.`)}`);
|
|
19528
20945
|
const drafted = draftConfig({
|
|
19529
20946
|
look: result.look,
|
|
19530
|
-
facts
|
|
20947
|
+
facts,
|
|
19531
20948
|
understanding,
|
|
20949
|
+
start: reading?.verified.start,
|
|
20950
|
+
effects: reading?.verified.effects,
|
|
19532
20951
|
previous: previous ?? void 0,
|
|
19533
20952
|
consent: options.offline ? previous?.consent : consent
|
|
19534
20953
|
});
|
|
19535
|
-
let config =
|
|
20954
|
+
let config = {
|
|
20955
|
+
...drafted.config,
|
|
20956
|
+
closer_look: closerLook ?? previous?.closer_look
|
|
20957
|
+
};
|
|
19536
20958
|
const open = drafted.questions.filter((q) => !state.answers[q.id]);
|
|
20959
|
+
const summary = summaryOf(config, facts, reading);
|
|
20960
|
+
const written = api && !offline ? await scanWords(api, [summary, ...open.map((q) => ({
|
|
20961
|
+
slot: "question",
|
|
20962
|
+
facts: {
|
|
20963
|
+
question: q.text,
|
|
20964
|
+
about: q.about,
|
|
20965
|
+
why: q.why
|
|
20966
|
+
},
|
|
20967
|
+
fallback: `${q.text}\n${q.why}`
|
|
20968
|
+
}))]) : [summary.fallback, ...open.map((q) => `${q.text}\n${q.why}`)];
|
|
20969
|
+
say(`\n ${written[0]}\n`);
|
|
20970
|
+
for (const [i, q] of open.entries()) {
|
|
20971
|
+
const [text, ...why] = written[i + 1].split("\n");
|
|
20972
|
+
q.text = text.trim();
|
|
20973
|
+
q.why = why.join(" ").trim() || q.why;
|
|
20974
|
+
}
|
|
19537
20975
|
for (const q of drafted.questions) if (state.answers[q.id]) config = applyAnswer(config, q, state.answers[q.id]);
|
|
19538
20976
|
if (options.ci && open.length > 0) {
|
|
19539
20977
|
say("\nThese questions need answering locally, because CI never guesses:");
|
|
@@ -19549,7 +20987,7 @@ async function scanCommand(dir, options) {
|
|
|
19549
20987
|
await writeState(dir, state);
|
|
19550
20988
|
config = applyAnswer(config, question, value);
|
|
19551
20989
|
}
|
|
19552
|
-
for (const tool of
|
|
20990
|
+
for (const tool of facts) {
|
|
19553
20991
|
if (tool.kind !== "tool") continue;
|
|
19554
20992
|
if ((tool.services?.length ?? 0) < 3) continue;
|
|
19555
20993
|
if (previous?.tools.some((t) => t.name === tool.name)) continue;
|
|
@@ -19575,25 +21013,47 @@ async function scanCommand(dir, options) {
|
|
|
19575
21013
|
config = treatToolAs(config, tool.name, state.answers[id]);
|
|
19576
21014
|
}
|
|
19577
21015
|
if (drafted.config.decided_for_you?.length) say(` ${grey(`I hit the limit of 7 questions, so I made the safer choice on ${plural(drafted.config.decided_for_you.length, "tool")} and recorded it for review in modelos-secure.yaml.`)}`);
|
|
19578
|
-
|
|
21016
|
+
const candidates = [...new Map([...reading?.verified.start ?? [], ...result.look.startCommands].map((c) => [c.command, c])).values()];
|
|
21017
|
+
if (!previous?.start?.command && candidates.length > 1 && !options.ci) {
|
|
21018
|
+
const command = (state.answers.start ?? (options.yes ? candidates[0].command : await ask("How should the scan start the app?", "It starts the app on this machine, with the hooks attached, to check the connection.", [...candidates.map((c, i) => ({
|
|
21019
|
+
label: `${c.command} (${c.from})`,
|
|
21020
|
+
value: c.command,
|
|
21021
|
+
recommended: i === 0
|
|
21022
|
+
})), {
|
|
21023
|
+
label: "Type the command",
|
|
21024
|
+
value: ""
|
|
21025
|
+
}]))) || await askText("How do you start the app?");
|
|
21026
|
+
if (command.trim()) {
|
|
21027
|
+
state.answers.start = command.trim();
|
|
21028
|
+
await writeState(dir, state);
|
|
21029
|
+
config = {
|
|
21030
|
+
...config,
|
|
21031
|
+
start: {
|
|
21032
|
+
...config.start,
|
|
21033
|
+
command: command.trim()
|
|
21034
|
+
}
|
|
21035
|
+
};
|
|
21036
|
+
}
|
|
21037
|
+
}
|
|
21038
|
+
await writeConfig(dir, config);
|
|
19579
21039
|
const parts = versionParts(config, {
|
|
19580
|
-
model:
|
|
19581
|
-
systemPrompts:
|
|
19582
|
-
guardrails:
|
|
21040
|
+
model: facts.find((f) => f.kind === "model")?.name,
|
|
21041
|
+
systemPrompts: facts.flatMap((f) => f.kind === "system-prompt" ? [f.text] : []),
|
|
21042
|
+
guardrails: facts.flatMap((f) => f.kind === "guardrail" ? [f.name] : [])
|
|
19583
21043
|
});
|
|
19584
21044
|
const versionLabel = createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 7);
|
|
19585
|
-
if (
|
|
21045
|
+
if (options.offline) step("Start", `${dash} skipped offline`);
|
|
19586
21046
|
let pool = null;
|
|
19587
21047
|
const stopPool = () => pool?.stop();
|
|
19588
21048
|
process.once("exit", stopPool);
|
|
19589
|
-
if (!
|
|
21049
|
+
if (!options.offline) {
|
|
19590
21050
|
const startCtx = {
|
|
19591
21051
|
dir,
|
|
19592
21052
|
config,
|
|
19593
21053
|
extraEnv: {},
|
|
19594
|
-
skipTools: [],
|
|
19595
21054
|
startCommands: result.look.startCommands,
|
|
19596
|
-
onConfig: (c) =>
|
|
21055
|
+
onConfig: (c) => writeConfig(dir, c),
|
|
21056
|
+
explain: explainWith(offline ? null : api, config)
|
|
19597
21057
|
};
|
|
19598
21058
|
pool = await withFixes(() => startApp(startCtx, 1), retry);
|
|
19599
21059
|
step("Start", `${config.start?.command ?? ""} with hooks attached`);
|
|
@@ -19617,10 +21077,13 @@ async function scanCommand(dir, options) {
|
|
|
19617
21077
|
if (check.modelCalls.length === 0) say(` ${grey("The hooks saw no model call on the connection check, so the first review will say so.")}`);
|
|
19618
21078
|
}
|
|
19619
21079
|
const register = toRegister(config, parts, {
|
|
19620
|
-
sample: options.sample,
|
|
19621
21080
|
summary: understanding?.summary,
|
|
19622
21081
|
versionLabel,
|
|
19623
|
-
toolFacts: config.consent === "outline" ?
|
|
21082
|
+
toolFacts: config.consent === "outline" ? facts.flatMap((f) => f.kind === "tool" ? [{
|
|
21083
|
+
name: f.name,
|
|
21084
|
+
description: f.description,
|
|
21085
|
+
signature: f.signature
|
|
21086
|
+
}] : []) : void 0
|
|
19624
21087
|
});
|
|
19625
21088
|
if (offline || !api) {
|
|
19626
21089
|
state.pendingRegister = register;
|
|
@@ -19643,7 +21106,7 @@ async function scanCommand(dir, options) {
|
|
|
19643
21106
|
"start",
|
|
19644
21107
|
"check"
|
|
19645
21108
|
];
|
|
19646
|
-
|
|
21109
|
+
await writeState(dir, state);
|
|
19647
21110
|
step("Check", "The hooks saw the app and held what they should");
|
|
19648
21111
|
const paths = (config.ways_in.length || 1) * (config.rules.length || 1);
|
|
19649
21112
|
say(`\n ${bold(`${config.system} is connected.`)}\n`);
|
|
@@ -19666,6 +21129,46 @@ async function scanCommand(dir, options) {
|
|
|
19666
21129
|
yes: options.yes
|
|
19667
21130
|
});
|
|
19668
21131
|
}
|
|
21132
|
+
/** Model-written words, checked on the server; the plain sentences when anything goes wrong. */
|
|
21133
|
+
async function scanWords(api, items) {
|
|
21134
|
+
try {
|
|
21135
|
+
const { texts } = await api.post("/scan/words", { items });
|
|
21136
|
+
return texts.length === items.length ? texts : items.map((i) => i.fallback);
|
|
21137
|
+
} catch {
|
|
21138
|
+
return items.map((i) => i.fallback);
|
|
21139
|
+
}
|
|
21140
|
+
}
|
|
21141
|
+
/** The facts of what the scan found and what it could not, with its plain sentence. */
|
|
21142
|
+
function summaryOf(config, facts, reading) {
|
|
21143
|
+
const notFound = [
|
|
21144
|
+
...config.start?.entry ? [] : ["no route that takes a message"],
|
|
21145
|
+
...config.start?.command ? [] : ["no command that starts the app"],
|
|
21146
|
+
...facts.some((f) => f.kind === "system-prompt") ? [] : ["no system prompt"]
|
|
21147
|
+
];
|
|
21148
|
+
const tools = config.tools.map((t) => ({
|
|
21149
|
+
name: t.name,
|
|
21150
|
+
can: t.can,
|
|
21151
|
+
from: t.from
|
|
21152
|
+
}));
|
|
21153
|
+
const waysIn = config.ways_in.map((w) => ({
|
|
21154
|
+
name: w.name,
|
|
21155
|
+
from: w.from
|
|
21156
|
+
}));
|
|
21157
|
+
const plain = [`It found ${plural(tools.length, "tool")} and ${plural(waysIn.length, "way")} in.`, ...notFound.length ? [`It found ${notFound.join(", ")}.`] : []].join(" ");
|
|
21158
|
+
return {
|
|
21159
|
+
slot: "summary",
|
|
21160
|
+
facts: {
|
|
21161
|
+
app: config.system,
|
|
21162
|
+
whatItDoes: reading?.verified.understanding.summary,
|
|
21163
|
+
tools,
|
|
21164
|
+
waysIn,
|
|
21165
|
+
systemPrompts: facts.filter((f) => f.kind === "system-prompt").length,
|
|
21166
|
+
notFound,
|
|
21167
|
+
droppedClaims: reading?.verified.dropped.length ?? 0
|
|
21168
|
+
},
|
|
21169
|
+
fallback: plain
|
|
21170
|
+
};
|
|
21171
|
+
}
|
|
19669
21172
|
async function uploadAnswerKey(api, systemId, name, csv) {
|
|
19670
21173
|
const answer = await api.postText(`/systems/${systemId}/answer-key?name=${encodeURIComponent(name)}`, csv);
|
|
19671
21174
|
say(` ${grey(`Read ${plural(answer.questions, "question")} from ${name}.`)}`);
|
|
@@ -19695,9 +21198,12 @@ var USAGE = `modelos-secure: test an AI system for Security, Safety and Quality.
|
|
|
19695
21198
|
doctor Checks the setup and fixes what it can.
|
|
19696
21199
|
runner --system <id> Runs a runner from this machine.
|
|
19697
21200
|
export R-1 --format sarif Writes a review's findings as SARIF or JSON.`;
|
|
21201
|
+
var STOPPED = "Stopped. Run the same command again to carry on from here.";
|
|
19698
21202
|
process.once("SIGINT", () => {
|
|
19699
|
-
|
|
19700
|
-
|
|
21203
|
+
finishBeforeStopping().finally(() => {
|
|
21204
|
+
say(`\n${STOPPED}`);
|
|
21205
|
+
process.exit(130);
|
|
21206
|
+
});
|
|
19701
21207
|
});
|
|
19702
21208
|
try {
|
|
19703
21209
|
const ci = has("ci") || Boolean(process.env.CI);
|
|
@@ -19712,7 +21218,6 @@ try {
|
|
|
19712
21218
|
explain: has("explain"),
|
|
19713
21219
|
yes: has("yes"),
|
|
19714
21220
|
ci: has("ci"),
|
|
19715
|
-
sample: has("sample"),
|
|
19716
21221
|
noReview: has("no-review")
|
|
19717
21222
|
});
|
|
19718
21223
|
else if (command === "review") await reviewCommand(process.cwd(), {
|
|
@@ -19736,11 +21241,16 @@ try {
|
|
|
19736
21241
|
} else say(USAGE);
|
|
19737
21242
|
} catch (error) {
|
|
19738
21243
|
if (error instanceof Interrupted) {
|
|
19739
|
-
|
|
21244
|
+
await finishBeforeStopping();
|
|
21245
|
+
say(`\n${STOPPED}`);
|
|
19740
21246
|
process.exit(130);
|
|
19741
21247
|
}
|
|
19742
|
-
|
|
19743
|
-
|
|
21248
|
+
if (!(error instanceof Failure && error.shown)) say(`\n ${red(error instanceof Error ? error.message : String(error))}`);
|
|
21249
|
+
const step = lastFinishedStep();
|
|
21250
|
+
say(` ${step ? `It stopped after ${step}. ` : ""}What it did is kept, and running the same command again carries on from there.`);
|
|
21251
|
+
const log = await keepError(process.cwd(), error);
|
|
21252
|
+
if (log && !(error instanceof Failure)) say(` The details are in ${log}, on this machine only.`);
|
|
21253
|
+
say(" modelos-secure doctor checks the setup and fixes what it can.");
|
|
19744
21254
|
process.exit(1);
|
|
19745
21255
|
}
|
|
19746
21256
|
export {};
|