modelos-secure 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { _ as interactive, a as apiClient, b as say, c as Interrupted, d as beforeStopping, f as blue, g as grey, h as finishBeforeStopping, i as IapRefusal, l as ask, m as dash, o as atAddress, p as bold, r as ApiError$1, s as readConnection, t as loginCommand, u as askText, v as lastFinishedStep, x as step, y as red } from "./assets/login-DZgP66jr.js";
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 { access, appendFile, constants, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
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.0";
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": false,
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.label)));
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.label);
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(relative(root, full));
352
- } else if (DOCUMENT.has(extname(entry.name)) && !NOT_DOCUMENTS.test(entry.name)) documents.push(relative(root, full));
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(relative(root, full));
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$1 = /* @__PURE__ */ new Set([
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$1.has(node.type)) return;
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$1.has(inner.type)) found = inner;
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
- return {
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
- /** What the scan's last step sends to connect a system. */
19444
- var RegisterSystem = object({
19445
- name: string(),
19446
- kind: SystemKind,
19447
- team: string().default("Not set"),
19448
- waysIn: array(WayIn),
19449
- rules: array(MapRule),
19450
- tools: array(ToolSetup),
19451
- knot: string(),
19452
- reaches: string(),
19453
- ruleOfTwo: object({
19454
- untrustedInput: string(),
19455
- privateData: string(),
19456
- outsideActions: string().nullable()
19457
- }),
19458
- parts: VersionParts,
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: string().optional(),
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
- consent: _enum(["outline", "none"]).optional(),
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
- "ignore",
20947
+ "pipe",
19835
20948
  "pipe"
19836
20949
  ],
19837
- detached: true,
20950
+ detached: process.platform !== "win32",
20951
+ windowsHide: true,
19838
20952
  env: {
19839
20953
  ...process.env,
19840
20954
  ...input.extraEnv,
19841
- PATH: `${dirname(process.execPath)}:${process.env.PATH ?? ""}`,
20955
+ ...appPath(dir),
19842
20956
  PORT: instance.port.toString(),
19843
- NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${hookNode()}`.trim(),
19844
- PYTHONPATH: `${hookPython()}:${process.env.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
- child.stderr?.on("data", (chunk) => {
19858
- instance.output = (instance.output + chunk.toString()).slice(-4e3);
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) try {
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: async () => {
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. An empty answer, or none at all when nobody is there to type, changes
20259
- * nothing and is never saved, so the command found in the repository is still offered next time.
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
- const typed = (await askText("How do you start the app?")).trim();
20263
- if (!typed) return;
20264
- ctx.config.start = {
20265
- ...ctx.config.start,
20266
- command: typed
20267
- };
20268
- await ctx.onConfig(ctx.config);
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
- /** Starts the pool, or fails with what happened and how to fix it. */
20271
- async function startApp(ctx, instances) {
20272
- const { config } = ctx;
20273
- const command = config.start?.command;
20274
- if (!command) {
20275
- const fixes = ctx.startCommands.map((c) => ({
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
- if (ctx.startCommands.length > 1 && !config.start?.settings && !config.start?.command) throw new Failure("I can't tell how to start the app.", []);
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: () => process.exit(0)
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 = await whyItStopped(ctx, commandForPort(command, basePort));
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
- if (explained) throw new Failure(explained.what, [...explained.fixes.map((f) => toFix(ctx, f)), {
20337
- label: "Type the command",
20338
- apply: () => typeTheCommand(ctx)
20339
- }], explained.cause);
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
- const lastLine = output?.trim().split("\n").at(-1);
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
- var slug$1 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
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$1(way.name),
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 = 1;
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
- "paused"
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) 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()}.`);
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
- const file = join(dir, ".gitignore");
20771
- const text = await readFile(file, "utf8").catch(() => "");
20772
- if (!text.split("\n").some((line) => line.trim().replace(/\/$/, "") === ".modelos-secure")) await appendFile(file, `${text.endsWith("\n") || text === "" ? "" : "\n"}${STATE_DIR}/\n`);
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 code, the scan sends an outline: file paths; the names, signatures, decorators and docstrings of functions and classes; which function calls which; the arguments where agents and tools are declared; your system prompts and the prompt files your code reads; and the names of your settings. If the app stops while starting, the last lines it printed go too, with secrets taken out, to work out the fix. It never sends function bodies, secrets, the values of settings or data. If it needs to read a function, it asks first. Run with --offline to keep everything here; the scan will ask you more questions instead.";
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(relative(root, full));
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.length ? `; ${result.adapters.join(", ")} read for certain` : ""}`);
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
- say("\nThis is the exact outline that would be sent. Nothing was sent.\n");
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
- let consent = options.offline ? "none" : previous?.consent ?? "outline";
20865
- if (!options.offline && !previous?.consent && !state.consented && !options.yes && !options.ci) {
20866
- say(`\n ${CONSENT_TEXT}\n`);
20867
- consent = await ask("Send the outline?", void 0, [{
20868
- label: "Yes, send the outline",
20869
- value: "outline",
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 === "outline";
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
- const understandOnce = async (sent) => {
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-CipN1qFD.js");
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
- reading = read(result, await understandOutline(api, sent, state));
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
- if (!offline) await understandOnce(outline);
20919
- const wanted = reading ? wantsCloserLook(reading) : [];
20920
- if (reading && wanted.length > 0 && !previous?.closer_look) {
20921
- say(`\n To work out what the outline cannot show, the scan would send the code of ${plural(wanted.length, "function")}, and nothing else:`);
20922
- for (const w of wanted) say(` ${w.name} in ${w.file}:${w.line.toString()}, ${w.why}`);
20923
- const yes = !options.yes && !options.ci && await ask("Send these to the model for a closer look?", void 0, [{
20924
- label: "Yes, send only these",
20925
- value: true,
20926
- recommended: true
20927
- }, {
20928
- label: "No, ask me instead",
20929
- value: false
20930
- }]);
20931
- closerLook = yes ? wanted.map((w) => `${w.file}:${w.name}`) : options.yes || options.ci ? void 0 : "no";
20932
- if (yes) await understandOnce({
20933
- ...outline,
20934
- bodies: bodiesOf(result, wanted)
20935
- });
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: closerLook ?? previous?.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 = 1;
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
- state.pendingRegister = register;
21090
- await writeState(dir, state);
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
- state.pendingRegister = void 0;
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.", void 0, [{
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/main.ts
21178
- var [command = "", ...rest] = process.argv.slice(2);
21179
- var has = (name) => rest.includes(`--${name}`);
21180
- function flag(name) {
21181
- const index = rest.indexOf(`--${name}`);
21182
- return index >= 0 ? rest[index + 1] : void 0;
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
- var positional = rest.filter((arg, i) => !arg.startsWith("--") && !rest[i - 1]?.match(/^--(format|out|system|instances|against|budget|url|client-id|client-secret|tls-name)$/));
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. --url, --client-id, --client-secret,
21194
- --tls-name.
21195
- scan . Connects this repository. --offline, --explain, --yes.
21196
- review Starts a review of this system. --quick, --ci, --budget.
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 --system <id> Runs a runner from this machine.
21200
- export R-1 --format sarif Writes a review's findings as SARIF or JSON.`;
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 === "login") await loginCommand({
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]) throw new Error("Say which finding, such as modelos-secure replay F-031.");
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]) throw new Error("Say which review, such as modelos-secure export R-1179 --format sarif.");
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(` ${step ? `It stopped after ${step}. ` : ""}What it did is kept, and running the same command again carries on from there.`);
21251
- const log = await keepError(process.cwd(), error);
21252
- if (log && !(error instanceof Failure)) say(` The details are in ${log}, on this machine only.`);
21253
- say(" modelos-secure doctor checks the setup and fixes what it can.");
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 {};