modelos-secure 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -12
- package/assets/login-CvPyhFJ7.js +1 -0
- package/assets/{login-DZgP66jr.js → login-DeAJGhiN.js} +22 -3
- package/cli.js +1763 -358
- package/hook/edge.mjs +6 -0
- package/hook/hook.mjs +4 -0
- package/package.json +2 -2
- package/python/modelos_secure/edge.py +19 -2
- package/python/modelos_secure/hook.py +3 -0
- package/assets/login-CipN1qFD.js +0 -1
package/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { _ as
|
|
2
|
+
import { _ as lastFinishedStep, a as apiClient, b as status, c as Interrupted, d as beforeStopping, f as blue, g as interactive, h as grey, i as IapRefusal, l as ask, m as finishBeforeStopping, o as atAddress, p as bold, r as ApiError$1, s as readConnection, t as loginCommand, u as askText, v as red, x as step, y as say } from "./assets/login-DeAJGhiN.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
|
-
import {
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
import { access, appendFile, constants, glob, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
12
|
import { createServer, request } from "node:http";
|
|
12
13
|
import { connect, createConnection, createServer as createServer$1 } from "node:net";
|
|
13
14
|
// \0rolldown/runtime.js
|
|
@@ -20,6 +21,44 @@ if (existsSync(resolve(here, "wasm"))) {
|
|
|
20
21
|
process.env.MODELOS_SECURE_HOOK_NODE ??= resolve(here, "hook/hook.mjs");
|
|
21
22
|
process.env.MODELOS_SECURE_HOOK_PYTHON ??= resolve(here, "python");
|
|
22
23
|
}
|
|
24
|
+
// package.json
|
|
25
|
+
var name = "modelos-secure";
|
|
26
|
+
var version$1 = "0.3.1";
|
|
27
|
+
var type = "module";
|
|
28
|
+
var bin = { "modelos-secure": "dist/cli.js" };
|
|
29
|
+
var exports$1 = { "./hook": "./dist/hook/hook.mjs" };
|
|
30
|
+
var engines = { "node": ">=22" };
|
|
31
|
+
var files = ["dist"];
|
|
32
|
+
var scripts = { "build": "vite build" };
|
|
33
|
+
var dependencies = {
|
|
34
|
+
"@modelos-secure/core": "workspace:*",
|
|
35
|
+
"@modelos-secure/scan": "workspace:*",
|
|
36
|
+
"@modelos-secure/schema": "workspace:*",
|
|
37
|
+
"yaml": "2.9.1"
|
|
38
|
+
};
|
|
39
|
+
var devDependencies = { "vite": "8.3.6" };
|
|
40
|
+
var description = "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.";
|
|
41
|
+
var license = "UNLICENSED";
|
|
42
|
+
var repository = {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/mn-chetan/modelos-secure.git"
|
|
45
|
+
};
|
|
46
|
+
var package_default = {
|
|
47
|
+
name,
|
|
48
|
+
version: version$1,
|
|
49
|
+
"private": true,
|
|
50
|
+
type,
|
|
51
|
+
bin,
|
|
52
|
+
exports: exports$1,
|
|
53
|
+
engines,
|
|
54
|
+
files,
|
|
55
|
+
scripts,
|
|
56
|
+
dependencies,
|
|
57
|
+
devDependencies,
|
|
58
|
+
description,
|
|
59
|
+
license,
|
|
60
|
+
repository
|
|
61
|
+
};
|
|
23
62
|
// src/failures.ts
|
|
24
63
|
var Failure = class extends Error {
|
|
25
64
|
cause2;
|
|
@@ -35,15 +74,16 @@ var Failure = class extends Error {
|
|
|
35
74
|
/** Runs a step, offering the fixes for any failure, until it passes or no fix is left to try. */
|
|
36
75
|
async function withFixes(run, options) {
|
|
37
76
|
const applied = /* @__PURE__ */ new Set();
|
|
77
|
+
const key = (error, fix) => `${error.message}\n${fix.label}`;
|
|
38
78
|
for (let tries = 0;; tries++) try {
|
|
39
79
|
return await run();
|
|
40
80
|
} catch (caught) {
|
|
41
81
|
if (!(caught instanceof Failure)) throw caught;
|
|
42
82
|
const error = caught;
|
|
43
|
-
error.fixes.splice(0, error.fixes.length, ...error.fixes.filter((f) => !applied.has(f
|
|
83
|
+
error.fixes.splice(0, error.fixes.length, ...error.fixes.filter((f) => !applied.has(key(error, f))));
|
|
44
84
|
say();
|
|
45
85
|
say(` ${red(error.message)}`);
|
|
46
|
-
if (error.cause2) say(` ${error.cause2}`);
|
|
86
|
+
if (error.cause2 && !restates(error.cause2, error.message)) say(` ${error.cause2}`);
|
|
47
87
|
error.shown = true;
|
|
48
88
|
const unattended = options.yes || options.ci || !interactive();
|
|
49
89
|
const choosable = unattended ? error.fixes.filter((f) => !f.needsPerson) : error.fixes;
|
|
@@ -55,10 +95,18 @@ async function withFixes(run, options) {
|
|
|
55
95
|
value: f,
|
|
56
96
|
recommended: f === preferred
|
|
57
97
|
})));
|
|
58
|
-
applied.add(fix
|
|
98
|
+
applied.add(key(error, fix));
|
|
59
99
|
await fix.apply();
|
|
60
100
|
}
|
|
61
101
|
}
|
|
102
|
+
/** Whether a cause only says again what happened, so printing it would repeat the line above. */
|
|
103
|
+
function restates(cause, what) {
|
|
104
|
+
if (/\bbecause\b/i.test(what)) return true;
|
|
105
|
+
const words = (text) => new Set(text.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []);
|
|
106
|
+
const said = words(what);
|
|
107
|
+
const told = [...words(cause)];
|
|
108
|
+
return told.filter((w) => said.has(w)).length >= told.length / 2;
|
|
109
|
+
}
|
|
62
110
|
// ../../packages/core/src/rules.ts
|
|
63
111
|
function parseRule(source) {
|
|
64
112
|
const parts = source.split(/\s+and\s+/).map((p) => p.trim());
|
|
@@ -255,6 +303,11 @@ function defaultRules(tools) {
|
|
|
255
303
|
return rules;
|
|
256
304
|
}
|
|
257
305
|
// ../../packages/scan/src/look.ts
|
|
306
|
+
/**
|
|
307
|
+
* A path in the repository, from its root, always with forward slashes: what the map, the
|
|
308
|
+
* outline, the model and modelos-secure.yaml all use, on Windows too.
|
|
309
|
+
*/
|
|
310
|
+
var inRepo = (root, full) => relative(root, full).split(sep).join("/");
|
|
258
311
|
var SKIPPED = /* @__PURE__ */ new Set([
|
|
259
312
|
"node_modules",
|
|
260
313
|
"vendor",
|
|
@@ -348,8 +401,8 @@ async function walk$1(root) {
|
|
|
348
401
|
} else if (entry.isFile()) {
|
|
349
402
|
all += 1;
|
|
350
403
|
if (SOURCE.has(extname(entry.name)) && !/\.(test|spec|d)\.[jt]sx?$/.test(entry.name) && !/^test_.*\.py$/.test(entry.name)) {
|
|
351
|
-
if ((await stat(full)).size <= MAX_FILE_BYTES) source.push(
|
|
352
|
-
} else if (DOCUMENT.has(extname(entry.name)) && !NOT_DOCUMENTS.test(entry.name)) documents.push(
|
|
404
|
+
if ((await stat(full)).size <= MAX_FILE_BYTES) source.push(inRepo(root, full));
|
|
405
|
+
} else if (DOCUMENT.has(extname(entry.name)) && !NOT_DOCUMENTS.test(entry.name)) documents.push(inRepo(root, full));
|
|
353
406
|
}
|
|
354
407
|
}
|
|
355
408
|
};
|
|
@@ -430,7 +483,7 @@ async function requirementFiles(root) {
|
|
|
430
483
|
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
431
484
|
const full = join(dir, entry.name);
|
|
432
485
|
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(
|
|
486
|
+
else if (entry.isFile() && /^requirements.*\.txt$/.test(entry.name)) found.push(inRepo(root, full));
|
|
434
487
|
}
|
|
435
488
|
};
|
|
436
489
|
await visit(root, 0);
|
|
@@ -5061,7 +5114,7 @@ var ADAPTERS = [
|
|
|
5061
5114
|
];
|
|
5062
5115
|
// ../../packages/scan/src/wrappers.ts
|
|
5063
5116
|
var FOLLOW_DEPTH = 2;
|
|
5064
|
-
var FUNCTIONS
|
|
5117
|
+
var FUNCTIONS = /* @__PURE__ */ new Set([
|
|
5065
5118
|
"function_definition",
|
|
5066
5119
|
"function_declaration",
|
|
5067
5120
|
"arrow_function",
|
|
@@ -5080,7 +5133,7 @@ var rootName = (node) => {
|
|
|
5080
5133
|
}
|
|
5081
5134
|
};
|
|
5082
5135
|
var calledBy = (call) => call.type === "call" || call.type === "call_expression" ? call.childForFieldName("function") : call.type === "new_expression" ? call.childForFieldName("constructor") : null;
|
|
5083
|
-
function importsOf(file, project) {
|
|
5136
|
+
function importsOf$1(file, project) {
|
|
5084
5137
|
const bound = /* @__PURE__ */ new Map();
|
|
5085
5138
|
const pythonLocal = (module) => module.startsWith(".") || project.files.some((f) => {
|
|
5086
5139
|
const first = module.split(".")[0];
|
|
@@ -5126,14 +5179,14 @@ function importsOf(file, project) {
|
|
|
5126
5179
|
function functionFor(file, line, name) {
|
|
5127
5180
|
let found;
|
|
5128
5181
|
walk(file.root, (node) => {
|
|
5129
|
-
if (found || !FUNCTIONS
|
|
5182
|
+
if (found || !FUNCTIONS.has(node.type)) return;
|
|
5130
5183
|
if (lineOf(node) === line || node.childForFieldName("name")?.text === name) found = node;
|
|
5131
5184
|
});
|
|
5132
5185
|
if (found) return found;
|
|
5133
5186
|
walk(file.root, (node) => {
|
|
5134
5187
|
if (found || lineOf(node) !== line) return;
|
|
5135
5188
|
walk(node, (inner) => {
|
|
5136
|
-
if (!found && FUNCTIONS
|
|
5189
|
+
if (!found && FUNCTIONS.has(inner.type)) found = inner;
|
|
5137
5190
|
});
|
|
5138
5191
|
});
|
|
5139
5192
|
return found;
|
|
@@ -5152,7 +5205,7 @@ function servicesCalledBy(project, tool) {
|
|
|
5152
5205
|
return true;
|
|
5153
5206
|
};
|
|
5154
5207
|
const collect = (fn, file, depth) => {
|
|
5155
|
-
const imports = importsOf(file, project);
|
|
5208
|
+
const imports = importsOf$1(file, project);
|
|
5156
5209
|
const addImported = (bound, name) => {
|
|
5157
5210
|
if (bound.local) services.add(name);
|
|
5158
5211
|
else if (!NOT_SERVICES.has(bound.module.replace(/^node:/, "").split(".")[0])) services.add(bound.module);
|
|
@@ -5242,6 +5295,76 @@ function dedupe(facts) {
|
|
|
5242
5295
|
return true;
|
|
5243
5296
|
});
|
|
5244
5297
|
}
|
|
5298
|
+
// ../../packages/scan/src/secrets.ts
|
|
5299
|
+
/** Files that are never read, whatever the model asks: settings, keys and credentials. */
|
|
5300
|
+
var NEVER_READ = [
|
|
5301
|
+
/(^|\/)\.env(\..+)?$/i,
|
|
5302
|
+
/\.(pem|key|p12|pfx|jks|keystore|crt|cer|der|p8|ppk|kdbx|gpg|asc)$/i,
|
|
5303
|
+
/(^|\/)(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.pub)?$/i,
|
|
5304
|
+
/(^|\/)\.(npmrc|pypirc|netrc|git-credentials|htpasswd|pgpass)$/i,
|
|
5305
|
+
/(^|\/)(credentials|secrets?|service[-_]?account)[^/]*\.(json|ya?ml|toml|ini|txt)$/i,
|
|
5306
|
+
/(^|\/)client_secret[^/]*\.json$/i,
|
|
5307
|
+
/(^|\/)\.aws\/|(^|\/)\.ssh\/|(^|\/)\.gnupg\/|(^|\/)\.docker\/config\.json$/i,
|
|
5308
|
+
/(^|\/)terraform\.tfstate(\.backup)?$/i
|
|
5309
|
+
];
|
|
5310
|
+
/** Example settings say which names a project reads, with no real values: they may be read. */
|
|
5311
|
+
var EXAMPLE = /\.(example|sample|template|dist)$/i;
|
|
5312
|
+
/** Why a file in the repository is never sent, or null when it may be. */
|
|
5313
|
+
function neverRead(path) {
|
|
5314
|
+
const posix = path.split(sep).join("/");
|
|
5315
|
+
if (EXAMPLE.test(posix)) return null;
|
|
5316
|
+
return NEVER_READ.some((pattern) => pattern.test(posix)) ? "it holds settings, keys or credentials, which never leave this machine" : null;
|
|
5317
|
+
}
|
|
5318
|
+
/**
|
|
5319
|
+
* The repository's own path to a file, or null when the path leads outside it, through `..` or a
|
|
5320
|
+
* link. The model names paths; this decides whether one is in the repository at all.
|
|
5321
|
+
*/
|
|
5322
|
+
async function insideRepository(root, path) {
|
|
5323
|
+
const realRoot = await realpath(root);
|
|
5324
|
+
const full = await realpath(resolve(realRoot, path)).catch(() => null);
|
|
5325
|
+
if (!full) return null;
|
|
5326
|
+
const inRepo = relative(realRoot, full);
|
|
5327
|
+
if (inRepo.startsWith("..") || resolve(realRoot, inRepo) !== full) return null;
|
|
5328
|
+
return inRepo.split(sep).join("/");
|
|
5329
|
+
}
|
|
5330
|
+
var SECRET_VALUES = /\b(GOCSP[X]-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{16,}|sk_(live|test)_[A-Za-z0-9]{16,}|rk_(live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}|glpat-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|ya29\.[A-Za-z0-9._-]+|mst\.[A-Za-z0-9._-]+|hf_[A-Za-z0-9]{30,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})\b/g;
|
|
5331
|
+
/** A name that holds a secret: `password`, `apiKey`, `DB_PASS`, `client_secret` and the like. */
|
|
5332
|
+
var SECRET_NAME = "(?:[A-Za-z0-9_]*(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|access[_-]?key|credential)[A-Za-z0-9_]*|[A-Za-z0-9_]*_(?:pass|pwd)|pass|pwd)";
|
|
5333
|
+
/** An assignment of a quoted literal to such a name, in code, JSON or YAML: the value goes. */
|
|
5334
|
+
var SECRET_ASSIGNMENT = new RegExp(`\\b(${SECRET_NAME})(["']?\\s*[:=]\\s*)(["'\`])([^"'\`\\n]{4,})\\3`, "gi");
|
|
5335
|
+
/**
|
|
5336
|
+
* The same with no quotes, as settings files write it (`password: hunter2pass`, `DB_PASS=...`).
|
|
5337
|
+
* Only a value with both letters and digits and no call or index in it, so `token = get_token()`
|
|
5338
|
+
* and `key = os.environ["KEY"]` stay readable.
|
|
5339
|
+
*/
|
|
5340
|
+
var SECRET_BARE = new RegExp(`(^|\\n)(\\s*(?:export\\s+)?${SECRET_NAME}\\s*[:=]\\s*)([^\\s"'\`#(\\[{]{4,})`, "gi");
|
|
5341
|
+
var looksLikeAValue = (v) => /[0-9]/.test(v) && /[A-Za-z]/.test(v) && !/[()[\]{}]/.test(v);
|
|
5342
|
+
/**
|
|
5343
|
+
* Text with every secret it holds taken out: private keys, known token shapes, long encoded values,
|
|
5344
|
+
* credentials in addresses, literal values assigned to names like `password`, and the exact values
|
|
5345
|
+
* of the person's own local settings.
|
|
5346
|
+
*/
|
|
5347
|
+
function redact(text, knownValues = []) {
|
|
5348
|
+
let clean = text.replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[a private key, left out]").replace(SECRET_VALUES, "[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]@").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, "$1 [a secret, left out]").replace(SECRET_ASSIGNMENT, "$1$2$3[a secret, left out]$3").replace(SECRET_BARE, (all, start, name, value) => looksLikeAValue(value) ? `${start}${name}[a secret, left out]` : all);
|
|
5349
|
+
for (const value of knownValues) if (value.length >= 6) clean = clean.split(value).join("[a setting's value, left out]");
|
|
5350
|
+
return clean;
|
|
5351
|
+
}
|
|
5352
|
+
/**
|
|
5353
|
+
* The values in the person's own settings files, such as `.env`, read here so the same values are
|
|
5354
|
+
* taken out of anything sent. They never leave: only their absence does.
|
|
5355
|
+
*/
|
|
5356
|
+
async function localValues(root, settingsFiles) {
|
|
5357
|
+
const values = /* @__PURE__ */ new Set();
|
|
5358
|
+
for (const file of settingsFiles) {
|
|
5359
|
+
if (EXAMPLE.test(file)) continue;
|
|
5360
|
+
const text = await readFile(resolve(root, file), "utf8").catch(() => "");
|
|
5361
|
+
for (const line of text.split(/\r?\n/)) {
|
|
5362
|
+
const value = /^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*[=:]\s*(.*)$/.exec(line)?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
5363
|
+
if (value && value.length >= 6) values.add(value);
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
return [...values];
|
|
5367
|
+
}
|
|
5245
5368
|
// ../../packages/scan/src/symbols.ts
|
|
5246
5369
|
var SHORT = 300;
|
|
5247
5370
|
var PROMPT_TEXT = 6e3;
|
|
@@ -5249,10 +5372,6 @@ var MAX_CALLS = 40;
|
|
|
5249
5372
|
/** A string this long, or named like a prompt, can be a prompt. */
|
|
5250
5373
|
var PROMPT_LENGTH = 80;
|
|
5251
5374
|
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
5375
|
var short = (text, max = SHORT) => {
|
|
5257
5376
|
const clean = redact(text.replace(/\s+/g, " ").trim());
|
|
5258
5377
|
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
@@ -5298,8 +5417,15 @@ function argumentValue(value) {
|
|
|
5298
5417
|
const inner = p.childForFieldName("value");
|
|
5299
5418
|
return inner ? `${key}: ${argumentValue(inner)}` : key;
|
|
5300
5419
|
}).join(", ")}}`, 1200);
|
|
5420
|
+
if (FUNCTION_VALUE.has(value.type)) return "function(…)";
|
|
5301
5421
|
return short(value.text, 120);
|
|
5302
5422
|
}
|
|
5423
|
+
var FUNCTION_VALUE = /* @__PURE__ */ new Set([
|
|
5424
|
+
"arrow_function",
|
|
5425
|
+
"function_expression",
|
|
5426
|
+
"function",
|
|
5427
|
+
"lambda"
|
|
5428
|
+
]);
|
|
5303
5429
|
function declarationArgs(call) {
|
|
5304
5430
|
const args = {};
|
|
5305
5431
|
const list = call.childForFieldName("arguments");
|
|
@@ -5492,6 +5618,72 @@ function docOf(node, file) {
|
|
|
5492
5618
|
const before = node.previousSibling;
|
|
5493
5619
|
if (before?.type === "comment" && before.endPosition.row + 1 >= node.startPosition.row) return short(before.text.replace(/^\/\*+|\*+\/$|^\s*\*\s?|^\/\/\s?/gm, ""), 600);
|
|
5494
5620
|
}
|
|
5621
|
+
// ../../packages/scan/src/start-candidates.ts
|
|
5622
|
+
/** Words a README command starts with that start an app, not install or test one. */
|
|
5623
|
+
var STARTS = /^(?:\$\s*)?((?:uvicorn|gunicorn|hypercorn|fastapi (?:dev|run)|flask (?:--app \S+ )?run|streamlit run|chainlit run|gradio|python3? (?:-m )?\S+\.py|python3? -m \S+|(?:npm|pnpm|yarn|bun) (?:run )?(?:dev|start|serve)|node \S+\.(?:m?js|ts)|npx tsx \S+|docker compose up|docker-compose up)\b.*)$/;
|
|
5624
|
+
async function startCandidates(look, project) {
|
|
5625
|
+
const files = new Set(project.files.map((f) => f.path));
|
|
5626
|
+
const read = (file) => readFile(join(look.root, file), "utf8").catch(() => null);
|
|
5627
|
+
const found = [];
|
|
5628
|
+
for (const file of project.files) {
|
|
5629
|
+
if (file.language !== "python") continue;
|
|
5630
|
+
const module = file.path.replace(/\.py$/, "").replace(/\//g, ".");
|
|
5631
|
+
const standsAlone = module.includes(".") || !/^from \.+\w*\s+import/m.test(file.text);
|
|
5632
|
+
for (const d of symbolsOf(file).declarations) {
|
|
5633
|
+
const callee = d.callee.split(".").at(-1) ?? "";
|
|
5634
|
+
if (!d.name) continue;
|
|
5635
|
+
if (!standsAlone) continue;
|
|
5636
|
+
if (/^(FastAPI|Starlette)$/.test(callee)) found.push({
|
|
5637
|
+
command: `uvicorn ${module}:${d.name}`,
|
|
5638
|
+
from: `the ${callee} app ${d.name} at ${file.path}:${d.line.toString()}`
|
|
5639
|
+
});
|
|
5640
|
+
else if (callee === "Flask") found.push({
|
|
5641
|
+
command: `flask --app ${module}:${d.name} run`,
|
|
5642
|
+
from: `the Flask app ${d.name} at ${file.path}:${d.line.toString()}`
|
|
5643
|
+
});
|
|
5644
|
+
}
|
|
5645
|
+
const main = file.text.split("\n").findIndex((l) => /^if __name__\s*==\s*["']__main__["']/.test(l));
|
|
5646
|
+
if (main >= 0) found.push({
|
|
5647
|
+
command: `python ${file.path}`,
|
|
5648
|
+
from: `the main block at ${file.path}:${(main + 1).toString()}`
|
|
5649
|
+
});
|
|
5650
|
+
}
|
|
5651
|
+
const cmd = (await read("Dockerfile"))?.split("\n").reverse().find((l) => /^\s*(CMD|ENTRYPOINT)\s/i.test(l));
|
|
5652
|
+
if (cmd) {
|
|
5653
|
+
const body = cmd.replace(/^\s*(CMD|ENTRYPOINT)\s+/i, "").trim();
|
|
5654
|
+
let command = body;
|
|
5655
|
+
try {
|
|
5656
|
+
const parts = JSON.parse(body);
|
|
5657
|
+
if (Array.isArray(parts)) command = parts.map(String).join(" ");
|
|
5658
|
+
} catch {}
|
|
5659
|
+
if (command) found.push({
|
|
5660
|
+
command,
|
|
5661
|
+
from: "the Dockerfile's command"
|
|
5662
|
+
});
|
|
5663
|
+
}
|
|
5664
|
+
for (const name of [
|
|
5665
|
+
"README.md",
|
|
5666
|
+
"readme.md",
|
|
5667
|
+
"README.rst",
|
|
5668
|
+
"README"
|
|
5669
|
+
]) {
|
|
5670
|
+
const text = await read(name);
|
|
5671
|
+
if (!text) continue;
|
|
5672
|
+
const blocks = text.match(/```[^\n]*\n[\s\S]*?```/g) ?? [];
|
|
5673
|
+
for (const line of blocks.flatMap((b) => b.split("\n"))) if (STARTS.test(line.trim())) found.push({
|
|
5674
|
+
command: line.trim().replace(/^\$\s*/, ""),
|
|
5675
|
+
from: name
|
|
5676
|
+
});
|
|
5677
|
+
break;
|
|
5678
|
+
}
|
|
5679
|
+
const seen = new Set(look.startCommands.map((c) => c.command));
|
|
5680
|
+
return found.filter((c) => {
|
|
5681
|
+
if (seen.has(c.command)) return false;
|
|
5682
|
+
seen.add(c.command);
|
|
5683
|
+
const file = c.command.split(/\s+/).find((w) => /\.(py|m?js|ts)$/.test(w));
|
|
5684
|
+
return !file || files.has(file.replace(/^\.\//, ""));
|
|
5685
|
+
});
|
|
5686
|
+
}
|
|
5495
5687
|
// ../../packages/scan/src/outline.ts
|
|
5496
5688
|
/** About 60,000 tokens: enough for an app's code, small enough to be quick and cheap. */
|
|
5497
5689
|
var BUDGET = 24e4;
|
|
@@ -5508,10 +5700,10 @@ function priority(file) {
|
|
|
5508
5700
|
return score;
|
|
5509
5701
|
}
|
|
5510
5702
|
/** Prompt and configuration files the code names, or that sit where prompts are kept. */
|
|
5511
|
-
async function documentsOf(look, outlines) {
|
|
5703
|
+
async function documentsOf(look, outlines, known) {
|
|
5512
5704
|
const named = new Set(outlines.flatMap((f) => [...f.strings.map((s) => s.text), ...f.declarations.flatMap((d) => Object.values(d.args))]));
|
|
5513
5705
|
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));
|
|
5706
|
+
const wanted = look.documents.filter((path) => !neverRead(path) && (mentioned(path) || /(^|\/)(prompts?|config|instructions|agents|skills)\//i.test(path)));
|
|
5515
5707
|
const documents = [];
|
|
5516
5708
|
let total = 0;
|
|
5517
5709
|
for (const file of wanted) {
|
|
@@ -5521,7 +5713,7 @@ async function documentsOf(look, outlines) {
|
|
|
5521
5713
|
total += text.length;
|
|
5522
5714
|
documents.push({
|
|
5523
5715
|
file,
|
|
5524
|
-
text: redact(text)
|
|
5716
|
+
text: redact(text, known)
|
|
5525
5717
|
});
|
|
5526
5718
|
}
|
|
5527
5719
|
return documents;
|
|
@@ -5532,6 +5724,7 @@ var where$1 = (f) => ({
|
|
|
5532
5724
|
});
|
|
5533
5725
|
async function buildOutline(look, facts, project) {
|
|
5534
5726
|
const outlines = project.files.map(symbolsOf);
|
|
5727
|
+
const known = await localValues(look.root, look.settingsFiles);
|
|
5535
5728
|
const ranked = [...outlines].sort((a, b) => priority(b) - priority(a));
|
|
5536
5729
|
const code = [];
|
|
5537
5730
|
const omitted = [];
|
|
@@ -5544,15 +5737,18 @@ async function buildOutline(look, facts, project) {
|
|
|
5544
5737
|
size += bytes;
|
|
5545
5738
|
}
|
|
5546
5739
|
}
|
|
5547
|
-
|
|
5740
|
+
const outline = {
|
|
5548
5741
|
version: 2,
|
|
5549
5742
|
name: look.name,
|
|
5550
5743
|
files: look.sourceFiles,
|
|
5551
5744
|
dependencies: [...look.dependencies].sort(),
|
|
5552
|
-
scripts: look.startCommands
|
|
5745
|
+
scripts: look.startCommands.map((s) => ({
|
|
5746
|
+
...s,
|
|
5747
|
+
command: redact(s.command, known)
|
|
5748
|
+
})),
|
|
5553
5749
|
settings: look.settingNames,
|
|
5554
5750
|
code,
|
|
5555
|
-
documents: await documentsOf(look, outlines),
|
|
5751
|
+
documents: await documentsOf(look, outlines, known),
|
|
5556
5752
|
found: {
|
|
5557
5753
|
tools: facts.flatMap((f) => f.kind === "tool" ? [{
|
|
5558
5754
|
name: f.name,
|
|
@@ -5566,6 +5762,7 @@ async function buildOutline(look, facts, project) {
|
|
|
5566
5762
|
},
|
|
5567
5763
|
omitted
|
|
5568
5764
|
};
|
|
5765
|
+
return known.length === 0 ? outline : JSON.parse(known.reduce((text, value) => text.split(JSON.stringify(value).slice(1, -1)).join("[a setting's value, left out]"), JSON.stringify(outline)));
|
|
5569
5766
|
}
|
|
5570
5767
|
// ../../packages/scan/src/verify.ts
|
|
5571
5768
|
/** Names compared as people read them: case, spaces, dashes and underscores aside. */
|
|
@@ -5836,36 +6033,6 @@ function mergeFacts(read, verified) {
|
|
|
5836
6033
|
});
|
|
5837
6034
|
return [...read, ...extra];
|
|
5838
6035
|
}
|
|
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
|
-
}
|
|
5869
6036
|
// ../../packages/scan/src/draft.ts
|
|
5870
6037
|
var CONFIDENCE_THRESHOLD = .7;
|
|
5871
6038
|
var MAX_QUESTIONS = 7;
|
|
@@ -6114,10 +6281,650 @@ function applyAnswer(config, question, value) {
|
|
|
6114
6281
|
}
|
|
6115
6282
|
return config;
|
|
6116
6283
|
}
|
|
6284
|
+
// ../../packages/scan/src/graph.ts
|
|
6285
|
+
var SERVERS = /^(FastAPI|Flask|Starlette|Quart|Sanic|express|Hono|Koa|fastify|Elysia|APIRouter|Blueprint)$/;
|
|
6286
|
+
/**
|
|
6287
|
+
* What a file imports, as names that can resolve to files: each module, and for Python's
|
|
6288
|
+
* `from a import b` also `a.b`, since b may be a module of its own.
|
|
6289
|
+
*/
|
|
6290
|
+
function importsOf(file) {
|
|
6291
|
+
const names = /* @__PURE__ */ new Set();
|
|
6292
|
+
walk(file.root, (node) => {
|
|
6293
|
+
if (node.type === "import_statement" || node.type === "import_from_statement") {
|
|
6294
|
+
const module = node.childForFieldName("module_name");
|
|
6295
|
+
const source = module?.text ?? stringValue(node.childForFieldName("source"));
|
|
6296
|
+
if (source) names.add(source);
|
|
6297
|
+
for (const child of node.namedChildren) if (child && child !== module && child.type === "dotted_name") names.add(source === void 0 ? child.text : `${source}${source.endsWith(".") ? "" : "."}${child.text}`);
|
|
6298
|
+
}
|
|
6299
|
+
});
|
|
6300
|
+
return [...names];
|
|
6301
|
+
}
|
|
6302
|
+
function buildGraph(sources, symbols, extra = []) {
|
|
6303
|
+
const files = symbols;
|
|
6304
|
+
const known = new Set(sources.map((f) => f.path));
|
|
6305
|
+
const imports = /* @__PURE__ */ new Map();
|
|
6306
|
+
const importedBy = /* @__PURE__ */ new Map();
|
|
6307
|
+
for (const source of sources) {
|
|
6308
|
+
const f = {
|
|
6309
|
+
file: source.path,
|
|
6310
|
+
imports: importsOf(source)
|
|
6311
|
+
};
|
|
6312
|
+
const resolved = [...new Set(f.imports.flatMap((name) => resolveImport(f.file, name, known) ?? []))];
|
|
6313
|
+
imports.set(f.file, resolved);
|
|
6314
|
+
for (const target of resolved) importedBy.set(target, [...importedBy.get(target) ?? [], f.file]);
|
|
6315
|
+
}
|
|
6316
|
+
const entries = [...extra];
|
|
6317
|
+
for (const f of files) for (const d of f.declarations) if (SERVERS.test(d.callee.split(".").at(-1) ?? "")) entries.push({
|
|
6318
|
+
file: f.file,
|
|
6319
|
+
line: d.line,
|
|
6320
|
+
why: `${d.callee}()`
|
|
6321
|
+
});
|
|
6322
|
+
const distance = /* @__PURE__ */ new Map();
|
|
6323
|
+
let frontier = [...new Set(entries.map((e) => e.file))].filter((f) => known.has(f));
|
|
6324
|
+
for (const f of frontier) distance.set(f, 0);
|
|
6325
|
+
for (let step = 1; frontier.length > 0; step++) {
|
|
6326
|
+
const next = [];
|
|
6327
|
+
for (const f of frontier) for (const target of imports.get(f) ?? []) if (!distance.has(target)) {
|
|
6328
|
+
distance.set(target, step);
|
|
6329
|
+
next.push(target);
|
|
6330
|
+
}
|
|
6331
|
+
frontier = next;
|
|
6332
|
+
}
|
|
6333
|
+
return {
|
|
6334
|
+
imports,
|
|
6335
|
+
importedBy,
|
|
6336
|
+
entries,
|
|
6337
|
+
distance
|
|
6338
|
+
};
|
|
6339
|
+
}
|
|
6340
|
+
/** The repository file an import names, or null for a package from outside. */
|
|
6341
|
+
function resolveImport(from, name, known) {
|
|
6342
|
+
const dir = from.includes("/") ? from.slice(0, from.lastIndexOf("/")) : "";
|
|
6343
|
+
const join = (base, rest) => normalise(base ? `${base}/${rest}` : rest);
|
|
6344
|
+
const first = (candidates) => candidates.find((c) => known.has(c)) ?? null;
|
|
6345
|
+
if (name.startsWith("./") || name.startsWith("../")) {
|
|
6346
|
+
const base = join(dir, name).replace(/\.(m?js|cjs)$/, "");
|
|
6347
|
+
return first([...[
|
|
6348
|
+
"",
|
|
6349
|
+
".ts",
|
|
6350
|
+
".tsx",
|
|
6351
|
+
".js",
|
|
6352
|
+
".mjs",
|
|
6353
|
+
".jsx",
|
|
6354
|
+
".cjs"
|
|
6355
|
+
].map((e) => base + e), ...[
|
|
6356
|
+
"ts",
|
|
6357
|
+
"tsx",
|
|
6358
|
+
"js",
|
|
6359
|
+
"mjs"
|
|
6360
|
+
].map((e) => `${base}/index.${e}`)]);
|
|
6361
|
+
}
|
|
6362
|
+
if (name.includes("/")) return null;
|
|
6363
|
+
const dots = /^\.+/.exec(name)?.[0].length ?? 0;
|
|
6364
|
+
const rest = name.slice(dots).replace(/\./g, "/");
|
|
6365
|
+
let base;
|
|
6366
|
+
if (dots > 0) {
|
|
6367
|
+
const up = dir.split("/").filter(Boolean);
|
|
6368
|
+
base = up.slice(0, Math.max(0, up.length - (dots - 1))).join("/");
|
|
6369
|
+
} else base = "";
|
|
6370
|
+
const path = rest ? base ? `${base}/${rest}` : rest : base;
|
|
6371
|
+
return first((dots > 0 ? [""] : [
|
|
6372
|
+
"",
|
|
6373
|
+
"src/",
|
|
6374
|
+
"app/"
|
|
6375
|
+
]).flatMap((root) => [`${root}${path}.py`, `${root}${path}/__init__.py`]));
|
|
6376
|
+
}
|
|
6377
|
+
function normalise(path) {
|
|
6378
|
+
const out = [];
|
|
6379
|
+
for (const part of path.split("/")) {
|
|
6380
|
+
if (part === "" || part === ".") continue;
|
|
6381
|
+
if (part === "..") out.pop();
|
|
6382
|
+
else out.push(part);
|
|
6383
|
+
}
|
|
6384
|
+
return out.join("/");
|
|
6385
|
+
}
|
|
6386
|
+
// ../../packages/scan/src/tools.ts
|
|
6387
|
+
var LIMITS = {
|
|
6388
|
+
/** Lines and characters one read may return. */
|
|
6389
|
+
readLines: 200,
|
|
6390
|
+
readChars: 12e3,
|
|
6391
|
+
grepMatches: 40,
|
|
6392
|
+
listEntries: 200,
|
|
6393
|
+
/** Characters of code all reads and searches in one scan may send, together. */
|
|
6394
|
+
codeBudget: 2e5,
|
|
6395
|
+
maxFileBytes: 1e6
|
|
6396
|
+
};
|
|
6397
|
+
var CODE_TOOLS = ["grep", "read"];
|
|
6398
|
+
/** Every file in the repository the tools could see, skipping what the scan never walks. */
|
|
6399
|
+
async function repositoryFiles(root, pattern = "**/*", most = 5e3) {
|
|
6400
|
+
const found = [];
|
|
6401
|
+
for await (const entry of glob(pattern, {
|
|
6402
|
+
cwd: root,
|
|
6403
|
+
exclude: (name) => SKIPPED.has(String(name).split(/[\\/]/).at(-1) ?? "")
|
|
6404
|
+
})) {
|
|
6405
|
+
const path = String(entry).split("\\").join("/");
|
|
6406
|
+
if ((await stat(join(root, path)).catch(() => null))?.isFile()) found.push(path);
|
|
6407
|
+
if (found.length >= most) break;
|
|
6408
|
+
}
|
|
6409
|
+
return found.sort();
|
|
6410
|
+
}
|
|
6411
|
+
function createTools(input) {
|
|
6412
|
+
const { root, symbols, graph, consent } = input;
|
|
6413
|
+
const known = input.knownValues ?? [];
|
|
6414
|
+
let spent = 0;
|
|
6415
|
+
const byFile = new Map(symbols.map((s) => [s.file, s]));
|
|
6416
|
+
const files = (pattern = "**/*") => repositoryFiles(root, pattern);
|
|
6417
|
+
const tools = {
|
|
6418
|
+
async list(args) {
|
|
6419
|
+
const dir = String(args.dir ?? ".");
|
|
6420
|
+
const inRepo = await insideRepository(root, dir);
|
|
6421
|
+
if (inRepo === null) return refused(dir, "it is not a folder in this repository");
|
|
6422
|
+
const entries = await readdir(join(root, inRepo), { withFileTypes: true }).catch(() => null);
|
|
6423
|
+
if (!entries) return refused(dir, "it is not a folder in this repository");
|
|
6424
|
+
return {
|
|
6425
|
+
text: entries.filter((e) => !SKIPPED.has(e.name)).sort((a, b) => a.name.localeCompare(b.name)).slice(0, LIMITS.listEntries).map((e) => e.isDirectory() ? `${e.name}/` : neverRead(join(inRepo, e.name)) ? `${e.name} (never read)` : e.name).join("\n") || "(empty)",
|
|
6426
|
+
code: 0,
|
|
6427
|
+
about: inRepo || "."
|
|
6428
|
+
};
|
|
6429
|
+
},
|
|
6430
|
+
async glob(args) {
|
|
6431
|
+
const pattern = String(args.pattern ?? "");
|
|
6432
|
+
if (!pattern || pattern.includes("..")) return refused(pattern, "it is not a pattern inside the repository");
|
|
6433
|
+
return {
|
|
6434
|
+
text: (await files(pattern)).slice(0, LIMITS.listEntries).join("\n") || "(nothing matches)",
|
|
6435
|
+
code: 0,
|
|
6436
|
+
about: pattern
|
|
6437
|
+
};
|
|
6438
|
+
},
|
|
6439
|
+
async grep(args) {
|
|
6440
|
+
const text = String(args.text ?? "").toLowerCase();
|
|
6441
|
+
if (text.length < 2) return refused(text, "the text to find is too short");
|
|
6442
|
+
const lines = [];
|
|
6443
|
+
for (const path of await files(String(args.glob ?? "**/*"))) {
|
|
6444
|
+
if (neverRead(path)) continue;
|
|
6445
|
+
if ((await stat(join(root, path))).size > LIMITS.maxFileBytes) continue;
|
|
6446
|
+
const body = await readFile(join(root, path), "utf8").catch(() => "");
|
|
6447
|
+
if (body.includes("\0")) continue;
|
|
6448
|
+
body.split(/\r?\n/).forEach((line, i) => {
|
|
6449
|
+
if (lines.length < LIMITS.grepMatches && line.toLowerCase().includes(text)) lines.push(`${path}:${(i + 1).toString()}: ${redact(line.trim(), known).slice(0, 200)}`);
|
|
6450
|
+
});
|
|
6451
|
+
if (lines.length >= LIMITS.grepMatches) break;
|
|
6452
|
+
}
|
|
6453
|
+
const out = lines.join("\n") || "(not found)";
|
|
6454
|
+
return {
|
|
6455
|
+
text: out,
|
|
6456
|
+
code: out.length,
|
|
6457
|
+
about: `"${text}"`
|
|
6458
|
+
};
|
|
6459
|
+
},
|
|
6460
|
+
async read(args) {
|
|
6461
|
+
const path = String(args.path ?? "");
|
|
6462
|
+
const inRepo = await insideRepository(root, path);
|
|
6463
|
+
if (inRepo === null) return refused(path, "it is not a file in this repository");
|
|
6464
|
+
const why = neverRead(inRepo);
|
|
6465
|
+
if (why) return refused(inRepo, why);
|
|
6466
|
+
if ((await stat(join(root, inRepo))).size > LIMITS.maxFileBytes) return refused(inRepo, "it is over 1 MB");
|
|
6467
|
+
const all = (await readFile(join(root, inRepo), "utf8")).split(/\r?\n/);
|
|
6468
|
+
const from = Math.max(1, Number(args.from ?? (args.to === void 0 ? 1 : args.to - LIMITS.readLines + 1)));
|
|
6469
|
+
const to = Math.min(all.length, Number(args.to ?? from + LIMITS.readLines - 1), from + LIMITS.readLines - 1);
|
|
6470
|
+
let text = "";
|
|
6471
|
+
let last = from - 1;
|
|
6472
|
+
for (let n = from; n <= to; n++) {
|
|
6473
|
+
const line = `${n.toString()} ${redact(all[n - 1] ?? "", known)}\n`;
|
|
6474
|
+
if (text.length + line.length > LIMITS.readChars) break;
|
|
6475
|
+
text += line;
|
|
6476
|
+
last = n;
|
|
6477
|
+
}
|
|
6478
|
+
const more = last < all.length ? `(lines ${from.toString()} to ${last.toString()} of ${all.length.toString()}; ask from ${(last + 1).toString()} for the rest)` : `(lines ${from.toString()} to ${last.toString()}, the end of the file)`;
|
|
6479
|
+
return {
|
|
6480
|
+
text: `${text}${more}`,
|
|
6481
|
+
code: text.length,
|
|
6482
|
+
about: `${inRepo}:${from.toString()}-${last.toString()}`
|
|
6483
|
+
};
|
|
6484
|
+
},
|
|
6485
|
+
async symbols(args) {
|
|
6486
|
+
if (args.path) {
|
|
6487
|
+
const path = String(args.path).split("\\").join("/");
|
|
6488
|
+
const s = byFile.get(path);
|
|
6489
|
+
if (!s) return refused(path, "the index has no source file by that name");
|
|
6490
|
+
return {
|
|
6491
|
+
text: redact([
|
|
6492
|
+
`${s.file}, ${s.lines.toString()} lines`,
|
|
6493
|
+
`imports here: ${(graph.imports.get(s.file) ?? []).join(", ") || "none"}`,
|
|
6494
|
+
`imported by: ${(graph.importedBy.get(s.file) ?? []).join(", ") || "nothing here"}`,
|
|
6495
|
+
...s.symbols.map((x) => `${x.kind} ${x.name}${x.signature ?? ""} at ${x.line.toString()}-${x.end.toString()}${x.decorators?.length ? `, ${x.decorators.join(" ")}` : ""}${x.doc ? `: ${x.doc}` : ""}`)
|
|
6496
|
+
].join("\n"), known),
|
|
6497
|
+
code: 0,
|
|
6498
|
+
about: path
|
|
6499
|
+
};
|
|
6500
|
+
}
|
|
6501
|
+
const name = String(args.name ?? "");
|
|
6502
|
+
if (!name) return refused("", "give a path or a name");
|
|
6503
|
+
const defined = symbols.flatMap((s) => s.symbols.filter((x) => x.name === name).map((x) => `defined: ${s.file}:${x.line.toString()}-${x.end.toString()} ${x.kind}${x.signature ?? ""}`));
|
|
6504
|
+
const used = symbols.filter((s) => s.symbols.some((x) => x.calls?.includes(name))).map((s) => `called by name in: ${s.file}`);
|
|
6505
|
+
return {
|
|
6506
|
+
text: `${[...defined, ...used].join("\n") || "(the index has no such name)"}\n(references are by name, not resolved)`,
|
|
6507
|
+
code: 0,
|
|
6508
|
+
about: name
|
|
6509
|
+
};
|
|
6510
|
+
}
|
|
6511
|
+
};
|
|
6512
|
+
return {
|
|
6513
|
+
/** Where reading starts: entries first, with why each is one. */
|
|
6514
|
+
entries: graph.entries,
|
|
6515
|
+
allowed: () => consent === "code" ? [
|
|
6516
|
+
"list",
|
|
6517
|
+
"glob",
|
|
6518
|
+
"grep",
|
|
6519
|
+
"read",
|
|
6520
|
+
"symbols"
|
|
6521
|
+
] : [
|
|
6522
|
+
"list",
|
|
6523
|
+
"glob",
|
|
6524
|
+
"symbols"
|
|
6525
|
+
],
|
|
6526
|
+
spent: () => spent,
|
|
6527
|
+
async run(call) {
|
|
6528
|
+
if (CODE_TOOLS.includes(call.tool) && consent !== "code") return {
|
|
6529
|
+
id: call.id,
|
|
6530
|
+
text: "Not allowed: the person agreed to send an outline, not code.",
|
|
6531
|
+
codeChars: 0,
|
|
6532
|
+
about: call.tool
|
|
6533
|
+
};
|
|
6534
|
+
if (CODE_TOOLS.includes(call.tool) && spent >= LIMITS.codeBudget) return {
|
|
6535
|
+
id: call.id,
|
|
6536
|
+
text: "The reading budget for this scan is spent: answer with what you have.",
|
|
6537
|
+
codeChars: 0,
|
|
6538
|
+
about: call.tool
|
|
6539
|
+
};
|
|
6540
|
+
const tool = tools[call.tool];
|
|
6541
|
+
if (!tool) return {
|
|
6542
|
+
id: call.id,
|
|
6543
|
+
text: `No tool named ${String(call.tool)}.`,
|
|
6544
|
+
codeChars: 0,
|
|
6545
|
+
about: ""
|
|
6546
|
+
};
|
|
6547
|
+
const out = await tool(call).catch((error) => refused("", `it could not be read: ${String(error).slice(0, 120)}`));
|
|
6548
|
+
spent += out.code;
|
|
6549
|
+
return {
|
|
6550
|
+
id: call.id,
|
|
6551
|
+
text: out.text,
|
|
6552
|
+
codeChars: out.code,
|
|
6553
|
+
about: `${call.tool} ${out.about}`
|
|
6554
|
+
};
|
|
6555
|
+
}
|
|
6556
|
+
};
|
|
6557
|
+
}
|
|
6558
|
+
function refused(what, why) {
|
|
6559
|
+
return {
|
|
6560
|
+
text: `Not read: ${what ? `${what}, because ` : ""}${why}.`,
|
|
6561
|
+
code: 0,
|
|
6562
|
+
about: what
|
|
6563
|
+
};
|
|
6564
|
+
}
|
|
6565
|
+
// ../../packages/scan/src/explore.ts
|
|
6566
|
+
/** More turns than the server allows, so a server that never says stop cannot keep it reading. */
|
|
6567
|
+
var MOST_TURNS$1 = 24;
|
|
6568
|
+
var MOST_UNSURE = 10;
|
|
6569
|
+
async function explore(input) {
|
|
6570
|
+
const { result, outline, consent } = input;
|
|
6571
|
+
const symbols = result.project.files.map(symbolsOf);
|
|
6572
|
+
const graph = buildGraph(result.project.files, symbols, entryHints(result));
|
|
6573
|
+
const tools = createTools({
|
|
6574
|
+
root: result.look.root,
|
|
6575
|
+
symbols,
|
|
6576
|
+
graph,
|
|
6577
|
+
consent,
|
|
6578
|
+
knownValues: input.knownValues
|
|
6579
|
+
});
|
|
6580
|
+
const base = {
|
|
6581
|
+
version: 1,
|
|
6582
|
+
scanId: input.scanId ?? randomUUID(),
|
|
6583
|
+
consent,
|
|
6584
|
+
outline,
|
|
6585
|
+
entries: graph.entries.slice(0, 50)
|
|
6586
|
+
};
|
|
6587
|
+
const steps = [];
|
|
6588
|
+
const asked = /* @__PURE__ */ new Map();
|
|
6589
|
+
let turns = 0;
|
|
6590
|
+
let stopped;
|
|
6591
|
+
const dropped = /* @__PURE__ */ new Map();
|
|
6592
|
+
const verify = (u) => {
|
|
6593
|
+
const v = verifyUnderstanding(u, result.look, result.project);
|
|
6594
|
+
for (const d of v.dropped) dropped.set(d.what, d);
|
|
6595
|
+
return v;
|
|
6596
|
+
};
|
|
6597
|
+
/** Two readings, each cut to what held, the surer kept for each item. */
|
|
6598
|
+
const merge = (a, va, b) => preferConfident(keepPassing(a, va), keepPassing(b, verify(b)));
|
|
6599
|
+
/** Turns until the model gives a map, running its calls in between. */
|
|
6600
|
+
const readUntilMap = async (mode, unsure) => {
|
|
6601
|
+
while (turns < MOST_TURNS$1) {
|
|
6602
|
+
turns++;
|
|
6603
|
+
const reply = await input.turn({
|
|
6604
|
+
...base,
|
|
6605
|
+
mode,
|
|
6606
|
+
steps,
|
|
6607
|
+
unsure
|
|
6608
|
+
});
|
|
6609
|
+
if (reply.map) return reply.map;
|
|
6610
|
+
if (reply.stopped || reply.calls.length === 0) {
|
|
6611
|
+
stopped = reply.stopped ?? "the model asked for nothing and gave no map";
|
|
6612
|
+
return null;
|
|
6613
|
+
}
|
|
6614
|
+
const calls = reply.calls.slice(0, 6);
|
|
6615
|
+
steps.push({
|
|
6616
|
+
role: "model",
|
|
6617
|
+
calls,
|
|
6618
|
+
note: reply.note
|
|
6619
|
+
});
|
|
6620
|
+
const results = [];
|
|
6621
|
+
for (const call of calls) {
|
|
6622
|
+
const key = JSON.stringify({
|
|
6623
|
+
...call,
|
|
6624
|
+
id: void 0
|
|
6625
|
+
});
|
|
6626
|
+
const earlier = asked.get(key);
|
|
6627
|
+
if (earlier) {
|
|
6628
|
+
results.push({
|
|
6629
|
+
id: call.id,
|
|
6630
|
+
text: `Already answered in [${earlier}]: ask for other lines or another file.`
|
|
6631
|
+
});
|
|
6632
|
+
continue;
|
|
6633
|
+
}
|
|
6634
|
+
asked.set(key, call.id);
|
|
6635
|
+
const answer = await tools.run(call);
|
|
6636
|
+
results.push({
|
|
6637
|
+
id: call.id,
|
|
6638
|
+
text: answer.text.slice(0, 2e4)
|
|
6639
|
+
});
|
|
6640
|
+
input.onProgress?.(answer.about);
|
|
6641
|
+
await input.onSent?.({
|
|
6642
|
+
what: answer.about,
|
|
6643
|
+
chars: answer.text.length
|
|
6644
|
+
});
|
|
6645
|
+
}
|
|
6646
|
+
steps.push({
|
|
6647
|
+
role: "tools",
|
|
6648
|
+
results
|
|
6649
|
+
});
|
|
6650
|
+
}
|
|
6651
|
+
stopped = "turns";
|
|
6652
|
+
return null;
|
|
6653
|
+
};
|
|
6654
|
+
let map = await readUntilMap("map");
|
|
6655
|
+
if (!map) return {
|
|
6656
|
+
map,
|
|
6657
|
+
verified: null,
|
|
6658
|
+
steps,
|
|
6659
|
+
turns,
|
|
6660
|
+
settled: false,
|
|
6661
|
+
stopped,
|
|
6662
|
+
dropped: []
|
|
6663
|
+
};
|
|
6664
|
+
let verified = verify(map);
|
|
6665
|
+
const problems = verified.dropped.map((d) => `${d.what}: ${d.why}`);
|
|
6666
|
+
if (problems.length > 0) {
|
|
6667
|
+
steps.push({
|
|
6668
|
+
role: "map",
|
|
6669
|
+
map
|
|
6670
|
+
}, {
|
|
6671
|
+
role: "checks",
|
|
6672
|
+
problems
|
|
6673
|
+
});
|
|
6674
|
+
const again = await readUntilMap("map");
|
|
6675
|
+
if (again) {
|
|
6676
|
+
map = merge(map, verified, again);
|
|
6677
|
+
verified = verify(map);
|
|
6678
|
+
}
|
|
6679
|
+
}
|
|
6680
|
+
const unsure = unsureOf(map, verified);
|
|
6681
|
+
let settled = false;
|
|
6682
|
+
if (unsure.length > 0 && !stopped) {
|
|
6683
|
+
if (steps.at(-1)?.role !== "checks") steps.push({
|
|
6684
|
+
role: "map",
|
|
6685
|
+
map
|
|
6686
|
+
});
|
|
6687
|
+
const second = await readUntilMap("settle", unsure.slice(0, MOST_UNSURE));
|
|
6688
|
+
if (second) {
|
|
6689
|
+
map = merge(map, verified, second);
|
|
6690
|
+
verified = verify(map);
|
|
6691
|
+
settled = true;
|
|
6692
|
+
}
|
|
6693
|
+
}
|
|
6694
|
+
return {
|
|
6695
|
+
map,
|
|
6696
|
+
verified,
|
|
6697
|
+
steps,
|
|
6698
|
+
turns,
|
|
6699
|
+
settled,
|
|
6700
|
+
stopped,
|
|
6701
|
+
dropped: [...dropped.values()]
|
|
6702
|
+
};
|
|
6703
|
+
}
|
|
6704
|
+
/**
|
|
6705
|
+
* Where the app starts, from the text the parse does not read as declarations: main blocks, server
|
|
6706
|
+
* starts, and files a start command names.
|
|
6707
|
+
*/
|
|
6708
|
+
function entryHints(result) {
|
|
6709
|
+
const hints = [];
|
|
6710
|
+
const known = new Set(result.project.files.map((f) => f.path));
|
|
6711
|
+
for (const file of result.project.files) file.text.split("\n").forEach((text, i) => {
|
|
6712
|
+
if (/^if __name__\s*==\s*["']__main__["']/.test(text)) hints.push({
|
|
6713
|
+
file: file.path,
|
|
6714
|
+
line: i + 1,
|
|
6715
|
+
why: "a main block"
|
|
6716
|
+
});
|
|
6717
|
+
else if (/\b(uvicorn\.run|\.listen|serve)\(/.test(text)) hints.push({
|
|
6718
|
+
file: file.path,
|
|
6719
|
+
line: i + 1,
|
|
6720
|
+
why: "starts a server"
|
|
6721
|
+
});
|
|
6722
|
+
});
|
|
6723
|
+
for (const start of result.look.startCommands) {
|
|
6724
|
+
const named = start.command.split(/\s+/).map((w) => w.replace(/^\.\//, "")).find((w) => known.has(w));
|
|
6725
|
+
if (named) hints.push({
|
|
6726
|
+
file: named,
|
|
6727
|
+
line: 1,
|
|
6728
|
+
why: `named by ${start.from}`
|
|
6729
|
+
});
|
|
6730
|
+
}
|
|
6731
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6732
|
+
return hints.filter((h) => {
|
|
6733
|
+
const key = `${h.file}:${h.line.toString()}`;
|
|
6734
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
6735
|
+
});
|
|
6736
|
+
}
|
|
6737
|
+
/** What --explain shows: the files the reader may ask for, and those it never gets, with why. */
|
|
6738
|
+
async function whatMayBeRead(root) {
|
|
6739
|
+
const mayRead = [];
|
|
6740
|
+
const never = [];
|
|
6741
|
+
for (const path of await repositoryFiles(root)) {
|
|
6742
|
+
const why = neverRead(path);
|
|
6743
|
+
const size = (await stat(join(root, path)).catch(() => null))?.size ?? 0;
|
|
6744
|
+
if (why) never.push({
|
|
6745
|
+
path,
|
|
6746
|
+
why
|
|
6747
|
+
});
|
|
6748
|
+
else if (size > LIMITS.maxFileBytes) never.push({
|
|
6749
|
+
path,
|
|
6750
|
+
why: "it is over 1 MB"
|
|
6751
|
+
});
|
|
6752
|
+
else mayRead.push(path);
|
|
6753
|
+
}
|
|
6754
|
+
return {
|
|
6755
|
+
mayRead,
|
|
6756
|
+
neverSent: never
|
|
6757
|
+
};
|
|
6758
|
+
}
|
|
6759
|
+
/** What a stronger model should look at again, each in a line it can act on. */
|
|
6760
|
+
function unsureOf(u, v) {
|
|
6761
|
+
const at = (x) => `${x.file}:${x.line.toString()}`;
|
|
6762
|
+
const low = (x) => x.confidence < CONFIDENCE_THRESHOLD;
|
|
6763
|
+
const out = [
|
|
6764
|
+
...v.dropped.map((d) => `${d.what} did not hold: ${d.why}`),
|
|
6765
|
+
...u.tools.filter(low).map((t) => `the tool ${t.name} at ${at(t)}: what it can do`),
|
|
6766
|
+
...u.tools.filter((t) => t.can === "read" && [
|
|
6767
|
+
"email",
|
|
6768
|
+
"subprocess",
|
|
6769
|
+
"code"
|
|
6770
|
+
].includes(t.leavesBy)).map((t) => `the tool ${t.name} at ${at(t)} is called read only, but its effect leaves by ${t.leavesBy}`),
|
|
6771
|
+
...u.waysIn.filter(low).map((w) => `the way in ${w.name} at ${at(w)}: who controls it`),
|
|
6772
|
+
...u.rules.filter(low).map((r) => `the rule "${r.never}"`)
|
|
6773
|
+
];
|
|
6774
|
+
if (u.modelCalls.length > 0 && u.tools.length === 0) out.push("the code calls a model but no tool was found: whether the model is given any tools");
|
|
6775
|
+
if (!u.entry || low(u.entry)) out.push("how a person sends the app a message, and where the reply is");
|
|
6776
|
+
if (u.start.length === 0) out.push("the command that starts the app");
|
|
6777
|
+
return out;
|
|
6778
|
+
}
|
|
6779
|
+
/** The map with only what held when checked against the code. */
|
|
6780
|
+
function keepPassing(u, v) {
|
|
6781
|
+
const at = (x) => `${x.file}:${x.line.toString()}`;
|
|
6782
|
+
const facts = new Set(v.facts.map((f) => `${f.kind} ${f.file}:${f.line.toString()}`));
|
|
6783
|
+
const held = (kind, x) => facts.has(`${kind} ${at(x)}`);
|
|
6784
|
+
const commands = new Set(v.start.map((s) => s.command));
|
|
6785
|
+
const unclear = new Set(v.unclear.map(at));
|
|
6786
|
+
return {
|
|
6787
|
+
...u,
|
|
6788
|
+
agents: u.agents.filter((x) => held("agent", x)),
|
|
6789
|
+
modelCalls: u.modelCalls.filter((x) => held("model", x)),
|
|
6790
|
+
tools: u.tools.filter((t) => v.effects.has(t.name)),
|
|
6791
|
+
systemPrompts: u.systemPrompts.filter((x) => held("system-prompt", x)),
|
|
6792
|
+
waysIn: u.waysIn.filter((w) => w.kind === "conversation" || held("way-in", w)),
|
|
6793
|
+
entry: u.entry && !v.dropped.some((d) => d.what === `the route ${u.entry?.path ?? ""}` || d.what === `the route at ${at(u.entry)}`) ? u.entry : null,
|
|
6794
|
+
start: u.start.filter((s) => commands.has(s.command)),
|
|
6795
|
+
rules: v.understanding.rules,
|
|
6796
|
+
unclear: u.unclear.filter((x) => unclear.has(at(x)))
|
|
6797
|
+
};
|
|
6798
|
+
}
|
|
6799
|
+
/** Per item, the more confident of two readings, keyed by what it is and where. */
|
|
6800
|
+
function preferConfident(first, second) {
|
|
6801
|
+
const pick = (a, b, key) => {
|
|
6802
|
+
const merged = new Map(a.map((x) => [key(x), x]));
|
|
6803
|
+
for (const item of b) {
|
|
6804
|
+
const existing = merged.get(key(item));
|
|
6805
|
+
if (!existing || item.confidence > existing.confidence) merged.set(key(item), item);
|
|
6806
|
+
}
|
|
6807
|
+
return [...merged.values()];
|
|
6808
|
+
};
|
|
6809
|
+
const at = (x) => `${x.file}:${x.line.toString()}`;
|
|
6810
|
+
return {
|
|
6811
|
+
...first,
|
|
6812
|
+
agents: pick(first.agents, second.agents, (x) => x.name),
|
|
6813
|
+
modelCalls: pick(first.modelCalls, second.modelCalls, at),
|
|
6814
|
+
tools: pick(first.tools, second.tools, (t) => t.name.toLowerCase()),
|
|
6815
|
+
systemPrompts: pick(first.systemPrompts, second.systemPrompts, at),
|
|
6816
|
+
waysIn: pick(first.waysIn, second.waysIn, (w) => w.name.toLowerCase()),
|
|
6817
|
+
privateData: pick(first.privateData, second.privateData, (d) => d.name),
|
|
6818
|
+
entry: (second.entry?.confidence ?? 0) > (first.entry?.confidence ?? 0) ? second.entry : first.entry,
|
|
6819
|
+
start: pick(first.start, second.start, (s) => s.command),
|
|
6820
|
+
rules: pick(first.rules, second.rules, (r) => r.never),
|
|
6821
|
+
unclear: second.unclear.length > 0 ? second.unclear : first.unclear,
|
|
6822
|
+
summary: second.summary || first.summary
|
|
6823
|
+
};
|
|
6824
|
+
}
|
|
6825
|
+
// ../../packages/scan/src/ask.ts
|
|
6826
|
+
var MOST_TURNS = 12;
|
|
6827
|
+
/** Said when no answer held: never a guess in its place. */
|
|
6828
|
+
var NO_CHECKED_ANSWER = "I could not give an answer I could check against your code. Try asking about one file, one route or one tool.";
|
|
6829
|
+
/** The citations in a piece of text that do not hold: a file that is not here, or a line past its end. */
|
|
6830
|
+
async function checkCitations(text, root) {
|
|
6831
|
+
const problems = [];
|
|
6832
|
+
for (const m of text.matchAll(/([\w./-]+\.[A-Za-z0-9]{1,6}):(\d+)(?:-(\d+))?/g)) {
|
|
6833
|
+
const [cited, path, from, to] = m;
|
|
6834
|
+
const inRepo = await insideRepository(root, path);
|
|
6835
|
+
if (inRepo === null) {
|
|
6836
|
+
problems.push(`${cited} names ${path}, which is not in the repository`);
|
|
6837
|
+
continue;
|
|
6838
|
+
}
|
|
6839
|
+
const lines = (await readFile(join(root, inRepo), "utf8").catch(() => "")).split("\n").length;
|
|
6840
|
+
const last = Number(to ?? from);
|
|
6841
|
+
if (Number(from) < 1 || last > lines) problems.push(`${cited} cites a line past the end of ${path}, which has ${lines.toString()} lines`);
|
|
6842
|
+
}
|
|
6843
|
+
return problems;
|
|
6844
|
+
}
|
|
6845
|
+
async function answerQuestion(input) {
|
|
6846
|
+
const { result, outline, consent } = input;
|
|
6847
|
+
const symbols = result.project.files.map(symbolsOf);
|
|
6848
|
+
const graph = buildGraph(result.project.files, symbols, entryHints(result));
|
|
6849
|
+
const tools = createTools({
|
|
6850
|
+
root: result.look.root,
|
|
6851
|
+
symbols,
|
|
6852
|
+
graph,
|
|
6853
|
+
consent,
|
|
6854
|
+
knownValues: input.knownValues
|
|
6855
|
+
});
|
|
6856
|
+
const base = {
|
|
6857
|
+
version: 1,
|
|
6858
|
+
scanId: randomUUID(),
|
|
6859
|
+
mode: "answer",
|
|
6860
|
+
consent,
|
|
6861
|
+
outline,
|
|
6862
|
+
entries: graph.entries.slice(0, 50),
|
|
6863
|
+
question: input.question
|
|
6864
|
+
};
|
|
6865
|
+
const steps = [];
|
|
6866
|
+
let turns = 0;
|
|
6867
|
+
let retried = false;
|
|
6868
|
+
while (turns < MOST_TURNS) {
|
|
6869
|
+
turns++;
|
|
6870
|
+
const reply = await input.turn({
|
|
6871
|
+
...base,
|
|
6872
|
+
steps
|
|
6873
|
+
});
|
|
6874
|
+
if (reply.answer) {
|
|
6875
|
+
const problems = [...reply.problems ?? [], ...await checkCitations(reply.answer, result.look.root)];
|
|
6876
|
+
if (problems.length === 0) return {
|
|
6877
|
+
answer: reply.answer,
|
|
6878
|
+
checked: true,
|
|
6879
|
+
turns
|
|
6880
|
+
};
|
|
6881
|
+
if (retried) break;
|
|
6882
|
+
retried = true;
|
|
6883
|
+
steps.push({
|
|
6884
|
+
role: "answer",
|
|
6885
|
+
text: reply.answer
|
|
6886
|
+
}, {
|
|
6887
|
+
role: "checks",
|
|
6888
|
+
problems
|
|
6889
|
+
});
|
|
6890
|
+
continue;
|
|
6891
|
+
}
|
|
6892
|
+
if (reply.stopped || reply.calls.length === 0) break;
|
|
6893
|
+
const calls = reply.calls.slice(0, 6);
|
|
6894
|
+
steps.push({
|
|
6895
|
+
role: "model",
|
|
6896
|
+
calls,
|
|
6897
|
+
note: reply.note
|
|
6898
|
+
});
|
|
6899
|
+
const results = [];
|
|
6900
|
+
for (const call of calls) {
|
|
6901
|
+
const answer = await tools.run(call);
|
|
6902
|
+
results.push({
|
|
6903
|
+
id: call.id,
|
|
6904
|
+
text: answer.text.slice(0, 2e4)
|
|
6905
|
+
});
|
|
6906
|
+
input.onProgress?.(answer.about);
|
|
6907
|
+
await input.onSent?.({
|
|
6908
|
+
what: answer.about,
|
|
6909
|
+
chars: answer.text.length
|
|
6910
|
+
});
|
|
6911
|
+
}
|
|
6912
|
+
steps.push({
|
|
6913
|
+
role: "tools",
|
|
6914
|
+
results
|
|
6915
|
+
});
|
|
6916
|
+
}
|
|
6917
|
+
return {
|
|
6918
|
+
answer: NO_CHECKED_ANSWER,
|
|
6919
|
+
checked: false,
|
|
6920
|
+
turns
|
|
6921
|
+
};
|
|
6922
|
+
}
|
|
6117
6923
|
// ../../packages/scan/src/index.ts
|
|
6118
6924
|
async function scanFolder(root) {
|
|
6119
6925
|
const found = await look(root);
|
|
6120
6926
|
const { facts, adapters, project } = await readFacts(found);
|
|
6927
|
+
found.startCommands.push(...await startCandidates(found, project));
|
|
6121
6928
|
return {
|
|
6122
6929
|
look: found,
|
|
6123
6930
|
facts,
|
|
@@ -13686,7 +14493,7 @@ var guid = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-
|
|
|
13686
14493
|
/** Returns a regex for validating an RFC 9562/4122 UUID.
|
|
13687
14494
|
*
|
|
13688
14495
|
* @param version Optionally specify a version 1-8. If no version is specified, all versions are supported. */
|
|
13689
|
-
var uuid = (version) => {
|
|
14496
|
+
var uuid$1 = (version) => {
|
|
13690
14497
|
if (!version) return /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/;
|
|
13691
14498
|
return new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`);
|
|
13692
14499
|
};
|
|
@@ -14221,8 +15028,8 @@ var $ZodUUID = /*@__PURE__*/ $constructor("$ZodUUID", (inst, def) => {
|
|
|
14221
15028
|
v8: 8
|
|
14222
15029
|
}[def.version];
|
|
14223
15030
|
if (v === void 0) throw new Error(`Invalid UUID version: "${def.version}"`);
|
|
14224
|
-
def.pattern ?? (def.pattern = uuid(v));
|
|
14225
|
-
} else def.pattern ?? (def.pattern = uuid());
|
|
15031
|
+
def.pattern ?? (def.pattern = uuid$1(v));
|
|
15032
|
+
} else def.pattern ?? (def.pattern = uuid$1());
|
|
14226
15033
|
$ZodStringFormat.init(inst, def);
|
|
14227
15034
|
});
|
|
14228
15035
|
var $ZodEmail = /*@__PURE__*/ $constructor("$ZodEmail", (inst, def) => {
|
|
@@ -14955,6 +15762,70 @@ var $ZodUnion = /*@__PURE__*/ $constructor("$ZodUnion", (inst, def) => {
|
|
|
14955
15762
|
});
|
|
14956
15763
|
};
|
|
14957
15764
|
});
|
|
15765
|
+
function discriminatorMap(def) {
|
|
15766
|
+
const map = /* @__PURE__ */ new Map();
|
|
15767
|
+
for (const option of def.options) {
|
|
15768
|
+
const values = option._zod.propValues?.[def.discriminator];
|
|
15769
|
+
if (!values || values.size === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option)}"`);
|
|
15770
|
+
for (const value of values) if (map.has(value)) {
|
|
15771
|
+
if (value !== void 0) throw new Error(`Duplicate discriminator value "${String(value)}"`);
|
|
15772
|
+
map.set(value, null);
|
|
15773
|
+
} else map.set(value, option);
|
|
15774
|
+
}
|
|
15775
|
+
return map;
|
|
15776
|
+
}
|
|
15777
|
+
var $ZodDiscriminatedUnion = /*@__PURE__*/ $constructor("$ZodDiscriminatedUnion", (inst, def) => {
|
|
15778
|
+
def.inclusive = false;
|
|
15779
|
+
$ZodUnion.init(inst, def);
|
|
15780
|
+
const _super = inst._zod.parse;
|
|
15781
|
+
defineLazyInternal(inst, "propValues", (zod) => {
|
|
15782
|
+
const propValues = {};
|
|
15783
|
+
let undefinedCount = 0;
|
|
15784
|
+
for (const option of zod.def.options) {
|
|
15785
|
+
const pv = option._zod.propValues;
|
|
15786
|
+
if (!pv || Object.keys(pv).length === 0) throw new Error(`Invalid discriminated union option at index "${zod.def.options.indexOf(option)}"`);
|
|
15787
|
+
if (pv[zod.def.discriminator]?.has(void 0)) undefinedCount++;
|
|
15788
|
+
for (const [k, v] of Object.entries(pv)) {
|
|
15789
|
+
if (!Object.prototype.hasOwnProperty.call(propValues, k)) assignProp(propValues, k, /* @__PURE__ */ new Set());
|
|
15790
|
+
for (const val of v) propValues[k].add(val);
|
|
15791
|
+
}
|
|
15792
|
+
}
|
|
15793
|
+
if (!zod.def.unionFallback && undefinedCount > 1) propValues[zod.def.discriminator]?.delete(void 0);
|
|
15794
|
+
return propValues;
|
|
15795
|
+
});
|
|
15796
|
+
def.options.forEach((option, i) => {
|
|
15797
|
+
const propShape = rawShape(option._zod.def);
|
|
15798
|
+
if (propShape && !Object.prototype.hasOwnProperty.call(propShape, def.discriminator)) throw new Error(`Invalid discriminated union option at index "${i}"`);
|
|
15799
|
+
});
|
|
15800
|
+
const disc = cached(() => discriminatorMap(def));
|
|
15801
|
+
inst._zod.parse = (payload, ctx) => {
|
|
15802
|
+
const input = payload.value;
|
|
15803
|
+
if (!isObject(input)) {
|
|
15804
|
+
payload.issues.push({
|
|
15805
|
+
code: "invalid_type",
|
|
15806
|
+
expected: "object",
|
|
15807
|
+
input,
|
|
15808
|
+
inst
|
|
15809
|
+
});
|
|
15810
|
+
return payload;
|
|
15811
|
+
}
|
|
15812
|
+
const value = input?.[def.discriminator];
|
|
15813
|
+
const opt = disc.value.get(value);
|
|
15814
|
+
if (opt && (value !== void 0 || ctx.direction !== "backward")) return opt._zod.run(payload, ctx);
|
|
15815
|
+
if (def.unionFallback || ctx.direction === "backward") return _super(payload, ctx);
|
|
15816
|
+
payload.issues.push({
|
|
15817
|
+
code: "invalid_union",
|
|
15818
|
+
errors: [],
|
|
15819
|
+
note: "No matching discriminator",
|
|
15820
|
+
discriminator: def.discriminator,
|
|
15821
|
+
options: Array.from(disc.value.keys()).filter((value) => disc.value.get(value) !== null),
|
|
15822
|
+
input,
|
|
15823
|
+
path: [def.discriminator],
|
|
15824
|
+
inst
|
|
15825
|
+
});
|
|
15826
|
+
return payload;
|
|
15827
|
+
};
|
|
15828
|
+
});
|
|
14958
15829
|
var $ZodIntersection = /*@__PURE__*/ $constructor("$ZodIntersection", (inst, def) => {
|
|
14959
15830
|
$ZodType.init(inst, def);
|
|
14960
15831
|
inst._zod.parse = (payload, ctx) => {
|
|
@@ -17839,6 +18710,9 @@ var ZodUUID = /*@__PURE__*/ $constructor("ZodUUID", (inst, def) => {
|
|
|
17839
18710
|
$ZodUUID.init(inst, def);
|
|
17840
18711
|
ZodStringFormat.init(inst, def);
|
|
17841
18712
|
});
|
|
18713
|
+
function uuid(params) {
|
|
18714
|
+
return /* @__PURE__ */ _uuid(ZodUUID, params);
|
|
18715
|
+
}
|
|
17842
18716
|
var ZodURL = /*@__PURE__*/ $constructor("ZodURL", (inst, def) => {
|
|
17843
18717
|
$ZodURL.init(inst, def);
|
|
17844
18718
|
ZodStringFormat.init(inst, def);
|
|
@@ -18104,6 +18978,18 @@ function union(options, params) {
|
|
|
18104
18978
|
...normalizeParams(params)
|
|
18105
18979
|
});
|
|
18106
18980
|
}
|
|
18981
|
+
var ZodDiscriminatedUnion = /*@__PURE__*/ $constructor("ZodDiscriminatedUnion", (inst, def) => {
|
|
18982
|
+
ZodUnion.init(inst, def);
|
|
18983
|
+
$ZodDiscriminatedUnion.init(inst, def);
|
|
18984
|
+
});
|
|
18985
|
+
function discriminatedUnion(discriminator, options, params) {
|
|
18986
|
+
return new ZodDiscriminatedUnion({
|
|
18987
|
+
type: "union",
|
|
18988
|
+
options,
|
|
18989
|
+
discriminator,
|
|
18990
|
+
...normalizeParams(params)
|
|
18991
|
+
});
|
|
18992
|
+
}
|
|
18107
18993
|
var ZodIntersection = /*@__PURE__*/ $constructor("ZodIntersection", (inst, def) => {
|
|
18108
18994
|
$ZodIntersection.init(inst, def);
|
|
18109
18995
|
ZodType.init(inst, def);
|
|
@@ -18529,6 +19415,11 @@ var Workspace = object({
|
|
|
18529
19415
|
*/
|
|
18530
19416
|
proofValue: string().optional()
|
|
18531
19417
|
});
|
|
19418
|
+
/**
|
|
19419
|
+
* Every ID we make: a workspace's eight hex characters, a slug, R-12, F-001. Nothing with a slash,
|
|
19420
|
+
* so an ID from a path or a body can never name a document in another workspace's tree.
|
|
19421
|
+
*/
|
|
19422
|
+
var DocId = string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
|
18532
19423
|
var SystemKind = _enum([
|
|
18533
19424
|
"agent",
|
|
18534
19425
|
"research-assistant",
|
|
@@ -18681,6 +19572,8 @@ var System = object({
|
|
|
18681
19572
|
}))
|
|
18682
19573
|
}).optional(),
|
|
18683
19574
|
tools: array(ToolSetup).default([]),
|
|
19575
|
+
/** When a runner last asked for this system's attempts. */
|
|
19576
|
+
runnerSeenAt: Timestamp.optional(),
|
|
18684
19577
|
summaryText: string().optional(),
|
|
18685
19578
|
policyId: string().optional(),
|
|
18686
19579
|
languages: array(string()).default(["English"]),
|
|
@@ -18877,7 +19770,9 @@ var Review = object({
|
|
|
18877
19770
|
returning: array(string()),
|
|
18878
19771
|
newBreaks: array(string()),
|
|
18879
19772
|
inBoth: array(string()),
|
|
18880
|
-
fixedSoFar: array(string())
|
|
19773
|
+
fixedSoFar: array(string()),
|
|
19774
|
+
/** Replays that met errors more than answers, counted apart. */
|
|
19775
|
+
notReplayed: array(string()).default([])
|
|
18881
19776
|
}).optional(),
|
|
18882
19777
|
/** Set when a review stops itself: the cost limit, or errors. */
|
|
18883
19778
|
stoppedBecause: string().optional(),
|
|
@@ -19053,8 +19948,11 @@ var Clause = object({
|
|
|
19053
19948
|
"medium"
|
|
19054
19949
|
]),
|
|
19055
19950
|
line: _enum(["not-allowed", "allowed-in-context"]),
|
|
19951
|
+
/** The exact sentence it came from, when it came from the team's own document. */
|
|
19056
19952
|
source: string().optional(),
|
|
19953
|
+
/** `proposed`: read from a document and not yet confirmed by a person, so never tested. */
|
|
19057
19954
|
state: _enum([
|
|
19955
|
+
"proposed",
|
|
19058
19956
|
"confirmed",
|
|
19059
19957
|
"needs-decision",
|
|
19060
19958
|
"settled"
|
|
@@ -19062,6 +19960,26 @@ var Clause = object({
|
|
|
19062
19960
|
settledBy: number().optional(),
|
|
19063
19961
|
neverTested: boolean().optional()
|
|
19064
19962
|
});
|
|
19963
|
+
/** One version of a team's content policy. Changing a clause makes a new version. */
|
|
19964
|
+
var PolicyVersion = object({
|
|
19965
|
+
version: string(),
|
|
19966
|
+
/** Where it was read from: a file's name, an address, or "pasted text". */
|
|
19967
|
+
from: string(),
|
|
19968
|
+
by: string().optional(),
|
|
19969
|
+
at: Timestamp,
|
|
19970
|
+
clauses: array(Clause),
|
|
19971
|
+
/** Areas of the standard policy the document does not cover. */
|
|
19972
|
+
gaps: array(string()),
|
|
19973
|
+
/** Clauses the model proposed whose quote was not found in the document, so they were left out. */
|
|
19974
|
+
dropped: number().default(0)
|
|
19975
|
+
});
|
|
19976
|
+
var PolicyRecord = object({
|
|
19977
|
+
id: string(),
|
|
19978
|
+
name: string(),
|
|
19979
|
+
latest: string(),
|
|
19980
|
+
createdAt: Timestamp,
|
|
19981
|
+
createdBy: string()
|
|
19982
|
+
});
|
|
19065
19983
|
var Label = object({
|
|
19066
19984
|
checkId: string(),
|
|
19067
19985
|
itemRef: string(),
|
|
@@ -19440,32 +20358,133 @@ var UnderstandingV3 = object({
|
|
|
19440
20358
|
kind: SystemKind.optional(),
|
|
19441
20359
|
summary: string()
|
|
19442
20360
|
});
|
|
19443
|
-
/**
|
|
19444
|
-
|
|
19445
|
-
|
|
19446
|
-
|
|
19447
|
-
|
|
19448
|
-
|
|
19449
|
-
|
|
19450
|
-
|
|
19451
|
-
|
|
19452
|
-
|
|
19453
|
-
|
|
19454
|
-
|
|
19455
|
-
|
|
19456
|
-
|
|
19457
|
-
|
|
19458
|
-
|
|
20361
|
+
/**
|
|
20362
|
+
* The scan's reader, a turn at a time: the model asks for files and searches, the person's machine
|
|
20363
|
+
* answers within what the person agreed to send, and the model ends with the map. The machine keeps
|
|
20364
|
+
* the steps and the tools; the server keeps no state but the budget. ADR 0086.
|
|
20365
|
+
*/
|
|
20366
|
+
var ReadingCall = object({
|
|
20367
|
+
id: string().max(40),
|
|
20368
|
+
tool: _enum([
|
|
20369
|
+
"list",
|
|
20370
|
+
"glob",
|
|
20371
|
+
"grep",
|
|
20372
|
+
"read",
|
|
20373
|
+
"symbols"
|
|
20374
|
+
]),
|
|
20375
|
+
/** list: the folder. */
|
|
20376
|
+
dir: string().max(400).optional(),
|
|
20377
|
+
/** glob: a file pattern, such as every Python file under src. */
|
|
20378
|
+
pattern: string().max(200).optional(),
|
|
20379
|
+
/** grep: the literal text to find, and the files to look in. */
|
|
20380
|
+
text: string().max(200).optional(),
|
|
20381
|
+
glob: string().max(200).optional(),
|
|
20382
|
+
/** read and symbols: the file; read takes lines from and to. */
|
|
20383
|
+
path: string().max(400).optional(),
|
|
20384
|
+
from: number().int().optional(),
|
|
20385
|
+
to: number().int().optional(),
|
|
20386
|
+
/** symbols: a name to find where it is defined and called. */
|
|
20387
|
+
name: string().max(200).optional()
|
|
20388
|
+
});
|
|
20389
|
+
var ReadingStep = discriminatedUnion("role", [
|
|
20390
|
+
object({
|
|
20391
|
+
role: literal("model"),
|
|
20392
|
+
calls: array(ReadingCall).max(6),
|
|
20393
|
+
note: string().max(2e3).optional()
|
|
20394
|
+
}),
|
|
20395
|
+
object({
|
|
20396
|
+
role: literal("tools"),
|
|
20397
|
+
results: array(object({
|
|
20398
|
+
id: string().max(40),
|
|
20399
|
+
text: string().max(2e4)
|
|
20400
|
+
})).max(6)
|
|
20401
|
+
}),
|
|
20402
|
+
object({
|
|
20403
|
+
role: literal("map"),
|
|
20404
|
+
map: UnderstandingV3
|
|
20405
|
+
}),
|
|
20406
|
+
object({
|
|
20407
|
+
role: literal("answer"),
|
|
20408
|
+
text: string().max(4e3)
|
|
20409
|
+
}),
|
|
20410
|
+
object({
|
|
20411
|
+
role: literal("checks"),
|
|
20412
|
+
problems: array(string().max(500)).max(60)
|
|
20413
|
+
})
|
|
20414
|
+
]);
|
|
20415
|
+
var ScanTurn = object({
|
|
20416
|
+
version: literal(1),
|
|
20417
|
+
scanId: uuid(),
|
|
20418
|
+
/**
|
|
20419
|
+
* map reads and answers with the map; settle looks again at what the map left unsure; answer
|
|
20420
|
+
* reads to answer the person's question in a few sentences.
|
|
20421
|
+
*/
|
|
20422
|
+
mode: _enum([
|
|
20423
|
+
"map",
|
|
20424
|
+
"settle",
|
|
20425
|
+
"answer"
|
|
20426
|
+
]),
|
|
20427
|
+
consent: _enum(["outline", "code"]),
|
|
20428
|
+
/** The same every turn, so the model's cache keeps it. */
|
|
20429
|
+
outline: ScanOutlineV2,
|
|
20430
|
+
entries: array(object({
|
|
20431
|
+
...Where,
|
|
20432
|
+
why: string().max(200)
|
|
20433
|
+
})).max(50),
|
|
20434
|
+
steps: array(ReadingStep).max(60),
|
|
20435
|
+
/** settle: what to look at again, each with why. */
|
|
20436
|
+
unsure: array(string().max(500)).max(10).optional(),
|
|
20437
|
+
/** answer: the person's question, in their words. */
|
|
20438
|
+
question: string().max(2e3).optional()
|
|
20439
|
+
});
|
|
20440
|
+
/** What the model answers each turn: calls, or the map when it has read enough. */
|
|
20441
|
+
var ReadingAnswer = object({
|
|
20442
|
+
calls: array(ReadingCall).max(6),
|
|
20443
|
+
note: string().max(2e3).optional(),
|
|
20444
|
+
map: UnderstandingV3.nullable(),
|
|
20445
|
+
/** In answer mode: the answer, citing file and line, when it has read enough. */
|
|
20446
|
+
answer: string().max(4e3).nullable().optional()
|
|
20447
|
+
});
|
|
20448
|
+
var ScanTurnReply = object({
|
|
20449
|
+
calls: array(ReadingCall),
|
|
20450
|
+
note: string().optional(),
|
|
20451
|
+
map: UnderstandingV3.nullable(),
|
|
20452
|
+
answer: string().optional(),
|
|
20453
|
+
/** What the text check found wrong in the answer, for one try to correct it. */
|
|
20454
|
+
problems: array(string()).optional(),
|
|
20455
|
+
/** Why reading ended without the model choosing to: no turns or no budget left. */
|
|
20456
|
+
stopped: _enum([
|
|
20457
|
+
"turns",
|
|
20458
|
+
"budget",
|
|
20459
|
+
"unreadable"
|
|
20460
|
+
]).optional()
|
|
20461
|
+
});
|
|
20462
|
+
/** What the scan's last step sends to connect a system. */
|
|
20463
|
+
var RegisterSystem = object({
|
|
20464
|
+
name: string(),
|
|
20465
|
+
kind: SystemKind,
|
|
20466
|
+
team: string().default("Not set"),
|
|
20467
|
+
waysIn: array(WayIn),
|
|
20468
|
+
rules: array(MapRule),
|
|
20469
|
+
tools: array(ToolSetup),
|
|
20470
|
+
knot: string(),
|
|
20471
|
+
reaches: string(),
|
|
20472
|
+
ruleOfTwo: object({
|
|
20473
|
+
untrustedInput: string(),
|
|
20474
|
+
privateData: string(),
|
|
20475
|
+
outsideActions: string().nullable()
|
|
20476
|
+
}),
|
|
20477
|
+
parts: VersionParts,
|
|
19459
20478
|
versionLabel: string(),
|
|
19460
20479
|
summary: string().optional()
|
|
19461
20480
|
});
|
|
19462
20481
|
var StartReview = object({
|
|
19463
|
-
systemId:
|
|
20482
|
+
systemId: DocId.optional(),
|
|
19464
20483
|
system: string().optional(),
|
|
19465
20484
|
quick: boolean().default(false),
|
|
19466
20485
|
ci: boolean().default(false),
|
|
19467
20486
|
/** The most answered attempts per path. Absent means the default for quick or full. */
|
|
19468
|
-
budget: number().int().positive().optional(),
|
|
20487
|
+
budget: number().int().positive().max(1e4).optional(),
|
|
19469
20488
|
why: string().optional(),
|
|
19470
20489
|
startedBy: _enum([
|
|
19471
20490
|
"person",
|
|
@@ -19479,7 +20498,9 @@ var StartReview = object({
|
|
|
19479
20498
|
changedTools: array(string()).default([]),
|
|
19480
20499
|
/** Non-empty narrows the checks, otherwise every check that applies runs. */
|
|
19481
20500
|
checks: array(string()).default([]),
|
|
19482
|
-
paths: array(string()).default([])
|
|
20501
|
+
paths: array(string()).default([]),
|
|
20502
|
+
/** The CLI asking serves the attempts itself, so no other runner has to be answering. */
|
|
20503
|
+
runnerHere: boolean().default(false)
|
|
19483
20504
|
});
|
|
19484
20505
|
var ApiError = object({
|
|
19485
20506
|
error: string(),
|
|
@@ -19570,7 +20591,12 @@ var ModelosConfig = object({
|
|
|
19570
20591
|
system: string(),
|
|
19571
20592
|
kind: SystemKind.optional(),
|
|
19572
20593
|
workspace: string().optional(),
|
|
19573
|
-
|
|
20594
|
+
/** What the scan may send: code it asks for, only the outline, or nothing. ADR 0086. */
|
|
20595
|
+
consent: _enum([
|
|
20596
|
+
"code",
|
|
20597
|
+
"outline",
|
|
20598
|
+
"none"
|
|
20599
|
+
]).optional(),
|
|
19574
20600
|
start: object({
|
|
19575
20601
|
command: string().optional(),
|
|
19576
20602
|
settings: string().optional(),
|
|
@@ -19659,6 +20685,65 @@ function renderConfig(config) {
|
|
|
19659
20685
|
async function writeConfig(dir, config) {
|
|
19660
20686
|
await writeFile(join(dir, CONFIG_FILE), renderConfig(config));
|
|
19661
20687
|
}
|
|
20688
|
+
// src/commands-here.ts
|
|
20689
|
+
/** Package scripts that build, test or install. None of them starts an app. */
|
|
20690
|
+
var NOT_A_START = /^(build|test|lint|format|typecheck|check|install|prepare|clean|ci)(:|$)/;
|
|
20691
|
+
/** Whether `name` is a command this machine can run, as its shell would find it. */
|
|
20692
|
+
async function onPath(name, env = process.env) {
|
|
20693
|
+
const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
|
|
20694
|
+
const windows = process.platform === "win32";
|
|
20695
|
+
const endings = windows ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")] : [""];
|
|
20696
|
+
for (const dir of (env[key] ?? "").split(delimiter)) {
|
|
20697
|
+
if (!dir) continue;
|
|
20698
|
+
for (const ending of endings) {
|
|
20699
|
+
const path = join(dir, name + ending.toLowerCase());
|
|
20700
|
+
if (!await stat(path).then((found) => found.isFile(), () => false)) continue;
|
|
20701
|
+
if (windows) return true;
|
|
20702
|
+
if (await access(path, constants.X_OK).then(() => true, () => false)) return true;
|
|
20703
|
+
}
|
|
20704
|
+
}
|
|
20705
|
+
return false;
|
|
20706
|
+
}
|
|
20707
|
+
var exists = (path) => access(path).then(() => true, () => false);
|
|
20708
|
+
async function scriptsIn(dir) {
|
|
20709
|
+
const text = await readFile(join(dir, "package.json"), "utf8").catch(() => "{}");
|
|
20710
|
+
try {
|
|
20711
|
+
return JSON.parse(text).scripts ?? {};
|
|
20712
|
+
} catch {
|
|
20713
|
+
return {};
|
|
20714
|
+
}
|
|
20715
|
+
}
|
|
20716
|
+
/**
|
|
20717
|
+
* Why `command` cannot start the app in `dir`, in one sentence, or null when it can as far as can
|
|
20718
|
+
* be told without running it.
|
|
20719
|
+
*/
|
|
20720
|
+
async function whyNotRunnable(dir, command) {
|
|
20721
|
+
const words = command.trim().split(/\s+/);
|
|
20722
|
+
const first = words[0];
|
|
20723
|
+
if (!first) return "it is empty";
|
|
20724
|
+
if (/[?!]$/.test(command.trim()) || /, /.test(command) || words.length > 12) return "it reads as a sentence, not a command";
|
|
20725
|
+
if ([
|
|
20726
|
+
"npm",
|
|
20727
|
+
"pnpm",
|
|
20728
|
+
"yarn",
|
|
20729
|
+
"bun"
|
|
20730
|
+
].includes(first)) {
|
|
20731
|
+
const named = words[1] === "run" || words[1] === "run-script" ? words[2] : words[1];
|
|
20732
|
+
if (named && ![
|
|
20733
|
+
"start",
|
|
20734
|
+
"exec",
|
|
20735
|
+
"dlx",
|
|
20736
|
+
"x"
|
|
20737
|
+
].includes(named)) {
|
|
20738
|
+
if (NOT_A_START.test(named)) return `${words.slice(0, words.indexOf(named) + 1).join(" ")} does not start the app`;
|
|
20739
|
+
if (!(named in await scriptsIn(dir))) return `package.json has no script named ${named}`;
|
|
20740
|
+
} else if (named === "start" && !("start" in await scriptsIn(dir))) return "package.json has no start script";
|
|
20741
|
+
}
|
|
20742
|
+
const file = [first, ...words.slice(1)].find((w) => /\.(m?js|cjs|ts|py|sh)$/.test(w));
|
|
20743
|
+
if (file && !await exists(join(dir, file))) return `${file} is not in this repository`;
|
|
20744
|
+
if (!(await exists(join(dir, first)) || await exists(join(dir, "node_modules", ".bin", first))) && !await onPath(first)) return words.length >= 3 && !/[-./:=]/.test(command) ? "it reads as a sentence, not a command" : `${first} is not installed on this machine`;
|
|
20745
|
+
return null;
|
|
20746
|
+
}
|
|
19662
20747
|
// ../../hooks/node/src/hosts.mjs
|
|
19663
20748
|
var MODEL_HOSTS = [
|
|
19664
20749
|
/(^|\.)api\.openai\.com$/,
|
|
@@ -19792,6 +20877,34 @@ function whyAppStopped(output) {
|
|
|
19792
20877
|
const lines = output.split("\n").map((l) => l.trim()).filter((l) => l && !/^at /.test(l) && !/^Node\.js v/.test(l));
|
|
19793
20878
|
return (lines.findLast((l) => /\b\w*(Error|Exception)\b/.test(l)) ?? lines.at(-1))?.slice(0, 200);
|
|
19794
20879
|
}
|
|
20880
|
+
/**
|
|
20881
|
+
* PATH for the app: this Node first, then the repository's own tools, as `npm run` would have
|
|
20882
|
+
* them. Windows spells the key `Path` and separates with `;`.
|
|
20883
|
+
*/
|
|
20884
|
+
function appPath(dir, env = process.env) {
|
|
20885
|
+
const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
|
|
20886
|
+
return { [key]: [
|
|
20887
|
+
dirname(process.execPath),
|
|
20888
|
+
resolve(dir, "node_modules", ".bin"),
|
|
20889
|
+
env[key]
|
|
20890
|
+
].filter(Boolean).join(delimiter) };
|
|
20891
|
+
}
|
|
20892
|
+
/** Stops an app and everything it started. */
|
|
20893
|
+
function stopTree(child) {
|
|
20894
|
+
if (!child.pid) return;
|
|
20895
|
+
try {
|
|
20896
|
+
if (process.platform === "win32") spawn("taskkill", [
|
|
20897
|
+
"/pid",
|
|
20898
|
+
child.pid.toString(),
|
|
20899
|
+
"/t",
|
|
20900
|
+
"/f"
|
|
20901
|
+
], {
|
|
20902
|
+
stdio: "ignore",
|
|
20903
|
+
windowsHide: true
|
|
20904
|
+
});
|
|
20905
|
+
else process.kill(-child.pid, "SIGTERM");
|
|
20906
|
+
} catch {}
|
|
20907
|
+
}
|
|
19795
20908
|
/** Starts the app with hooks attached, several times over so attempts can run side by side. */
|
|
19796
20909
|
async function startAppPool(input) {
|
|
19797
20910
|
const { config, dir } = input;
|
|
@@ -19831,17 +20944,18 @@ async function startAppPool(input) {
|
|
|
19831
20944
|
cwd: dir,
|
|
19832
20945
|
stdio: [
|
|
19833
20946
|
"ignore",
|
|
19834
|
-
"
|
|
20947
|
+
"pipe",
|
|
19835
20948
|
"pipe"
|
|
19836
20949
|
],
|
|
19837
|
-
detached:
|
|
20950
|
+
detached: process.platform !== "win32",
|
|
20951
|
+
windowsHide: true,
|
|
19838
20952
|
env: {
|
|
19839
20953
|
...process.env,
|
|
19840
20954
|
...input.extraEnv,
|
|
19841
|
-
|
|
20955
|
+
...appPath(dir),
|
|
19842
20956
|
PORT: instance.port.toString(),
|
|
19843
|
-
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${hookNode()}`.trim(),
|
|
19844
|
-
PYTHONPATH:
|
|
20957
|
+
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${pathToFileURL(hookNode()).href}`.trim(),
|
|
20958
|
+
PYTHONPATH: [hookPython(), process.env.PYTHONPATH].filter(Boolean).join(delimiter),
|
|
19845
20959
|
MODELOS_SECURE_HOOK: hookJson,
|
|
19846
20960
|
MODELOS_SECURE_RUNNER_URL: runnerUrl,
|
|
19847
20961
|
MODELOS_SECURE_INSTANCE: instance.id,
|
|
@@ -19854,10 +20968,12 @@ async function startAppPool(input) {
|
|
|
19854
20968
|
}
|
|
19855
20969
|
});
|
|
19856
20970
|
instance.child = child;
|
|
19857
|
-
|
|
19858
|
-
instance.output = (instance.output + chunk.toString()).slice(-
|
|
20971
|
+
const keep = (chunk) => {
|
|
20972
|
+
instance.output = (instance.output + chunk.toString()).slice(-16e3);
|
|
19859
20973
|
if (!input.quiet) process.stderr.write(chunk);
|
|
19860
|
-
}
|
|
20974
|
+
};
|
|
20975
|
+
child.stdout?.on("data", keep);
|
|
20976
|
+
child.stderr?.on("data", keep);
|
|
19861
20977
|
let exited = false;
|
|
19862
20978
|
child.once("exit", () => {
|
|
19863
20979
|
exited = true;
|
|
@@ -19900,9 +21016,7 @@ async function startAppPool(input) {
|
|
|
19900
21016
|
instances.push(...launched.map((l) => l.instance));
|
|
19901
21017
|
const stop = () => {
|
|
19902
21018
|
stopping = true;
|
|
19903
|
-
for (const instance of instances)
|
|
19904
|
-
if (instance.child.pid) process.kill(-instance.child.pid, "SIGTERM");
|
|
19905
|
-
} catch {}
|
|
21019
|
+
for (const instance of instances) stopTree(instance.child);
|
|
19906
21020
|
server.close();
|
|
19907
21021
|
proxy.stop();
|
|
19908
21022
|
};
|
|
@@ -19947,7 +21061,7 @@ async function startAppPool(input) {
|
|
|
19947
21061
|
signal: AbortSignal.timeout(17e4)
|
|
19948
21062
|
}).finally(() => proxy.end());
|
|
19949
21063
|
const reply = response.ok ? await replyText(response, entry.reply) : "";
|
|
19950
|
-
const ended = await (await fetch(`${control}/end`, { method: "POST" })).json();
|
|
21064
|
+
const { modelFailures = [], ...ended } = await (await fetch(`${control}/end`, { method: "POST" })).json();
|
|
19951
21065
|
const seen = {
|
|
19952
21066
|
...ended,
|
|
19953
21067
|
edgeHeld: [...ended.edgeHeld ?? [], ...proxy.stoppedSince(started).map(({ method, host, path, bytes }) => ({
|
|
@@ -19964,6 +21078,15 @@ async function startAppPool(input) {
|
|
|
19964
21078
|
reply: "",
|
|
19965
21079
|
latencyMs: Date.now() - started
|
|
19966
21080
|
};
|
|
21081
|
+
const modelFailed = modelFailures[0];
|
|
21082
|
+
const empty = !reply.trim() && seen.heldCalls.length === 0 && seen.modelCalls.length === 0 && seen.ranTools.length === 0;
|
|
21083
|
+
if (modelFailed || empty) return {
|
|
21084
|
+
...seen,
|
|
21085
|
+
answered: false,
|
|
21086
|
+
error: modelFailed ? `The app's own model call failed (${modelFailed.why}), so its reply is not counted.` : "The app answered with nothing where its reply goes.",
|
|
21087
|
+
reply: "",
|
|
21088
|
+
latencyMs: Date.now() - started
|
|
21089
|
+
};
|
|
19967
21090
|
return {
|
|
19968
21091
|
...seen,
|
|
19969
21092
|
answered: true,
|
|
@@ -19982,7 +21105,8 @@ async function startAppPool(input) {
|
|
|
19982
21105
|
instances,
|
|
19983
21106
|
send,
|
|
19984
21107
|
stop,
|
|
19985
|
-
lastStop: () => lastStop
|
|
21108
|
+
lastStop: () => lastStop,
|
|
21109
|
+
output: () => instances[0]?.output ?? ""
|
|
19986
21110
|
};
|
|
19987
21111
|
}
|
|
19988
21112
|
async function takeInstance(instances) {
|
|
@@ -20054,35 +21178,6 @@ var freePort = () => new Promise((resolve) => {
|
|
|
20054
21178
|
server.close(() => resolve(port));
|
|
20055
21179
|
});
|
|
20056
21180
|
});
|
|
20057
|
-
/** Runs the start command briefly to read why it stopped. Nothing is kept from it. */
|
|
20058
|
-
async function whyItStopped(ctx, command) {
|
|
20059
|
-
return new Promise((resolve) => {
|
|
20060
|
-
let output = "";
|
|
20061
|
-
const child = spawn(command, {
|
|
20062
|
-
shell: true,
|
|
20063
|
-
cwd: ctx.dir,
|
|
20064
|
-
env: {
|
|
20065
|
-
...process.env,
|
|
20066
|
-
...ctx.extraEnv
|
|
20067
|
-
},
|
|
20068
|
-
stdio: [
|
|
20069
|
-
"ignore",
|
|
20070
|
-
"pipe",
|
|
20071
|
-
"pipe"
|
|
20072
|
-
]
|
|
20073
|
-
});
|
|
20074
|
-
child.stdout.on("data", (c) => output += c.toString());
|
|
20075
|
-
child.stderr.on("data", (c) => output += c.toString());
|
|
20076
|
-
const timer = setTimeout(() => {
|
|
20077
|
-
child.kill("SIGTERM");
|
|
20078
|
-
resolve(null);
|
|
20079
|
-
}, 8e3);
|
|
20080
|
-
child.once("exit", () => {
|
|
20081
|
-
clearTimeout(timer);
|
|
20082
|
-
resolve(output);
|
|
20083
|
-
});
|
|
20084
|
-
});
|
|
20085
|
-
}
|
|
20086
21181
|
function missingSetting(output) {
|
|
20087
21182
|
return output.match(/([A-Z][A-Z0-9_]{2,}) (?:is not set|environment variable|must be set|is required|not found)/)?.[1] ?? output.match(/KeyError: ['"]([A-Z][A-Z0-9_]+)['"]/)?.[1] ?? output.match(/process\.env\.([A-Z][A-Z0-9_]+)/)?.[1];
|
|
20088
21183
|
}
|
|
@@ -20121,7 +21216,7 @@ function settingsFixes(ctx, name, keyInShell) {
|
|
|
20121
21216
|
* error's last lines go with it, secrets taken out. Without that consent the rules alone decide.
|
|
20122
21217
|
*/
|
|
20123
21218
|
function explainWith(api, config) {
|
|
20124
|
-
if (!api || config.consent !== "outline") return void 0;
|
|
21219
|
+
if (!api || config.consent !== "outline" && config.consent !== "code") return void 0;
|
|
20125
21220
|
return (facts) => api.post("/scan/failure", facts).then((r) => r.explanation);
|
|
20126
21221
|
}
|
|
20127
21222
|
/** What a fix may name, from the repository itself when the caller did not say. */
|
|
@@ -20152,22 +21247,16 @@ var RUNNERS = [
|
|
|
20152
21247
|
"bun",
|
|
20153
21248
|
"make"
|
|
20154
21249
|
];
|
|
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
21250
|
/** The last lines the app printed, with secrets taken out, for the model to read. */
|
|
20163
21251
|
async function failureFacts(known, ctx, command, output) {
|
|
20164
21252
|
const firstWords = [...RUNNERS, ...[command, ...ctx.startCommands.map((c) => c.command)].map((c) => c.trim().split(/\s+/)[0] ?? "")];
|
|
20165
21253
|
const available = [];
|
|
20166
21254
|
for (const name of new Set(firstWords.filter(Boolean))) if (await onPath(name)) available.push(name);
|
|
21255
|
+
const values = await localValues(ctx.dir, known.settingsFiles);
|
|
20167
21256
|
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),
|
|
21257
|
+
command: redact(command, values),
|
|
21258
|
+
output: redact(output, values).split("\n").filter((l) => l.trim()).slice(-60).map((l) => l.slice(0, 300)),
|
|
21259
|
+
scripts: ctx.startCommands.map((c) => redact(c.command, values)),
|
|
20171
21260
|
settings: known.settings,
|
|
20172
21261
|
settingsFiles: known.settingsFiles,
|
|
20173
21262
|
files: known.files,
|
|
@@ -20199,12 +21288,11 @@ function toFix(ctx, fix) {
|
|
|
20199
21288
|
};
|
|
20200
21289
|
case "change-command": return {
|
|
20201
21290
|
label: fix.label,
|
|
20202
|
-
apply:
|
|
21291
|
+
apply: () => {
|
|
20203
21292
|
config.start = {
|
|
20204
21293
|
...config.start,
|
|
20205
21294
|
command: fix.command
|
|
20206
21295
|
};
|
|
20207
|
-
await save();
|
|
20208
21296
|
}
|
|
20209
21297
|
};
|
|
20210
21298
|
case "change-port": return {
|
|
@@ -20214,7 +21302,6 @@ function toFix(ctx, fix) {
|
|
|
20214
21302
|
...config.start,
|
|
20215
21303
|
command: commandForPort(config.start?.command ?? "", await freePort())
|
|
20216
21304
|
};
|
|
20217
|
-
await save();
|
|
20218
21305
|
}
|
|
20219
21306
|
};
|
|
20220
21307
|
case "run-command": return {
|
|
@@ -20254,38 +21341,70 @@ function toFix(ctx, fix) {
|
|
|
20254
21341
|
};
|
|
20255
21342
|
}
|
|
20256
21343
|
}
|
|
21344
|
+
/** What someone types when they do not know the command. */
|
|
21345
|
+
var DONT_KNOW = /^(i\s+)?(don'?t|do not)\s+know|^not\s+sure|^no\s+idea|^\?$/i;
|
|
20257
21346
|
/**
|
|
20258
|
-
* Asks for the start command
|
|
20259
|
-
*
|
|
21347
|
+
* Asks for the start command, and checks each answer before keeping it. An empty answer, "I don't
|
|
21348
|
+
* know", or none at all when nobody is there to type, changes nothing. Nothing is saved until the
|
|
21349
|
+
* app has started with it.
|
|
20260
21350
|
*/
|
|
20261
21351
|
async function typeTheCommand(ctx) {
|
|
20262
|
-
|
|
20263
|
-
|
|
20264
|
-
|
|
20265
|
-
|
|
20266
|
-
|
|
20267
|
-
|
|
20268
|
-
|
|
21352
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
21353
|
+
const typed = (await askText("How do you start the app?")).trim();
|
|
21354
|
+
if (!typed || DONT_KNOW.test(typed)) return;
|
|
21355
|
+
const why = await whyNotRunnable(ctx.dir, typed);
|
|
21356
|
+
if (!why) {
|
|
21357
|
+
ctx.config.start = {
|
|
21358
|
+
...ctx.config.start,
|
|
21359
|
+
command: typed
|
|
21360
|
+
};
|
|
21361
|
+
return;
|
|
21362
|
+
}
|
|
21363
|
+
say(` That will not start it: ${why}.`);
|
|
21364
|
+
}
|
|
20269
21365
|
}
|
|
20270
|
-
/**
|
|
20271
|
-
async function
|
|
20272
|
-
const
|
|
20273
|
-
const
|
|
20274
|
-
|
|
20275
|
-
|
|
21366
|
+
/** The commands found in the repository that can run here, as fixes to choose from. */
|
|
21367
|
+
async function candidateFixes(ctx) {
|
|
21368
|
+
const fixes = [];
|
|
21369
|
+
for (const c of ctx.startCommands) {
|
|
21370
|
+
if (c.command === ctx.config.start?.command) continue;
|
|
21371
|
+
if (await whyNotRunnable(ctx.dir, c.command)) continue;
|
|
21372
|
+
fixes.push({
|
|
20276
21373
|
label: `${c.command} (from ${c.from})`,
|
|
20277
|
-
apply: () => void (config.start = {
|
|
20278
|
-
...config.start,
|
|
21374
|
+
apply: () => void (ctx.config.start = {
|
|
21375
|
+
...ctx.config.start,
|
|
20279
21376
|
command: c.command
|
|
20280
21377
|
})
|
|
20281
|
-
}));
|
|
20282
|
-
fixes.push({
|
|
20283
|
-
label: "Type the command",
|
|
20284
|
-
apply: () => typeTheCommand(ctx)
|
|
20285
21378
|
});
|
|
20286
|
-
throw new Failure("I can't tell how to start the app.", fixes);
|
|
20287
21379
|
}
|
|
20288
|
-
|
|
21380
|
+
return fixes;
|
|
21381
|
+
}
|
|
21382
|
+
/** The way out when nobody knows the command yet: say so plainly, and stop with what is kept. */
|
|
21383
|
+
var notKnown = {
|
|
21384
|
+
label: "I don't know",
|
|
21385
|
+
apply: () => {
|
|
21386
|
+
throw new Failure("Then the app cannot be started yet. Your other answers are kept. When you know the command, put it under start: in modelos-secure.yaml, or run the same command again and type it.", []);
|
|
21387
|
+
}
|
|
21388
|
+
};
|
|
21389
|
+
/** Starts the pool, or fails with what happened and how to fix it. */
|
|
21390
|
+
async function startApp(ctx, instances) {
|
|
21391
|
+
const { config } = ctx;
|
|
21392
|
+
const command = config.start?.command;
|
|
21393
|
+
const typeIt = {
|
|
21394
|
+
label: "Type the command",
|
|
21395
|
+
apply: () => typeTheCommand(ctx)
|
|
21396
|
+
};
|
|
21397
|
+
if (!command) throw new Failure("I can't tell how to start the app.", [
|
|
21398
|
+
...await candidateFixes(ctx),
|
|
21399
|
+
typeIt,
|
|
21400
|
+
notKnown
|
|
21401
|
+
]);
|
|
21402
|
+
const wrong = await whyNotRunnable(ctx.dir, command);
|
|
21403
|
+
if (wrong) throw new Failure(`The start command ${command} cannot run here: ${wrong}.`, [
|
|
21404
|
+
...await candidateFixes(ctx),
|
|
21405
|
+
typeIt,
|
|
21406
|
+
notKnown
|
|
21407
|
+
]);
|
|
20289
21408
|
const port = portOf(command);
|
|
20290
21409
|
if (port && !await portFree(port)) throw new Failure(`Port ${port.toString()} is in use.`, [{
|
|
20291
21410
|
label: "Use the next free port",
|
|
@@ -20295,11 +21414,12 @@ async function startApp(ctx, instances) {
|
|
|
20295
21414
|
...config.start,
|
|
20296
21415
|
command: commandForPort(command, await freePort())
|
|
20297
21416
|
};
|
|
20298
|
-
await ctx.onConfig(config);
|
|
20299
21417
|
}
|
|
20300
21418
|
}, {
|
|
20301
21419
|
label: "Stop and let me close it",
|
|
20302
|
-
apply: () =>
|
|
21420
|
+
apply: () => {
|
|
21421
|
+
throw new Interrupted();
|
|
21422
|
+
}
|
|
20303
21423
|
}]);
|
|
20304
21424
|
if (!config.start?.entry) throw new Failure("I can't tell how a message reaches the app.", [{
|
|
20305
21425
|
label: "Type the route that takes a message",
|
|
@@ -20317,6 +21437,25 @@ async function startApp(ctx, instances) {
|
|
|
20317
21437
|
await ctx.onConfig(config);
|
|
20318
21438
|
}
|
|
20319
21439
|
}]);
|
|
21440
|
+
const file = config.start?.settings;
|
|
21441
|
+
if (file && !await readFile(join(ctx.dir, file)).catch(() => null)) throw new Failure(`The settings file ${file} is not in this repository.`, [{
|
|
21442
|
+
label: "Point to another settings file",
|
|
21443
|
+
apply: async () => {
|
|
21444
|
+
const other = await askText("Which settings file? It stays on this machine.");
|
|
21445
|
+
config.start = {
|
|
21446
|
+
...config.start,
|
|
21447
|
+
settings: other || void 0
|
|
21448
|
+
};
|
|
21449
|
+
}
|
|
21450
|
+
}, {
|
|
21451
|
+
label: "Start without a settings file",
|
|
21452
|
+
apply: () => {
|
|
21453
|
+
config.start = {
|
|
21454
|
+
...config.start,
|
|
21455
|
+
settings: void 0
|
|
21456
|
+
};
|
|
21457
|
+
}
|
|
21458
|
+
}]);
|
|
20320
21459
|
const settings = await loadSettings(ctx.dir, config.start?.settings);
|
|
20321
21460
|
Object.assign(ctx.extraEnv, settings, ctx.extraEnv);
|
|
20322
21461
|
const basePort = await freePort();
|
|
@@ -20331,20 +21470,19 @@ async function startApp(ctx, instances) {
|
|
|
20331
21470
|
const up = pool.instances.filter((i) => i.ready);
|
|
20332
21471
|
if (up.length === 0) {
|
|
20333
21472
|
pool.stop();
|
|
20334
|
-
const output =
|
|
21473
|
+
const output = pool.output();
|
|
21474
|
+
showOutput(output);
|
|
20335
21475
|
const explained = output && ctx.explain ? await ctx.explain(await failureFacts(await knownIn(ctx), ctx, command, output)).catch(() => null) : null;
|
|
20336
|
-
|
|
20337
|
-
|
|
20338
|
-
|
|
20339
|
-
|
|
21476
|
+
const others = [
|
|
21477
|
+
...await candidateFixes(ctx),
|
|
21478
|
+
typeIt,
|
|
21479
|
+
notKnown
|
|
21480
|
+
];
|
|
21481
|
+
if (explained) throw new Failure(explained.what, [...await runnableFixes(ctx, explained.fixes), ...others], explained.cause);
|
|
20340
21482
|
const missing = output ? missingSetting(output) : void 0;
|
|
20341
21483
|
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])));
|
|
20342
21484
|
if (missing) throw new Failure(`The app stopped: ${missing} is not set.`, settingsFixes(ctx, missing, false));
|
|
20343
|
-
|
|
20344
|
-
throw new Failure(`The app stopped while starting${lastLine ? `: ${lastLine.slice(0, 160)}` : "."}`, [{
|
|
20345
|
-
label: "Type the command",
|
|
20346
|
-
apply: () => typeTheCommand(ctx)
|
|
20347
|
-
}]);
|
|
21485
|
+
throw new Failure(output.trim() ? "The app stopped while starting, with the output above." : "The app stopped while starting, and printed nothing.", others);
|
|
20348
21486
|
}
|
|
20349
21487
|
if (up.every((i) => !i.controlPort)) {
|
|
20350
21488
|
pool.stop();
|
|
@@ -20356,12 +21494,45 @@ async function startApp(ctx, instances) {
|
|
|
20356
21494
|
...config.start,
|
|
20357
21495
|
command: await askText("How do you start the unbundled source?")
|
|
20358
21496
|
};
|
|
20359
|
-
await ctx.onConfig(config);
|
|
20360
21497
|
}
|
|
20361
21498
|
}]);
|
|
20362
21499
|
}
|
|
21500
|
+
await ctx.onConfig(config);
|
|
20363
21501
|
return pool;
|
|
20364
21502
|
}
|
|
21503
|
+
/** The last lines the app printed, as it printed them, for this machine's screen only. */
|
|
21504
|
+
function showOutput(output) {
|
|
21505
|
+
const lines = output.trimEnd().split(/\r?\n/).slice(-20);
|
|
21506
|
+
if (!lines.join("").trim()) return;
|
|
21507
|
+
say();
|
|
21508
|
+
say(" The app printed:");
|
|
21509
|
+
for (const line of lines) say(` ${line}`);
|
|
21510
|
+
}
|
|
21511
|
+
/** The model's fixes that can run here; a command it proposes is checked like a typed one. */
|
|
21512
|
+
async function runnableFixes(ctx, fixes) {
|
|
21513
|
+
const kept = [];
|
|
21514
|
+
for (const fix of fixes) if (fix.kind !== "change-command" || !await whyNotRunnable(ctx.dir, fix.command ?? "")) kept.push(toFix(ctx, fix));
|
|
21515
|
+
return kept;
|
|
21516
|
+
}
|
|
21517
|
+
/**
|
|
21518
|
+
* One harmless request through the running app before a review: it must answer, and no tool
|
|
21519
|
+
* marked as held may run. What the hooks saw is said, never more. docs/spec/05-scan-and-cli.md#6-check
|
|
21520
|
+
*/
|
|
21521
|
+
async function checkConnection(pool, config) {
|
|
21522
|
+
const check = await pool.send({
|
|
21523
|
+
session: "connection-check",
|
|
21524
|
+
message: "Connection check from ModelOS Secure",
|
|
21525
|
+
canaries: {
|
|
21526
|
+
record: "CONNECTION-CHECK",
|
|
21527
|
+
prompt: "CONNECTION-CHECK"
|
|
21528
|
+
}
|
|
21529
|
+
});
|
|
21530
|
+
const held = config.tools.filter((t) => t.during_reviews === "hold").map((t) => t.name);
|
|
21531
|
+
const escaped = check.ranTools.filter((name) => held.includes(name));
|
|
21532
|
+
if (escaped.length > 0) throw new Failure(`A tool marked as held ran for real: ${escaped.join(", ")}. No review starts until it is held.`, []);
|
|
21533
|
+
if (!check.answered) throw new Failure(`The app did not answer the connection check: ${pool.lastStop() ?? check.error ?? "no reply"}.`, []);
|
|
21534
|
+
step("Check", check.modelCalls.length === 0 ? "The app answered; the hooks saw no model call, so the review will say so" : `The app answered, and the hooks saw ${check.modelCalls.length === 1 ? "its model call" : "its model calls"}`);
|
|
21535
|
+
}
|
|
20365
21536
|
// src/commands/others.ts
|
|
20366
21537
|
async function connected() {
|
|
20367
21538
|
const connection = await readConnection();
|
|
@@ -20495,7 +21666,8 @@ async function exportCommand(reviewId, format, out) {
|
|
|
20495
21666
|
say(`Wrote ${file}.`);
|
|
20496
21667
|
}
|
|
20497
21668
|
// src/register.ts
|
|
20498
|
-
|
|
21669
|
+
/** A system's ID from its name, as the service stores it. */
|
|
21670
|
+
var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
20499
21671
|
var hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
20500
21672
|
function ruleTool(check) {
|
|
20501
21673
|
return check.split(".")[0] ?? "reply";
|
|
@@ -20526,7 +21698,7 @@ function toRegister(config, parts, extra) {
|
|
|
20526
21698
|
versionLabel: extra.versionLabel,
|
|
20527
21699
|
parts,
|
|
20528
21700
|
waysIn: config.ways_in.map((way) => ({
|
|
20529
|
-
id: slug
|
|
21701
|
+
id: slug(way.name),
|
|
20530
21702
|
name: way.name,
|
|
20531
21703
|
who: way.untrusted ? "Anyone who can reach it" : "People inside the company",
|
|
20532
21704
|
from: way.from,
|
|
@@ -20561,14 +21733,13 @@ function toRegister(config, parts, extra) {
|
|
|
20561
21733
|
};
|
|
20562
21734
|
}
|
|
20563
21735
|
// src/commands/review.ts
|
|
20564
|
-
var slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
20565
21736
|
async function reviewCommand(dir, options) {
|
|
20566
21737
|
const config = await readConfig(dir);
|
|
20567
21738
|
if (!config) throw new Error("There is no modelos-secure.yaml here. Run modelos-secure scan . first.");
|
|
20568
21739
|
if (options.ci && (config.skipped?.length ?? 0) > 0) {
|
|
20569
21740
|
say("These questions need answering locally, because CI never guesses:");
|
|
20570
21741
|
for (const name of config.skipped ?? []) say(` Should reviews hold ${name}?`);
|
|
20571
|
-
process.exitCode =
|
|
21742
|
+
process.exitCode = 3;
|
|
20572
21743
|
return;
|
|
20573
21744
|
}
|
|
20574
21745
|
const unheld = config.tools.filter((t) => t.held_by === "none" && (t.during_reviews === "hold" || t.during_reviews === "run" && !t.run_unheld));
|
|
@@ -20602,12 +21773,24 @@ async function reviewCommand(dir, options) {
|
|
|
20602
21773
|
yes: options.yes,
|
|
20603
21774
|
ci: options.ci
|
|
20604
21775
|
});
|
|
21776
|
+
step("Start", `${config.start?.command ?? ""} with hooks attached`);
|
|
21777
|
+
await withFixes(() => checkConnection(pool, config), {
|
|
21778
|
+
yes: options.yes,
|
|
21779
|
+
ci: options.ci
|
|
21780
|
+
});
|
|
21781
|
+
const serving = runRunner({
|
|
21782
|
+
api,
|
|
21783
|
+
pool,
|
|
21784
|
+
systemId: slug(config.system),
|
|
21785
|
+
signal: controller.signal
|
|
21786
|
+
});
|
|
20605
21787
|
const review = await api.post("/reviews", {
|
|
20606
21788
|
systemId: slug(config.system),
|
|
20607
21789
|
quick: options.quick,
|
|
20608
21790
|
budget: options.budget,
|
|
20609
21791
|
ci: options.ci,
|
|
20610
21792
|
startedBy: options.ci ? "ci" : "cli",
|
|
21793
|
+
runnerHere: true,
|
|
20611
21794
|
parts,
|
|
20612
21795
|
versionLabel,
|
|
20613
21796
|
why: options.ci ? `Fix check on ${versionLabel}` : options.quick ? "Quick first look" : "Full review"
|
|
@@ -20616,18 +21799,14 @@ async function reviewCommand(dir, options) {
|
|
|
20616
21799
|
say(`\n${bold(review.id)} ${review.why}, ${review.plan.paths.length.toString()} paths\n`);
|
|
20617
21800
|
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`);
|
|
20618
21801
|
let interrupted = false;
|
|
21802
|
+
let paused = false;
|
|
20619
21803
|
const forget = beforeStopping(async () => {
|
|
20620
21804
|
interrupted = true;
|
|
20621
21805
|
controller.abort();
|
|
20622
21806
|
pool?.stop();
|
|
20623
|
-
await api.post(`/reviews/${review.id}/stop`, {}).catch(() => void 0);
|
|
20624
|
-
});
|
|
20625
|
-
const serving = runRunner({
|
|
20626
|
-
api,
|
|
20627
|
-
pool,
|
|
20628
|
-
systemId: slug(config.system),
|
|
20629
|
-
signal: controller.signal
|
|
21807
|
+
if (!paused) await api.post(`/reviews/${review.id}/stop`, {}).catch(() => void 0);
|
|
20630
21808
|
});
|
|
21809
|
+
const waitsWhilePaused = !options.ci && interactive();
|
|
20631
21810
|
let last;
|
|
20632
21811
|
let lastPrinted = 0;
|
|
20633
21812
|
for (;;) {
|
|
@@ -20637,12 +21816,14 @@ async function reviewCommand(dir, options) {
|
|
|
20637
21816
|
lastPrinted = Date.now();
|
|
20638
21817
|
say(` ${answered.toLocaleString("en-GB")} answered, ${(last.progress?.broken ?? 0).toString()} broken, ${(last.progress?.errors ?? 0).toString()} errors not counted`);
|
|
20639
21818
|
}
|
|
21819
|
+
const nowPaused = last.review.status === "paused";
|
|
21820
|
+
if (nowPaused && !paused && waitsWhilePaused) say(` Paused: ${last.review.stoppedBecause ?? "it is pausing"}. This runner keeps serving, so Resume on the review's page carries on. Ctrl-C ends here and leaves it paused.`);
|
|
21821
|
+
paused = nowPaused;
|
|
20640
21822
|
if ([
|
|
20641
21823
|
"done",
|
|
20642
21824
|
"stopped",
|
|
20643
|
-
"failed"
|
|
20644
|
-
|
|
20645
|
-
].includes(last.review.status) || interrupted) break;
|
|
21825
|
+
"failed"
|
|
21826
|
+
].includes(last.review.status) || paused && !waitsWhilePaused || interrupted) break;
|
|
20646
21827
|
await new Promise((r) => setTimeout(r, 2e3));
|
|
20647
21828
|
}
|
|
20648
21829
|
forget();
|
|
@@ -20659,7 +21840,10 @@ async function reviewCommand(dir, options) {
|
|
|
20659
21840
|
else if (relevant.length > 0 && standIn) say(" The stand-in attacker wrote the attempts that broke it.");
|
|
20660
21841
|
say(`\n Review: ${connection.url}/reviews/${last.review.id}`);
|
|
20661
21842
|
const verdict = last.review.verdict;
|
|
20662
|
-
if (verdict)
|
|
21843
|
+
if (verdict) {
|
|
21844
|
+
say(`\n New ${verdict.newBreaks.length.toString()}, fixed so far ${verdict.fixedSoFar.length.toString()}, in both ${verdict.inBoth.length.toString()}, came back ${verdict.returning.length.toString()}.`);
|
|
21845
|
+
if (verdict.notReplayed.length > 0) say(` Not replayed, because the candidate answered with errors: ${verdict.notReplayed.join(", ")}. Errors are not counted, so these are neither fixed nor broken.`);
|
|
21846
|
+
}
|
|
20663
21847
|
const failed = last.review.status !== "done";
|
|
20664
21848
|
if (options.ci && (failed || verdict?.blocks)) {
|
|
20665
21849
|
const why = failed ? "the review did not finish" : [...verdict.newBreaks, ...verdict.returning].join(", ") + " broke on this version";
|
|
@@ -20672,12 +21856,19 @@ async function reviewCommand(dir, options) {
|
|
|
20672
21856
|
async function runnerCommand(connection, dir, systemId, instances) {
|
|
20673
21857
|
const config = await readConfig(dir);
|
|
20674
21858
|
if (!config) throw new Error("There is no modelos-secure.yaml here. Run modelos-secure scan . first.");
|
|
21859
|
+
const system = systemId ?? slug(config.system);
|
|
20675
21860
|
const pool = await startAppPool({
|
|
20676
21861
|
config,
|
|
20677
21862
|
dir,
|
|
20678
21863
|
instances,
|
|
20679
21864
|
basePort: 18e3 + Math.floor(Math.random() * 2e3)
|
|
20680
21865
|
});
|
|
21866
|
+
try {
|
|
21867
|
+
await checkConnection(pool, config);
|
|
21868
|
+
} catch (error) {
|
|
21869
|
+
pool.stop();
|
|
21870
|
+
throw error;
|
|
21871
|
+
}
|
|
20681
21872
|
const controller = new AbortController();
|
|
20682
21873
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
|
|
20683
21874
|
controller.abort();
|
|
@@ -20687,64 +21878,18 @@ async function runnerCommand(connection, dir, systemId, instances) {
|
|
|
20687
21878
|
await runRunner({
|
|
20688
21879
|
api: apiClient(connection),
|
|
20689
21880
|
pool,
|
|
20690
|
-
systemId,
|
|
21881
|
+
systemId: system,
|
|
20691
21882
|
signal: controller.signal
|
|
20692
21883
|
});
|
|
20693
21884
|
}
|
|
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
|
-
}
|
|
20736
21885
|
// src/state.ts
|
|
20737
21886
|
var STATE_DIR = ".modelos-secure";
|
|
20738
21887
|
async function readState(dir) {
|
|
20739
21888
|
const text = await readFile(join(dir, STATE_DIR, "state.json"), "utf8").catch(() => null);
|
|
20740
21889
|
return text ? {
|
|
20741
21890
|
answers: {},
|
|
20742
|
-
doneSteps: [],
|
|
20743
21891
|
...JSON.parse(text)
|
|
20744
|
-
} : {
|
|
20745
|
-
answers: {},
|
|
20746
|
-
doneSteps: []
|
|
20747
|
-
};
|
|
21892
|
+
} : { answers: {} };
|
|
20748
21893
|
}
|
|
20749
21894
|
/** Writes through a rename, so an interrupt never leaves half a file. */
|
|
20750
21895
|
async function writeState(dir, state) {
|
|
@@ -20766,10 +21911,90 @@ async function keepError(dir, error) {
|
|
|
20766
21911
|
const file = scanned ? join(STATE_DIR, "last-error.log") : join(tmpdir(), "modelos-secure-last-error.log");
|
|
20767
21912
|
return writeFile(scanned ? join(dir, file) : file, details).then(() => file, () => null);
|
|
20768
21913
|
}
|
|
21914
|
+
/**
|
|
21915
|
+
* Git ignores the folder by a file inside it, so the person's own .gitignore is never changed, and
|
|
21916
|
+
* a folder that is not a repository gets nothing it does not need.
|
|
21917
|
+
*/
|
|
20769
21918
|
async function ensureIgnored(dir) {
|
|
20770
|
-
|
|
20771
|
-
|
|
20772
|
-
|
|
21919
|
+
await writeFile(join(dir, STATE_DIR, ".gitignore"), "*\n");
|
|
21920
|
+
}
|
|
21921
|
+
/** What left this machine, one line per answer the reader sent, for the person to read. ADR 0086. */
|
|
21922
|
+
async function recordSent(dir, sent) {
|
|
21923
|
+
await mkdir(join(dir, STATE_DIR), { recursive: true });
|
|
21924
|
+
await appendFile(join(dir, STATE_DIR, "sent.jsonl"), `${JSON.stringify({
|
|
21925
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
21926
|
+
...sent
|
|
21927
|
+
})}\n`);
|
|
21928
|
+
}
|
|
21929
|
+
// src/reading.ts
|
|
21930
|
+
var outlineHash = (outline, consent = "outline") => createHash("sha256").update(consent).update(JSON.stringify(outline)).digest("hex");
|
|
21931
|
+
/**
|
|
21932
|
+
* Reads the repository a turn at a time through our API, or reuses what was read for this exact
|
|
21933
|
+
* outline and consent before. Null when reading ended with no map, so the adapters' facts stand.
|
|
21934
|
+
*/
|
|
21935
|
+
async function readRepository(input) {
|
|
21936
|
+
const { api, dir, result, outline, consent, state } = input;
|
|
21937
|
+
const hash = outlineHash(ScanOutlineV2.parse(outline), consent);
|
|
21938
|
+
const cached = UnderstandingV3.safeParse(state.understood);
|
|
21939
|
+
if (state.outlineHash === hash && cached.success) return {
|
|
21940
|
+
map: cached.data,
|
|
21941
|
+
dropped: [],
|
|
21942
|
+
turns: 0
|
|
21943
|
+
};
|
|
21944
|
+
let explored;
|
|
21945
|
+
try {
|
|
21946
|
+
explored = await explore({
|
|
21947
|
+
result,
|
|
21948
|
+
outline,
|
|
21949
|
+
consent,
|
|
21950
|
+
scanId: randomUUID(),
|
|
21951
|
+
knownValues: await localValues(dir, result.look.settingsFiles),
|
|
21952
|
+
turn: async (request) => ScanTurnReply.parse(await api.post("/scan/turn", request)),
|
|
21953
|
+
onProgress: (what) => status(`Reading: ${what}`),
|
|
21954
|
+
onSent: (sent) => recordSent(dir, sent)
|
|
21955
|
+
});
|
|
21956
|
+
} catch (error) {
|
|
21957
|
+
if (error instanceof ApiError$1 && error.status === 404) {
|
|
21958
|
+
status("");
|
|
21959
|
+
return {
|
|
21960
|
+
map: await understandOutline(api, outline, state),
|
|
21961
|
+
dropped: [],
|
|
21962
|
+
turns: 1
|
|
21963
|
+
};
|
|
21964
|
+
}
|
|
21965
|
+
throw error;
|
|
21966
|
+
} finally {
|
|
21967
|
+
status("");
|
|
21968
|
+
}
|
|
21969
|
+
if (!explored.map) return null;
|
|
21970
|
+
state.understood = explored.map;
|
|
21971
|
+
state.outlineHash = hash;
|
|
21972
|
+
return {
|
|
21973
|
+
map: explored.map,
|
|
21974
|
+
dropped: explored.dropped,
|
|
21975
|
+
turns: explored.turns,
|
|
21976
|
+
stopped: explored.stopped
|
|
21977
|
+
};
|
|
21978
|
+
}
|
|
21979
|
+
/** Asks the model, or reuses what it said about this exact outline before. */
|
|
21980
|
+
async function understandOutline(api, outline, state) {
|
|
21981
|
+
const hash = outlineHash(ScanOutlineV2.parse(outline));
|
|
21982
|
+
const cached = UnderstandingV3.safeParse(state.understood);
|
|
21983
|
+
if (state.outlineHash === hash && cached.success) return cached.data;
|
|
21984
|
+
const understood = UnderstandingV3.parse(await api.post("/scan/understand", outline));
|
|
21985
|
+
state.understood = understood;
|
|
21986
|
+
state.outlineHash = hash;
|
|
21987
|
+
return understood;
|
|
21988
|
+
}
|
|
21989
|
+
/** What the model said, kept only where the code agrees, beside what the adapters read. */
|
|
21990
|
+
function read(result, understood) {
|
|
21991
|
+
const verified = verifyUnderstanding(understood, result.look, result.project);
|
|
21992
|
+
const acts = new Set(verified.understanding.tools.filter((t) => !t.can.startsWith("read")).map((t) => t.name));
|
|
21993
|
+
return {
|
|
21994
|
+
understood,
|
|
21995
|
+
verified,
|
|
21996
|
+
facts: mergeFacts(result.facts, verified.facts).filter((f) => !(f.kind === "way-in" && f.adapter !== "model" && f.function && acts.has(f.function)))
|
|
21997
|
+
};
|
|
20773
21998
|
}
|
|
20774
21999
|
// src/commands/scan.ts
|
|
20775
22000
|
var TOOL_POWERS = [
|
|
@@ -20780,7 +22005,7 @@ var TOOL_POWERS = [
|
|
|
20780
22005
|
"move money",
|
|
20781
22006
|
"delete"
|
|
20782
22007
|
];
|
|
20783
|
-
var CONSENT_TEXT = "To understand your
|
|
22008
|
+
var CONSENT_TEXT = "To understand your app, the scan lets a model read your code the way an engineer would, on our own Google Cloud project. It starts from an outline: file paths, the names and signatures of functions and classes, your prompts and the names of your settings. Then it asks for the files and lines it needs, and only those are sent, each through a filter that takes secrets out. It never sends settings files, keys, certificates or credential files, the values of your settings, or files over 1 MB. Every answer it sends is listed in .modelos-secure/sent.jsonl. Run with --explain to see what may be read, or --offline to keep everything here.";
|
|
20784
22009
|
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
22010
|
/** Only for telling a repository of several apps from one app: whether any file imports an AI library. */
|
|
20786
22011
|
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;
|
|
@@ -20805,7 +22030,7 @@ async function appsIn(root) {
|
|
|
20805
22030
|
]) {
|
|
20806
22031
|
const text = await readFile(join(full, manifest), "utf8").catch(() => "");
|
|
20807
22032
|
if (AI_DEPENDENCIES.test(text)) {
|
|
20808
|
-
found.push(
|
|
22033
|
+
found.push(inRepo(root, full));
|
|
20809
22034
|
break;
|
|
20810
22035
|
}
|
|
20811
22036
|
}
|
|
@@ -20824,7 +22049,6 @@ async function scanCommand(dir, options) {
|
|
|
20824
22049
|
ci: options.ci
|
|
20825
22050
|
};
|
|
20826
22051
|
const state = await readState(dir);
|
|
20827
|
-
let closerLook;
|
|
20828
22052
|
let previous;
|
|
20829
22053
|
try {
|
|
20830
22054
|
previous = await readConfig(dir);
|
|
@@ -20853,40 +22077,71 @@ async function scanCommand(dir, options) {
|
|
|
20853
22077
|
}
|
|
20854
22078
|
if (apps.length === 1) return scanCommand(join(dir, apps[0]), options);
|
|
20855
22079
|
}
|
|
20856
|
-
step("Read", `${plural(result.project.files.length, "file")} parsed${result.adapters.
|
|
22080
|
+
step("Read", `${plural(result.project.files.length, "file")} parsed${result.adapters.some((a) => a !== "http") ? `, with the reader for ${result.adapters.filter((a) => a !== "http").join(" and ")}` : ""}`);
|
|
20857
22081
|
const outline = await buildOutline(result.look, result.facts, result.project);
|
|
20858
22082
|
if (options.explain) {
|
|
20859
|
-
|
|
22083
|
+
const { mayRead, neverSent } = await whatMayBeRead(dir);
|
|
22084
|
+
say("\nNothing was sent. This is the exact outline the reader starts from, with secrets taken out:\n");
|
|
20860
22085
|
say(JSON.stringify(outline, null, 2));
|
|
22086
|
+
say(`\nWith a yes to sending code, the reader may ask for lines of ${plural(mayRead.length, "file")}, each through the secret filter. With a yes to the outline only, it may list folders and ask what the index knows of a file, and no code is sent.`);
|
|
22087
|
+
say(`\nNever sent, whatever is asked (${neverSent.length.toString()}):`);
|
|
22088
|
+
for (const n of neverSent) say(` ${n.path}, because ${n.why}`);
|
|
22089
|
+
if (neverSent.length === 0) say(" nothing here matches");
|
|
22090
|
+
say(`\nWhat was actually sent on earlier runs is in .modelos-secure/sent.jsonl.`);
|
|
20861
22091
|
return;
|
|
20862
22092
|
}
|
|
20863
22093
|
const connection = await readConnection();
|
|
20864
|
-
|
|
20865
|
-
|
|
20866
|
-
|
|
20867
|
-
|
|
20868
|
-
|
|
20869
|
-
|
|
22094
|
+
const agreed = previous?.consent ?? (state.consented ? "outline" : void 0);
|
|
22095
|
+
const askable = !options.yes && !options.ci && interactive();
|
|
22096
|
+
let consent = options.offline ? "none" : agreed ?? "none";
|
|
22097
|
+
const choices = [
|
|
22098
|
+
{
|
|
22099
|
+
label: "Yes, let it read the code it needs",
|
|
22100
|
+
value: "code",
|
|
20870
22101
|
recommended: true
|
|
20871
|
-
},
|
|
22102
|
+
},
|
|
22103
|
+
{
|
|
22104
|
+
label: "Send only the outline",
|
|
22105
|
+
value: "outline"
|
|
22106
|
+
},
|
|
22107
|
+
{
|
|
20872
22108
|
label: "No, keep everything here and ask me more",
|
|
20873
22109
|
value: "none"
|
|
20874
|
-
}
|
|
22110
|
+
}
|
|
22111
|
+
];
|
|
22112
|
+
if (!options.offline && !agreed && !askable) say("\n Nothing is sent: that needs a yes once at a terminal, or consent: code or outline in modelos-secure.yaml.\n");
|
|
22113
|
+
if (!options.offline && !agreed && askable) {
|
|
22114
|
+
say(`\n ${CONSENT_TEXT}\n`);
|
|
22115
|
+
consent = await ask("Send code to the model?", void 0, choices);
|
|
22116
|
+
say();
|
|
22117
|
+
}
|
|
22118
|
+
if (!options.offline && agreed === "outline" && askable && !state.askedCode) {
|
|
22119
|
+
say(`\n This repository agreed to send the outline. The scan can now also read the code it needs, which gives a more exact map.\n\n ${CONSENT_TEXT}\n`);
|
|
22120
|
+
consent = await ask("Send code to the model?", void 0, [
|
|
22121
|
+
choices[0],
|
|
22122
|
+
{
|
|
22123
|
+
...choices[1],
|
|
22124
|
+
label: "Keep sending only the outline"
|
|
22125
|
+
},
|
|
22126
|
+
choices[2]
|
|
22127
|
+
]);
|
|
22128
|
+
state.askedCode = true;
|
|
20875
22129
|
say();
|
|
20876
22130
|
}
|
|
20877
|
-
state.consented = consent
|
|
22131
|
+
state.consented = consent !== "none";
|
|
20878
22132
|
await writeState(dir, state);
|
|
20879
22133
|
let offline = options.offline || consent === "none";
|
|
20880
22134
|
let api = connection ? apiClient(connection) : null;
|
|
20881
22135
|
let reading;
|
|
20882
|
-
|
|
22136
|
+
let understood = null;
|
|
22137
|
+
const understandOnce = async () => {
|
|
20883
22138
|
await withFixes(async () => {
|
|
20884
|
-
if (offline) return;
|
|
22139
|
+
if (offline || consent === "none") return;
|
|
20885
22140
|
if (!api) throw new Failure("I need a workspace token first.", [{
|
|
20886
22141
|
label: "Enter a token now",
|
|
20887
22142
|
recommended: true,
|
|
20888
22143
|
apply: async () => {
|
|
20889
|
-
const { loginCommand } = await import("./assets/login-
|
|
22144
|
+
const { loginCommand } = await import("./assets/login-CvPyhFJ7.js");
|
|
20890
22145
|
await loginCommand();
|
|
20891
22146
|
const fresh = await readConnection();
|
|
20892
22147
|
api = fresh ? apiClient(fresh) : null;
|
|
@@ -20896,11 +22151,19 @@ async function scanCommand(dir, options) {
|
|
|
20896
22151
|
apply: () => void (offline = true)
|
|
20897
22152
|
}]);
|
|
20898
22153
|
try {
|
|
20899
|
-
|
|
22154
|
+
understood = await readRepository({
|
|
22155
|
+
api,
|
|
22156
|
+
dir,
|
|
22157
|
+
result,
|
|
22158
|
+
outline,
|
|
22159
|
+
consent,
|
|
22160
|
+
state
|
|
22161
|
+
});
|
|
22162
|
+
if (understood) reading = read(result, understood.map);
|
|
20900
22163
|
} catch (error) {
|
|
20901
22164
|
const wrongAddress = error instanceof IapRefusal && error.kind === "not-the-app" ? error.message : void 0;
|
|
20902
22165
|
if (error instanceof ApiError$1 && error.status < 500 && !wrongAddress) throw error;
|
|
20903
|
-
throw new Failure("I can't reach ModelOS at the address in your settings.", [{
|
|
22166
|
+
throw new Failure(error instanceof ApiError$1 ? "ModelOS answered with an error on its side. It is recorded there; try the scan again in a few minutes." : "I can't reach ModelOS at the address in your settings.", [{
|
|
20904
22167
|
label: "Check the address",
|
|
20905
22168
|
apply: async () => {
|
|
20906
22169
|
const url = await askText("What is the address?");
|
|
@@ -20915,33 +22178,16 @@ async function scanCommand(dir, options) {
|
|
|
20915
22178
|
}, retry);
|
|
20916
22179
|
await writeState(dir, state);
|
|
20917
22180
|
};
|
|
20918
|
-
|
|
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
|
-
});
|
|
20936
|
-
}
|
|
22181
|
+
await understandOnce();
|
|
20937
22182
|
const facts = reading?.facts ?? result.facts;
|
|
20938
22183
|
const understanding = reading?.verified.understanding;
|
|
20939
22184
|
const count = (kind) => facts.filter((f) => f.kind === kind).length;
|
|
20940
22185
|
const changers = (understanding?.tools ?? []).filter((t) => t.can !== "read" && t.can !== "read private data");
|
|
20941
22186
|
const untrusted = (understanding?.waysIn ?? []).filter((w) => w.untrusted);
|
|
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 ?? [];
|
|
22187
|
+
step("Understand", offline || consent === "none" ? "Offline, so nothing was sent" : `${understood && understood.turns > 1 ? `read in ${plural(understood.turns, "turn")}; ` : ""}${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`);
|
|
22188
|
+
const dropped = [...understood?.dropped ?? [], ...reading?.verified.dropped ?? []].filter((d, i, all) => all.findIndex((x) => x.what === d.what) === i);
|
|
20944
22189
|
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("; ")}.`)}`);
|
|
22190
|
+
if (understood?.stopped === "budget") say(` ${grey("Reading stopped at this workspace's limit for today, so the map rests on what was read so far.")}`);
|
|
20945
22191
|
const drafted = draftConfig({
|
|
20946
22192
|
look: result.look,
|
|
20947
22193
|
facts,
|
|
@@ -20953,7 +22199,7 @@ async function scanCommand(dir, options) {
|
|
|
20953
22199
|
});
|
|
20954
22200
|
let config = {
|
|
20955
22201
|
...drafted.config,
|
|
20956
|
-
closer_look:
|
|
22202
|
+
closer_look: previous?.closer_look
|
|
20957
22203
|
};
|
|
20958
22204
|
const open = drafted.questions.filter((q) => !state.answers[q.id]);
|
|
20959
22205
|
const summary = summaryOf(config, facts, reading);
|
|
@@ -20976,7 +22222,7 @@ async function scanCommand(dir, options) {
|
|
|
20976
22222
|
if (options.ci && open.length > 0) {
|
|
20977
22223
|
say("\nThese questions need answering locally, because CI never guesses:");
|
|
20978
22224
|
for (const q of open) say(` ${q.text}`);
|
|
20979
|
-
process.exitCode =
|
|
22225
|
+
process.exitCode = 3;
|
|
20980
22226
|
return;
|
|
20981
22227
|
}
|
|
20982
22228
|
step("Ask", open.length === 0 ? "No questions" : plural(open.length, "question"));
|
|
@@ -21042,54 +22288,18 @@ async function scanCommand(dir, options) {
|
|
|
21042
22288
|
guardrails: facts.flatMap((f) => f.kind === "guardrail" ? [f.name] : [])
|
|
21043
22289
|
});
|
|
21044
22290
|
const versionLabel = createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 7);
|
|
21045
|
-
if (options.offline) step("Start", `${dash} skipped offline`);
|
|
21046
|
-
let pool = null;
|
|
21047
|
-
const stopPool = () => pool?.stop();
|
|
21048
|
-
process.once("exit", stopPool);
|
|
21049
|
-
if (!options.offline) {
|
|
21050
|
-
const startCtx = {
|
|
21051
|
-
dir,
|
|
21052
|
-
config,
|
|
21053
|
-
extraEnv: {},
|
|
21054
|
-
startCommands: result.look.startCommands,
|
|
21055
|
-
onConfig: (c) => writeConfig(dir, c),
|
|
21056
|
-
explain: explainWith(offline ? null : api, config)
|
|
21057
|
-
};
|
|
21058
|
-
pool = await withFixes(() => startApp(startCtx, 1), retry);
|
|
21059
|
-
step("Start", `${config.start?.command ?? ""} with hooks attached`);
|
|
21060
|
-
const check = await pool.send({
|
|
21061
|
-
session: "connection-check",
|
|
21062
|
-
message: "Connection check from ModelOS Secure",
|
|
21063
|
-
canaries: {
|
|
21064
|
-
record: "CONNECTION-CHECK",
|
|
21065
|
-
prompt: "CONNECTION-CHECK"
|
|
21066
|
-
}
|
|
21067
|
-
});
|
|
21068
|
-
const why = pool.lastStop();
|
|
21069
|
-
pool.stop();
|
|
21070
|
-
pool = null;
|
|
21071
|
-
const held = config.tools.filter((t) => t.during_reviews === "hold").map((t) => t.name);
|
|
21072
|
-
const heldEscaped = check.ranTools.filter((name) => held.includes(name));
|
|
21073
|
-
if (heldEscaped.length > 0) throw new Failure(`A tool marked as held ran for real: ${heldEscaped.join(", ")}.`, []);
|
|
21074
|
-
if (!check.answered) await withFixes(async () => {
|
|
21075
|
-
throw new Failure(`The app did not answer the connection check: ${why ?? check.error ?? "no reply"}.`, []);
|
|
21076
|
-
}, retry);
|
|
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.")}`);
|
|
21078
|
-
}
|
|
21079
22291
|
const register = toRegister(config, parts, {
|
|
21080
22292
|
summary: understanding?.summary,
|
|
21081
22293
|
versionLabel,
|
|
21082
|
-
toolFacts: config.consent === "outline" ? facts.flatMap((f) => f.kind === "tool" ? [{
|
|
22294
|
+
toolFacts: config.consent === "outline" || config.consent === "code" ? facts.flatMap((f) => f.kind === "tool" ? [{
|
|
21083
22295
|
name: f.name,
|
|
21084
|
-
description: f.description,
|
|
21085
|
-
signature: f.signature
|
|
22296
|
+
description: f.description && redact(f.description),
|
|
22297
|
+
signature: f.signature && redact(f.signature)
|
|
21086
22298
|
}] : []) : void 0
|
|
21087
22299
|
});
|
|
21088
22300
|
if (offline || !api) {
|
|
21089
|
-
|
|
21090
|
-
|
|
21091
|
-
step("Check", "Saved locally, because the scan ran offline");
|
|
21092
|
-
say(config.consent === "none" ? `\n modelos-secure.yaml says ${bold("consent: none")}, so nothing is sent. Change it to ${bold("outline")} to send the outline.` : `\n Run ${bold("modelos-secure scan .")} when you can reach ModelOS, and it will send this.`);
|
|
22301
|
+
step("Register", "Saved here only, because the scan ran offline");
|
|
22302
|
+
say(config.consent === "none" ? `\n modelos-secure.yaml says ${bold("consent: none")}, so nothing is sent. Change it to ${bold("code")} to let the scan read the code it needs, or ${bold("outline")} to send the outline only.` : `\n Run ${bold("modelos-secure scan .")} when you can reach ModelOS, and it will send this.`);
|
|
21093
22303
|
return;
|
|
21094
22304
|
}
|
|
21095
22305
|
const system = await api.post("/systems", register);
|
|
@@ -21097,17 +22307,7 @@ async function scanCommand(dir, options) {
|
|
|
21097
22307
|
const csv = await readFile(join(dir, config.answer_key), "utf8").catch(() => null);
|
|
21098
22308
|
if (csv) await uploadAnswerKey(api, system.id, config.answer_key, csv);
|
|
21099
22309
|
}
|
|
21100
|
-
|
|
21101
|
-
state.doneSteps = [
|
|
21102
|
-
"look",
|
|
21103
|
-
"read",
|
|
21104
|
-
"understand",
|
|
21105
|
-
"ask",
|
|
21106
|
-
"start",
|
|
21107
|
-
"check"
|
|
21108
|
-
];
|
|
21109
|
-
await writeState(dir, state);
|
|
21110
|
-
step("Check", "The hooks saw the app and held what they should");
|
|
22310
|
+
step("Register", `The map is saved to the workspace`);
|
|
21111
22311
|
const paths = (config.ways_in.length || 1) * (config.rules.length || 1);
|
|
21112
22312
|
say(`\n ${bold(`${config.system} is connected.`)}\n`);
|
|
21113
22313
|
say(` ${`${plural(config.ways_in.length, "way")} in`.padEnd(14)} ${config.ways_in.map((w) => w.name).join(", ")}`);
|
|
@@ -21116,7 +22316,7 @@ async function scanCommand(dir, options) {
|
|
|
21116
22316
|
const url = connection ? `${connection.url}/systems/${system.id}` : "";
|
|
21117
22317
|
say(` Map: ${url}`);
|
|
21118
22318
|
if (options.noReview || options.ci) return;
|
|
21119
|
-
if (options.yes ? false : await ask("Start a quick first review now? About 15 minutes, 1,200 attempts.",
|
|
22319
|
+
if (options.yes ? false : await ask("Start a quick first review now? About 15 minutes, 1,200 attempts.", "It starts the app on this machine with the hooks attached, checks they hold what they should, then reviews it.", [{
|
|
21120
22320
|
label: "Yes, start it",
|
|
21121
22321
|
value: true,
|
|
21122
22322
|
recommended: true
|
|
@@ -21174,14 +22374,105 @@ async function uploadAnswerKey(api, systemId, name, csv) {
|
|
|
21174
22374
|
say(` ${grey(`Read ${plural(answer.questions, "question")} from ${name}.`)}`);
|
|
21175
22375
|
}
|
|
21176
22376
|
var held = (config) => config.tools.filter((t) => t.during_reviews === "hold").map((t) => t.name);
|
|
21177
|
-
// src/
|
|
21178
|
-
|
|
21179
|
-
|
|
21180
|
-
|
|
21181
|
-
|
|
21182
|
-
|
|
22377
|
+
// src/session.ts
|
|
22378
|
+
async function sessionCommand(dir, run) {
|
|
22379
|
+
const connection = await readConnection();
|
|
22380
|
+
const config = await readConfig(dir).catch(() => null);
|
|
22381
|
+
say(`${bold("ModelOS Secure")} ${config?.system ?? basename(dir)}\n`);
|
|
22382
|
+
say(` ${config ? `Mapped as ${bold(config.system)} in modelos-secure.yaml.` : "No map here yet: scan reads this folder and makes one."}`);
|
|
22383
|
+
say(` ${connection ? "Signed in to a workspace." : "Not signed in: modelos-secure login connects a workspace."}`);
|
|
22384
|
+
say(`\n Ask about this app in your own words, or type ${bold("scan")}, ${bold("review")}, ${bold("help")} or ${bold("quit")}.`);
|
|
22385
|
+
for (;;) {
|
|
22386
|
+
say();
|
|
22387
|
+
const line = await askText(">");
|
|
22388
|
+
const word = line.toLowerCase();
|
|
22389
|
+
if (!line || word === "quit" || word === "exit") return;
|
|
22390
|
+
if (word === "help") {
|
|
22391
|
+
run.help();
|
|
22392
|
+
continue;
|
|
22393
|
+
}
|
|
22394
|
+
if (word === "scan" || word === "review") {
|
|
22395
|
+
if (await ask(word === "scan" ? "Scan this folder now?" : "Start a review of this app now? It starts the app here and sends attempts to it.", void 0, [{
|
|
22396
|
+
label: `Yes, ${word}`,
|
|
22397
|
+
value: true,
|
|
22398
|
+
recommended: true
|
|
22399
|
+
}, {
|
|
22400
|
+
label: "No",
|
|
22401
|
+
value: false
|
|
22402
|
+
}])) await (word === "scan" ? run.scan() : run.review());
|
|
22403
|
+
continue;
|
|
22404
|
+
}
|
|
22405
|
+
await answer(dir, line, connection);
|
|
22406
|
+
}
|
|
22407
|
+
}
|
|
22408
|
+
async function answer(dir, question, connection) {
|
|
22409
|
+
const consent = (await readConfig(dir).catch(() => null))?.consent;
|
|
22410
|
+
if (!connection) {
|
|
22411
|
+
say(`\n ${grey("Answers come from our reader, so they need a workspace: run modelos-secure login first.")}`);
|
|
22412
|
+
return;
|
|
22413
|
+
}
|
|
22414
|
+
if (consent !== "code" && consent !== "outline") {
|
|
22415
|
+
say(`\n ${grey("Answers come from reading your code, and this folder has not agreed to send any. Run scan first, and answer its question about what may be sent.")}`);
|
|
22416
|
+
return;
|
|
22417
|
+
}
|
|
22418
|
+
const api = apiClient(connection);
|
|
22419
|
+
const result = await scanFolder(dir);
|
|
22420
|
+
const outline = await buildOutline(result.look, result.facts, result.project);
|
|
22421
|
+
try {
|
|
22422
|
+
const answered = await answerQuestion({
|
|
22423
|
+
question,
|
|
22424
|
+
result,
|
|
22425
|
+
outline,
|
|
22426
|
+
consent,
|
|
22427
|
+
knownValues: await localValues(dir, result.look.settingsFiles),
|
|
22428
|
+
turn: async (request) => ScanTurnReply.parse(await api.post("/scan/turn", request)),
|
|
22429
|
+
onProgress: (what) => status(`Reading: ${what}`),
|
|
22430
|
+
onSent: (sent) => recordSent(dir, sent)
|
|
22431
|
+
});
|
|
22432
|
+
status("");
|
|
22433
|
+
say();
|
|
22434
|
+
for (const paragraph of answered.answer.split(/\n+/)) say(` ${paragraph}`);
|
|
22435
|
+
} catch (error) {
|
|
22436
|
+
status("");
|
|
22437
|
+
say(`\n ${grey(`No answer this time: ${error instanceof Error ? error.message : String(error)}`)}`);
|
|
22438
|
+
}
|
|
21183
22439
|
}
|
|
21184
|
-
|
|
22440
|
+
// src/main.ts
|
|
22441
|
+
var { values, positionals } = parseArgs({
|
|
22442
|
+
args: process.argv.slice(2),
|
|
22443
|
+
allowPositionals: true,
|
|
22444
|
+
strict: false,
|
|
22445
|
+
options: {
|
|
22446
|
+
offline: { type: "boolean" },
|
|
22447
|
+
explain: { type: "boolean" },
|
|
22448
|
+
yes: { type: "boolean" },
|
|
22449
|
+
ci: { type: "boolean" },
|
|
22450
|
+
"no-review": { type: "boolean" },
|
|
22451
|
+
quick: { type: "boolean" },
|
|
22452
|
+
version: {
|
|
22453
|
+
type: "boolean",
|
|
22454
|
+
short: "v"
|
|
22455
|
+
},
|
|
22456
|
+
help: {
|
|
22457
|
+
type: "boolean",
|
|
22458
|
+
short: "h"
|
|
22459
|
+
},
|
|
22460
|
+
instances: { type: "string" },
|
|
22461
|
+
budget: { type: "string" },
|
|
22462
|
+
format: { type: "string" },
|
|
22463
|
+
out: { type: "string" },
|
|
22464
|
+
system: { type: "string" },
|
|
22465
|
+
against: { type: "string" },
|
|
22466
|
+
url: { type: "string" },
|
|
22467
|
+
"client-id": { type: "string" },
|
|
22468
|
+
"client-secret": { type: "string" },
|
|
22469
|
+
"tls-name": { type: "string" }
|
|
22470
|
+
}
|
|
22471
|
+
});
|
|
22472
|
+
var [command = "", ...positional] = positionals;
|
|
22473
|
+
var VERSION = package_default.version;
|
|
22474
|
+
var has = (name) => values[name] === true;
|
|
22475
|
+
var flag = (name) => typeof values[name] === "string" ? values[name] : void 0;
|
|
21185
22476
|
async function connection() {
|
|
21186
22477
|
const found = await readConnection();
|
|
21187
22478
|
if (!found) throw new Error("No workspace token found. Run modelos-secure login, or set MODELOS_SECURE_TOKEN.");
|
|
@@ -21190,14 +22481,97 @@ async function connection() {
|
|
|
21190
22481
|
var USAGE = `modelos-secure: test an AI system for Security, Safety and Quality.
|
|
21191
22482
|
|
|
21192
22483
|
login Saves a workspace token, and signs in with Google when the
|
|
21193
|
-
address needs it.
|
|
21194
|
-
|
|
21195
|
-
|
|
21196
|
-
|
|
22484
|
+
address needs it.
|
|
22485
|
+
(no command) At a terminal, a session about the app in this folder: ask
|
|
22486
|
+
about it in your own words, scan it or review it.
|
|
22487
|
+
scan . Reads this repository and connects it.
|
|
22488
|
+
review Starts a review of this system.
|
|
21197
22489
|
replay F-031 Replays one finding.
|
|
21198
22490
|
doctor Checks the setup and fixes what it can.
|
|
21199
|
-
runner
|
|
21200
|
-
export R-1
|
|
22491
|
+
runner Serves a review's attempts from this machine, in the app's folder.
|
|
22492
|
+
export R-1 Writes a review's findings as SARIF or JSON.
|
|
22493
|
+
|
|
22494
|
+
--help Says what a command does and what it takes: modelos-secure scan --help.
|
|
22495
|
+
--version Prints this CLI's version.
|
|
22496
|
+
|
|
22497
|
+
Exit codes: 0 done, 1 failed or a fix check found a break, 2 used wrongly,
|
|
22498
|
+
3 questions only a person can answer, 130 stopped with Ctrl-C.`;
|
|
22499
|
+
/** What each command takes. A flag not listed for a command is refused, with this text. */
|
|
22500
|
+
var COMMANDS = {
|
|
22501
|
+
login: {
|
|
22502
|
+
flags: [
|
|
22503
|
+
"url",
|
|
22504
|
+
"client-id",
|
|
22505
|
+
"client-secret",
|
|
22506
|
+
"tls-name"
|
|
22507
|
+
],
|
|
22508
|
+
help: `modelos-secure login: saves a workspace token, from the app's tokens page.
|
|
22509
|
+
|
|
22510
|
+
--url <address> The app's address, with /secure.
|
|
22511
|
+
--client-id <id> The Google client to sign in with, when the app is behind Google.
|
|
22512
|
+
--client-secret <s> Its secret, from the same page.
|
|
22513
|
+
--tls-name <name> The name the address's certificate is issued for, when it is an IP.`
|
|
22514
|
+
},
|
|
22515
|
+
scan: {
|
|
22516
|
+
flags: [
|
|
22517
|
+
"offline",
|
|
22518
|
+
"explain",
|
|
22519
|
+
"yes",
|
|
22520
|
+
"ci",
|
|
22521
|
+
"no-review"
|
|
22522
|
+
],
|
|
22523
|
+
help: `modelos-secure scan <folder>: reads the repository and connects it.
|
|
22524
|
+
|
|
22525
|
+
--offline Keeps everything on this machine; asks more questions instead.
|
|
22526
|
+
--explain Prints what would be sent, and sends nothing.
|
|
22527
|
+
--yes Takes the recommended answer to every question. Never sends code.
|
|
22528
|
+
--ci Asks nothing; fails with the questions only a person can answer.
|
|
22529
|
+
--no-review Does not offer to start a review at the end.`
|
|
22530
|
+
},
|
|
22531
|
+
review: {
|
|
22532
|
+
flags: [
|
|
22533
|
+
"quick",
|
|
22534
|
+
"ci",
|
|
22535
|
+
"yes",
|
|
22536
|
+
"instances",
|
|
22537
|
+
"budget"
|
|
22538
|
+
],
|
|
22539
|
+
help: `modelos-secure review: starts the app with its hooks, serves the attempts, and reviews it.
|
|
22540
|
+
|
|
22541
|
+
--quick A first look: fewer attempts per path.
|
|
22542
|
+
--ci For a pull request: replays open findings and fails on a break.
|
|
22543
|
+
--yes Takes the recommended answer to every question.
|
|
22544
|
+
--budget <n> The most answered attempts per path.
|
|
22545
|
+
--instances <n> How many copies of the app to run side by side. Default 4.`
|
|
22546
|
+
},
|
|
22547
|
+
replay: {
|
|
22548
|
+
flags: [],
|
|
22549
|
+
help: `modelos-secure replay <finding>: replays one finding, such as F-031.`
|
|
22550
|
+
},
|
|
22551
|
+
doctor: {
|
|
22552
|
+
flags: [],
|
|
22553
|
+
help: `modelos-secure doctor: checks the setup, says what is wrong, and fixes what it can.`
|
|
22554
|
+
},
|
|
22555
|
+
runner: {
|
|
22556
|
+
flags: ["system", "instances"],
|
|
22557
|
+
help: `modelos-secure runner: serves a review's attempts from this machine.
|
|
22558
|
+
|
|
22559
|
+
--system <id> The system, when not run in the app's own folder.
|
|
22560
|
+
--instances <n> How many copies of the app to run side by side. Default 4.`
|
|
22561
|
+
},
|
|
22562
|
+
export: {
|
|
22563
|
+
flags: ["format", "out"],
|
|
22564
|
+
help: `modelos-secure export <review>: writes a review's findings, such as R-1179.
|
|
22565
|
+
|
|
22566
|
+
--format sarif|json Default json.
|
|
22567
|
+
--out <file> Where to write; standard output when left out.`
|
|
22568
|
+
}
|
|
22569
|
+
};
|
|
22570
|
+
/** A mistake in how the command was typed: said on standard error, and exit code 2. */
|
|
22571
|
+
function usedWrongly(message) {
|
|
22572
|
+
process.stderr.write(`${message}\n`);
|
|
22573
|
+
process.exit(2);
|
|
22574
|
+
}
|
|
21201
22575
|
var STOPPED = "Stopped. Run the same command again to carry on from here.";
|
|
21202
22576
|
process.once("SIGINT", () => {
|
|
21203
22577
|
finishBeforeStopping().finally(() => {
|
|
@@ -21205,9 +22579,42 @@ process.once("SIGINT", () => {
|
|
|
21205
22579
|
process.exit(130);
|
|
21206
22580
|
});
|
|
21207
22581
|
});
|
|
22582
|
+
var known = COMMANDS[command];
|
|
22583
|
+
if (has("version")) {
|
|
22584
|
+
say(VERSION);
|
|
22585
|
+
process.exit(0);
|
|
22586
|
+
}
|
|
22587
|
+
if (!command && Object.keys(values).some((k) => k !== "help")) usedWrongly(`modelos-secure on its own does not take --${Object.keys(values).find((k) => k !== "help")}. modelos-secure --help lists the commands.`);
|
|
22588
|
+
if (!command && (values.help || !interactive()) || values.help && !known) {
|
|
22589
|
+
say(USAGE);
|
|
22590
|
+
process.exit(0);
|
|
22591
|
+
}
|
|
22592
|
+
if (command && !known) usedWrongly(`There is no command ${command}. modelos-secure --help lists them.`);
|
|
22593
|
+
if (values.help && known) {
|
|
22594
|
+
say(known.help);
|
|
22595
|
+
process.exit(0);
|
|
22596
|
+
}
|
|
22597
|
+
for (const name of Object.keys(values)) if (!known?.flags.includes(name)) usedWrongly(command ? `modelos-secure ${command} does not take --${name}. modelos-secure ${command} --help lists what it does take.` : `modelos-secure on its own does not take --${name}. modelos-secure --help lists the commands.`);
|
|
21208
22598
|
try {
|
|
21209
22599
|
const ci = has("ci") || Boolean(process.env.CI);
|
|
21210
|
-
if (command
|
|
22600
|
+
if (!command) await sessionCommand(process.cwd(), {
|
|
22601
|
+
scan: () => scanCommand(process.cwd(), {
|
|
22602
|
+
offline: false,
|
|
22603
|
+
explain: false,
|
|
22604
|
+
yes: false,
|
|
22605
|
+
ci: false,
|
|
22606
|
+
noReview: true
|
|
22607
|
+
}),
|
|
22608
|
+
review: () => reviewCommand(process.cwd(), {
|
|
22609
|
+
quick: false,
|
|
22610
|
+
ci: false,
|
|
22611
|
+
yes: false,
|
|
22612
|
+
instances: 4,
|
|
22613
|
+
budget: void 0
|
|
22614
|
+
}),
|
|
22615
|
+
help: () => say(USAGE)
|
|
22616
|
+
});
|
|
22617
|
+
else if (command === "login") await loginCommand({
|
|
21211
22618
|
url: flag("url"),
|
|
21212
22619
|
clientId: flag("client-id"),
|
|
21213
22620
|
clientSecret: flag("client-secret"),
|
|
@@ -21228,17 +22635,13 @@ try {
|
|
|
21228
22635
|
budget: flag("budget") ? Number(flag("budget")) : void 0
|
|
21229
22636
|
});
|
|
21230
22637
|
else if (command === "replay") {
|
|
21231
|
-
if (!positional[0])
|
|
22638
|
+
if (!positional[0]) usedWrongly("Say which finding, such as modelos-secure replay F-031.");
|
|
21232
22639
|
await replayCommand(process.cwd(), positional[0]);
|
|
21233
22640
|
} else if (command === "doctor") await doctorCommand(process.cwd());
|
|
21234
22641
|
else if (command === "export") {
|
|
21235
|
-
if (!positional[0])
|
|
22642
|
+
if (!positional[0]) usedWrongly("Say which review, such as modelos-secure export R-1179 --format sarif.");
|
|
21236
22643
|
await exportCommand(positional[0], flag("format") ?? "json", flag("out"));
|
|
21237
|
-
} else if (command === "runner")
|
|
21238
|
-
const systemId = flag("system");
|
|
21239
|
-
if (!systemId) throw new Error("Say which system with --system.");
|
|
21240
|
-
await runnerCommand(await connection(), process.cwd(), systemId, Number(flag("instances") ?? 4));
|
|
21241
|
-
} else say(USAGE);
|
|
22644
|
+
} else if (command === "runner") await runnerCommand(await connection(), process.cwd(), flag("system"), Number(flag("instances") ?? 4));
|
|
21242
22645
|
} catch (error) {
|
|
21243
22646
|
if (error instanceof Interrupted) {
|
|
21244
22647
|
await finishBeforeStopping();
|
|
@@ -21247,10 +22650,12 @@ try {
|
|
|
21247
22650
|
}
|
|
21248
22651
|
if (!(error instanceof Failure && error.shown)) say(`\n ${red(error instanceof Error ? error.message : String(error))}`);
|
|
21249
22652
|
const step = lastFinishedStep();
|
|
21250
|
-
say(`
|
|
21251
|
-
|
|
21252
|
-
|
|
21253
|
-
|
|
22653
|
+
if (step) say(` It stopped after ${step}. Your answers are kept.`);
|
|
22654
|
+
if (!(error instanceof Failure)) {
|
|
22655
|
+
const log = await keepError(process.cwd(), error);
|
|
22656
|
+
if (log) say(` The details are in ${log}, on this machine only.`);
|
|
22657
|
+
say(" modelos-secure doctor checks the setup and fixes what it can.");
|
|
22658
|
+
}
|
|
21254
22659
|
process.exit(1);
|
|
21255
22660
|
}
|
|
21256
22661
|
export {};
|