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.
- package/cli.js +123 -56
- 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.
|
|
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
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
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
|
-
|
|
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 ((
|
|
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/
|
|
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
|
-
/**
|
|
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
|
|
22013
|
-
|
|
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
|
-
|
|
22027
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22066
|
-
|
|
22067
|
-
|
|
22068
|
-
|
|
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
|
-
|
|
22076
|
-
|
|
22077
|
-
|
|
22078
|
-
|
|
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
|
}
|