modelos-secure 0.3.1 → 0.3.3

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.
Files changed (2) hide show
  1. package/cli.js +123 -56
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -3,9 +3,9 @@ import { _ as lastFinishedStep, a as apiClient, b as status, c as Interrupted, d
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
6
+ import { basename, delimiter, dirname, extname, join, posix, relative, resolve, sep } from "node:path";
7
7
  import { parseArgs } from "node:util";
8
- import { access, appendFile, constants, glob, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
8
+ import { access, appendFile, constants, glob, lstat, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { spawn } from "node:child_process";
11
11
  import { createHash, randomUUID } from "node:crypto";
@@ -23,7 +23,7 @@ if (existsSync(resolve(here, "wasm"))) {
23
23
  }
24
24
  // package.json
25
25
  var name = "modelos-secure";
26
- var version$1 = "0.3.1";
26
+ var version$1 = "0.3.3";
27
27
  var type = "module";
28
28
  var bin = { "modelos-secure": "dist/cli.js" };
29
29
  var exports$1 = { "./hook": "./dist/hook/hook.mjs" };
@@ -335,7 +335,17 @@ var SKIPPED = /* @__PURE__ */ new Set([
335
335
  "e2e",
336
336
  "coverage",
337
337
  ".turbo",
338
- ".cache"
338
+ ".cache",
339
+ ".idea",
340
+ ".vscode",
341
+ ".vs",
342
+ ".terraform",
343
+ ".ruff_cache",
344
+ ".nox",
345
+ ".gradle",
346
+ ".yarn",
347
+ ".svelte-kit",
348
+ ".angular"
339
349
  ]);
340
350
  var MAX_FILE_BYTES = 1048576;
341
351
  var DOCUMENT = /* @__PURE__ */ new Set([
@@ -5621,6 +5631,30 @@ function docOf(node, file) {
5621
5631
  // ../../packages/scan/src/start-candidates.ts
5622
5632
  /** Words a README command starts with that start an app, not install or test one. */
5623
5633
  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.*)$/;
5634
+ /** The words of an instruction in its list form, or null when it is in the shell form. */
5635
+ function listForm(body) {
5636
+ try {
5637
+ const parts = JSON.parse(body);
5638
+ return Array.isArray(parts) ? parts.map(String) : null;
5639
+ } catch {
5640
+ return null;
5641
+ }
5642
+ }
5643
+ /**
5644
+ * What a Dockerfile runs, as Docker reads it: the last stage's entrypoint with its command as the
5645
+ * arguments, unless the entrypoint is in the shell form, which ignores them. A health check's own
5646
+ * CMD is not it. Only arguments, starting with a dash, go to a program in the base image, which is
5647
+ * not here to name, so they are no command at all.
5648
+ */
5649
+ function dockerCommand(dockerfile) {
5650
+ const lines = dockerfile.replace(/\\\r?\n/g, " ").split(/\r?\n/).map((l) => l.trim());
5651
+ const stage = lines.slice(lines.findLastIndex((l) => /^FROM\s/i.test(l)) + 1);
5652
+ const last = (word) => stage.findLast((l) => new RegExp(`^${word}\\s`, "i").test(l))?.slice(word.length).trim();
5653
+ const entrypoint = last("ENTRYPOINT");
5654
+ if (entrypoint && !listForm(entrypoint)) return entrypoint;
5655
+ const full = [entrypoint, last("CMD")].filter((body) => body !== void 0).map((body) => listForm(body)?.join(" ") ?? body).join(" ");
5656
+ return full && !full.startsWith("-") ? full : null;
5657
+ }
5624
5658
  async function startCandidates(look, project) {
5625
5659
  const files = new Set(project.files.map((f) => f.path));
5626
5660
  const read = (file) => readFile(join(look.root, file), "utf8").catch(() => null);
@@ -5648,19 +5682,12 @@ async function startCandidates(look, project) {
5648
5682
  from: `the main block at ${file.path}:${(main + 1).toString()}`
5649
5683
  });
5650
5684
  }
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
- }
5685
+ const dockerfile = await read("Dockerfile");
5686
+ const command = dockerfile ? dockerCommand(dockerfile) : null;
5687
+ if (command) found.push({
5688
+ command,
5689
+ from: "the Dockerfile's command"
5690
+ });
5664
5691
  for (const name of [
5665
5692
  "README.md",
5666
5693
  "readme.md",
@@ -6395,15 +6422,36 @@ var LIMITS = {
6395
6422
  maxFileBytes: 1e6
6396
6423
  };
6397
6424
  var CODE_TOOLS = ["grep", "read"];
6398
- /** Every file in the repository the tools could see, skipping what the scan never walks. */
6425
+ var NOT_OPENED = "the scan never opens that folder";
6426
+ /** Whether the scan opens a folder at all: no part of its path is one it never walks. */
6427
+ var opensFolder = (dir) => !dir.split("/").some((part) => SKIPPED.has(part.toLowerCase()));
6428
+ /**
6429
+ * Every file in the repository the tools could see, and so what --explain counts. All of it by hand
6430
+ * rather than by glob, whose `*` leaves out names starting with a dot, which is where settings files
6431
+ * are. A link is not a file here: it could lead outside the repository. ADR 0093.
6432
+ */
6399
6433
  async function repositoryFiles(root, pattern = "**/*", most = 5e3) {
6400
6434
  const found = [];
6435
+ if (pattern === "**/*") {
6436
+ const walk = async (dir) => {
6437
+ const entries = await readdir(join(root, dir), { withFileTypes: true }).catch(() => []);
6438
+ for (const entry of entries) {
6439
+ if (found.length >= most) return;
6440
+ const path = posix.join(dir, entry.name);
6441
+ if (entry.isFile()) found.push(path);
6442
+ else if (entry.isDirectory() && opensFolder(entry.name)) await walk(path);
6443
+ }
6444
+ };
6445
+ await walk("");
6446
+ return found.sort();
6447
+ }
6401
6448
  for await (const entry of glob(pattern, {
6402
6449
  cwd: root,
6403
6450
  exclude: (name) => SKIPPED.has(String(name).split(/[\\/]/).at(-1) ?? "")
6404
6451
  })) {
6405
6452
  const path = String(entry).split("\\").join("/");
6406
- if ((await stat(join(root, path)).catch(() => null))?.isFile()) found.push(path);
6453
+ if (!opensFolder(posix.dirname(path))) continue;
6454
+ if ((await lstat(join(root, path)).catch(() => null))?.isFile()) found.push(path);
6407
6455
  if (found.length >= most) break;
6408
6456
  }
6409
6457
  return found.sort();
@@ -6419,6 +6467,7 @@ function createTools(input) {
6419
6467
  const dir = String(args.dir ?? ".");
6420
6468
  const inRepo = await insideRepository(root, dir);
6421
6469
  if (inRepo === null) return refused(dir, "it is not a folder in this repository");
6470
+ if (!opensFolder(inRepo)) return refused(inRepo, NOT_OPENED);
6422
6471
  const entries = await readdir(join(root, inRepo), { withFileTypes: true }).catch(() => null);
6423
6472
  if (!entries) return refused(dir, "it is not a folder in this repository");
6424
6473
  return {
@@ -6463,6 +6512,7 @@ function createTools(input) {
6463
6512
  if (inRepo === null) return refused(path, "it is not a file in this repository");
6464
6513
  const why = neverRead(inRepo);
6465
6514
  if (why) return refused(inRepo, why);
6515
+ if (!opensFolder(posix.dirname(inRepo))) return refused(inRepo, NOT_OPENED);
6466
6516
  if ((await stat(join(root, inRepo))).size > LIMITS.maxFileBytes) return refused(inRepo, "it is over 1 MB");
6467
6517
  const all = (await readFile(join(root, inRepo), "utf8")).split(/\r?\n/);
6468
6518
  const from = Math.max(1, Number(args.from ?? (args.to === void 0 ? 1 : args.to - LIMITS.readLines + 1)));
@@ -21996,21 +22046,23 @@ function read(result, understood) {
21996
22046
  facts: mergeFacts(result.facts, verified.facts).filter((f) => !(f.kind === "way-in" && f.adapter !== "model" && f.function && acts.has(f.function)))
21997
22047
  };
21998
22048
  }
21999
- // src/commands/scan.ts
22000
- var TOOL_POWERS = [
22001
- "read",
22002
- "read private data",
22003
- "change",
22004
- "send outside",
22005
- "move money",
22006
- "delete"
22007
- ];
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.";
22049
+ // src/several-apps.ts
22009
22050
  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;
22010
- /** Only for telling a repository of several apps from one app: whether any file imports an AI library. */
22051
+ /** Whether a file imports an AI library. */
22011
22052
  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;
22012
- var plural = (n, one, many = `${one}s`) => `${n.toLocaleString("en-GB")} ${n === 1 ? one : many}`;
22013
- /** Folders below the root that are apps of their own. */
22053
+ var MANIFESTS = [
22054
+ "package.json",
22055
+ "pyproject.toml",
22056
+ "requirements.txt"
22057
+ ];
22058
+ async function dependsOnAi(folder) {
22059
+ for (const manifest of MANIFESTS) {
22060
+ const text = await readFile(join(folder, manifest), "utf8").catch(() => "");
22061
+ if (AI_DEPENDENCIES.test(text)) return true;
22062
+ }
22063
+ return false;
22064
+ }
22065
+ /** Folders below the root that are apps of their own. What is inside an app is part of it. */
22014
22066
  async function appsIn(root) {
22015
22067
  const found = [];
22016
22068
  const visit = async (dir, depth) => {
@@ -22023,26 +22075,34 @@ async function appsIn(root) {
22023
22075
  "venv"
22024
22076
  ].includes(entry.name)) continue;
22025
22077
  const full = join(dir, entry.name);
22026
- for (const manifest of [
22027
- "package.json",
22028
- "pyproject.toml",
22029
- "requirements.txt"
22030
- ]) {
22031
- const text = await readFile(join(full, manifest), "utf8").catch(() => "");
22032
- if (AI_DEPENDENCIES.test(text)) {
22033
- found.push(inRepo(root, full));
22034
- break;
22035
- }
22036
- }
22037
- await visit(full, depth + 1);
22078
+ if (await dependsOnAi(full)) found.push(inRepo(root, full));
22079
+ else await visit(full, depth + 1);
22038
22080
  }
22039
22081
  };
22040
22082
  await visit(root, 1);
22041
22083
  return found;
22042
22084
  }
22043
- function looksLikeAnApp(result) {
22085
+ /** Whether any code here, wherever it sits, builds on an AI library. */
22086
+ function usesAi(result) {
22044
22087
  return result.facts.some((f) => f.kind === "tool" || f.kind === "agent") || result.project.files.some((f) => AI_IMPORT.test(f.text));
22045
22088
  }
22089
+ /** Whether the folder is an app itself, beside any apps in its subfolders. */
22090
+ async function rootIsAnApp(root, result, apps) {
22091
+ if (await dependsOnAi(root)) return true;
22092
+ const outside = (path) => !apps.some((app) => path === app || path.startsWith(`${app}/`));
22093
+ return result.facts.some((f) => (f.kind === "tool" || f.kind === "agent") && outside(f.file)) || result.project.files.some((f) => outside(f.path) && AI_IMPORT.test(f.text));
22094
+ }
22095
+ // src/commands/scan.ts
22096
+ var TOOL_POWERS = [
22097
+ "read",
22098
+ "read private data",
22099
+ "change",
22100
+ "send outside",
22101
+ "move money",
22102
+ "delete"
22103
+ ];
22104
+ 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.";
22105
+ var plural = (n, one, many = `${one}s`) => `${n.toLocaleString("en-GB")} ${n === 1 ? one : many}`;
22046
22106
  async function scanCommand(dir, options) {
22047
22107
  const retry = {
22048
22108
  yes: options.yes,
@@ -22062,21 +22122,27 @@ async function scanCommand(dir, options) {
22062
22122
  const result = await scanFolder(dir);
22063
22123
  say(`${bold("ModelOS Secure")} scanning ${result.look.name}\n`);
22064
22124
  step("Look", `${[...result.look.languages, ...result.look.frameworks].join(", ") || "No source found"}, ${plural(result.look.fileCount, "file")}`);
22065
- if (!looksLikeAnApp(result)) {
22066
- const apps = await appsIn(dir);
22067
- if (apps.length > 1) {
22068
- const chosen = options.yes ? [apps[0]] : await ask(`This repository has ${apps.length.toString()} apps with model calls.`, void 0, [...apps.map((app) => ({
22125
+ const apps = await appsIn(dir);
22126
+ if (apps.length > 1 && !await rootIsAnApp(dir, result, apps)) {
22127
+ const several = `This repository has ${apps.length.toString()} apps with model calls.`;
22128
+ if (options.yes) say(`\n ${several} --yes takes the first, ${apps[0]}.\n`);
22129
+ const chosen = options.yes ? [apps[0]] : await ask(several, void 0, [
22130
+ ...apps.map((app) => ({
22069
22131
  label: `Pick ${app}`,
22070
22132
  value: [app]
22071
- })), {
22133
+ })),
22134
+ {
22072
22135
  label: `Scan all ${apps.length.toString()} as separate systems`,
22073
22136
  value: apps
22074
- }]);
22075
- for (const app of chosen) await scanCommand(join(dir, app), options);
22076
- return;
22077
- }
22078
- if (apps.length === 1) return scanCommand(join(dir, apps[0]), options);
22079
- }
22137
+ },
22138
+ {
22139
+ label: "They are one system: scan this whole folder",
22140
+ value: []
22141
+ }
22142
+ ]);
22143
+ for (const app of chosen) await scanCommand(join(dir, app), options);
22144
+ if (chosen.length > 0) return;
22145
+ } else if (apps.length === 1 && !usesAi(result)) return scanCommand(join(dir, apps[0]), options);
22080
22146
  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 ")}` : ""}`);
22081
22147
  const outline = await buildOutline(result.look, result.facts, result.project);
22082
22148
  if (options.explain) {
@@ -22087,6 +22153,7 @@ async function scanCommand(dir, options) {
22087
22153
  say(`\nNever sent, whatever is asked (${neverSent.length.toString()}):`);
22088
22154
  for (const n of neverSent) say(` ${n.path}, because ${n.why}`);
22089
22155
  if (neverSent.length === 0) say(" nothing here matches");
22156
+ say("\nThe scan never opens folders such as .git, node_modules and tests, so nothing in them is listed or read.");
22090
22157
  say(`\nWhat was actually sent on earlier runs is in .modelos-secure/sent.jsonl.`);
22091
22158
  return;
22092
22159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelos-secure",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {