cortad 0.1.11 → 0.1.13
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/lib/keys.mjs +38 -0
- package/lib/start.mjs +22 -5
- package/lib/switches.mjs +47 -0
- package/local.mjs +27 -6
- package/package.json +3 -1
package/lib/keys.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A file that carries a key stays on this machine, whatever git thinks of it: a catalogue that keeps
|
|
2
|
+
// an AES key per video is tracked like code and is no more shareable than a .env.
|
|
3
|
+
// The header alone is code that looks for keys; a key has its body after it.
|
|
4
|
+
const PRIVATE_KEY = /-----BEGIN [A-Z ]*PRIVATE KEY-----(?:\\n|\s)*[A-Za-z0-9+/=]{40,}/;
|
|
5
|
+
const PROVIDER_TOKEN = /\b((?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|[rs]k_live_[0-9A-Za-z]{24,}|glpat-[0-9A-Za-z_-]{20,}))\b/g;
|
|
6
|
+
// A long literal under a key-like name. A name read from the environment is not a literal, so
|
|
7
|
+
// `apiKey: process.env.KEY` never matches.
|
|
8
|
+
const NAMED_LITERAL = /["']?[\w-]*(?:key|secret|token|passw(?:or)?d|aes|iv|salt)["']?\s*[:=]\s*["']([A-Fa-f0-9]{32,}|[A-Za-z0-9+/_-]{32,}={0,2})["']/gi;
|
|
9
|
+
|
|
10
|
+
// A real key is random: digits and letters, many distinct characters. "sk-live-should-never-leak"
|
|
11
|
+
// in a test is a name for a key, not one.
|
|
12
|
+
const random = (s) => /\d/.test(s) && /[A-Za-z]/.test(s) && new Set(s).size >= 10;
|
|
13
|
+
const any = (re, text) => [...text.matchAll(re)].some((m) => random(m[1]));
|
|
14
|
+
|
|
15
|
+
export function holdsKeys(text, values = []) {
|
|
16
|
+
return PRIVATE_KEY.test(text) || any(PROVIDER_TOKEN, text) || any(NAMED_LITERAL, text) || values.some((v) => text.includes(v));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The values worth looking for elsewhere: a variable whose name says it is a secret, from a real env
|
|
20
|
+
// file. An example file's values are placeholders, and a bucket or model name is not a key.
|
|
21
|
+
const SECRET_NAME = /KEY|SECRET|TOKEN|PASSW|PRIVATE|CREDENTIAL/i;
|
|
22
|
+
const EXAMPLE = /\.(example|sample|template|dist)$/i;
|
|
23
|
+
export function secretEnvValues(envFiles, read) {
|
|
24
|
+
const values = [];
|
|
25
|
+
for (const file of envFiles) {
|
|
26
|
+
if (EXAMPLE.test(file)) continue;
|
|
27
|
+
let text = "";
|
|
28
|
+
try { text = read(file); } catch { continue; }
|
|
29
|
+
for (const line of text.split("\n")) {
|
|
30
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
31
|
+
if (!m || !SECRET_NAME.test(m[1])) continue;
|
|
32
|
+
const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
33
|
+
// A value of few distinct characters is a placeholder or a published test key, not a secret.
|
|
34
|
+
if (v.length >= 12 && !/\s/.test(v) && new Set(v).size >= 8) values.push(v);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return values;
|
|
38
|
+
}
|
package/lib/start.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// How their app is started, and from which folder, worked out from the repository itself so nobody
|
|
2
2
|
// is asked. A monorepo root has no app of its own: the app is the workspace that serves the AI.
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
-
import { join, relative } from "node:path";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
5
|
|
|
6
6
|
const read = (file) => { try { return readFileSync(file, "utf8"); } catch { return ""; } };
|
|
7
7
|
const json = (file) => { try { return JSON.parse(read(file)); } catch { return null; } };
|
|
@@ -23,6 +23,18 @@ const SERVES = /uvicorn\.run|FastAPI\(|Flask\(|Starlette\(|Litestar\(|Quart\(|ap
|
|
|
23
23
|
// The same question of a package script: does this command put something on a port, or run a task
|
|
24
24
|
// and exit. `crewai run` and `python main.py` are tasks; `next dev` and `nodemon server.js` serve.
|
|
25
25
|
const SERVES_JS = /\b(?:next|nuxt|vite|nest|remix|astro|sveltekit|serve|nodemon|ts-node-dev|uvicorn|gunicorn|rails|strapi)\b/;
|
|
26
|
+
// Where this workspace's install lives: the nearest folder at or above it holding a lockfile, never
|
|
27
|
+
// past the repository the customer ran the command in.
|
|
28
|
+
const LOCKS = ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "package-lock.json"];
|
|
29
|
+
export function lockRoot(dir, root) {
|
|
30
|
+
const inside = (d) => d === root || d.startsWith(root.endsWith("/") ? root : `${root}/`);
|
|
31
|
+
for (let at = dir; inside(at); at = dirname(at)) {
|
|
32
|
+
if (LOCKS.some((l) => existsSync(join(at, l)))) return at;
|
|
33
|
+
if (at === dirname(at)) break;
|
|
34
|
+
}
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
// The manager this repository was installed with, named by its lockfile.
|
|
27
39
|
const manager = (dir) => (existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm");
|
|
28
40
|
|
|
@@ -121,13 +133,18 @@ export const missingDependency = (said) => MISSING.exec(String(said ?? ""))?.sli
|
|
|
121
133
|
// One install, with the manager the project locked. Never a global one: a Python project without an
|
|
122
134
|
// interpreter of its own gets a virtual environment beside its code, so nothing installed here
|
|
123
135
|
// reaches the rest of the machine.
|
|
124
|
-
export function installPlan(dir, onPath) {
|
|
136
|
+
export function installPlan(dir, onPath, root = dir) {
|
|
125
137
|
if (json(join(dir, "package.json"))) {
|
|
138
|
+
// A workspace member holds no lockfile of its own: the manager, and the install, belong to the
|
|
139
|
+
// repository that owns it. novel's apps/web installed with npm here, and npm cannot read the
|
|
140
|
+
// `workspace:^` ranges its own packages are pinned with: "Unsupported URL Type".
|
|
141
|
+
const at = lockRoot(dir, root);
|
|
126
142
|
// The manager the repository locked, fetched for the job when this machine has not got it: npm
|
|
127
143
|
// refuses vercel's ai-chatbot outright over a peer range pnpm resolves without a word.
|
|
128
|
-
const locked = manager(
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
const locked = manager(at);
|
|
145
|
+
const where = at === dir ? "" : `cd ${JSON.stringify(at)} && `;
|
|
146
|
+
if (locked !== "npm") return `${where}${onPath(locked) ? `${locked} install` : `npx --yes ${locked} install`}`;
|
|
147
|
+
return `${where}${existsSync(join(at, "package-lock.json")) ? "npm ci || npm install --legacy-peer-deps" : "npm install || npm install --legacy-peer-deps"}`;
|
|
131
148
|
}
|
|
132
149
|
if (existsSync(join(dir, "uv.lock")) && onPath("uv")) return "uv sync";
|
|
133
150
|
if (existsSync(join(dir, "poetry.lock")) && onPath("poetry")) return "poetry install";
|
package/lib/switches.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Switches an app reads to decide who it lets in, set for the app this command started and for
|
|
2
|
+
// nothing else. The customer's files are never written, and every name is printed before the app
|
|
3
|
+
// starts. A lockout, an attempt counter and a password rule are never touched.
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
const GUARDED = /(AUTH|LOGIN|PASSWORD|BREAKER|LOCKOUT|ATTEMPT|FAIL|BAN|BLOCK)/;
|
|
7
|
+
const ENV_READ = /(?:process\.env(?:\.|\[\s*['"])|os\.(?:environ\.get|getenv)\(\s*['"]|os\.environ\[\s*['"]|\benv\(\s*['"]|Deno\.env\.get\(\s*['"])([A-Z][A-Z0-9_]*)/g;
|
|
8
|
+
|
|
9
|
+
// A switch their own app reads to decide whether a caller has to be signed in at all. morphic's
|
|
10
|
+
// chat answers 401 to everyone until ENABLE_GUEST_CHAT is true; open-webui, librechat and a dozen
|
|
11
|
+
// others carry the same idea under their own name. Set for the app this command started and for
|
|
12
|
+
// nothing else: their files are not touched, and the name is printed before anything starts.
|
|
13
|
+
// A lockout, an attempt counter and a password rule carry the same words and are never touched.
|
|
14
|
+
const OPENS_AUTH = /^(?:ENABLE|REQUIRE)_AUTH(?:ENTICATION)?$|^AUTH(?:ENTICATION)?_(?:ENABLED|REQUIRED)$|^(?:REQUIRE|ENABLE)_(?:LOGIN|SIGN_?IN)$/;
|
|
15
|
+
const CLOSES_AUTH = /^(?:DISABLE|SKIP|NO)_AUTH(?:ENTICATION)?$|^AUTH(?:ENTICATION)?_DISABLED$/;
|
|
16
|
+
const OPENS_GUEST = /^(?:ENABLE|ALLOW)_(?:GUEST|ANONYMOUS|PUBLIC)(?:_[A-Z0-9_]+)?$|^(?:GUEST|ANONYMOUS|PUBLIC)_(?:MODE|ACCESS|CHAT|ENABLED|LOGIN)$/;
|
|
17
|
+
const CLOSES_GUEST = /^(?:DISABLE|BLOCK)_(?:GUEST|ANONYMOUS|PUBLIC)(?:_[A-Z0-9_]+)?$|^(?:GUEST|ANONYMOUS|PUBLIC)_(?:MODE|ACCESS|CHAT)_DISABLED$/;
|
|
18
|
+
const BOOLEAN = /^(?:true|false|1|0|yes|no|on|off)$/i;
|
|
19
|
+
export function openSwitches(envFiles, sources = []) {
|
|
20
|
+
const out = {};
|
|
21
|
+
const open = (name) => {
|
|
22
|
+
if (GUARDED.test(name) && !OPENS_AUTH.test(name) && !CLOSES_AUTH.test(name)) return null;
|
|
23
|
+
if (OPENS_AUTH.test(name) || CLOSES_GUEST.test(name)) return "false";
|
|
24
|
+
if (CLOSES_AUTH.test(name) || OPENS_GUEST.test(name)) return "true";
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
for (const file of envFiles) {
|
|
28
|
+
let text = "";
|
|
29
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
30
|
+
for (const line of text.split("\n")) {
|
|
31
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
32
|
+
if (!m) continue;
|
|
33
|
+
const value = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
34
|
+
const to = open(m[1]);
|
|
35
|
+
// Only a switch: a name of this shape holding a URL or a key is something else entirely.
|
|
36
|
+
if (to && (value === "" || BOOLEAN.test(value))) out[m[1]] = to;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const file of sources) {
|
|
40
|
+
if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
|
|
41
|
+
let text = "";
|
|
42
|
+
try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
|
|
43
|
+
for (const m of text.matchAll(ENV_READ)) { const to = open(m[1]); if (to && !(m[1] in out)) out[m[1]] = to; }
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
package/local.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// (lib/lock.mjs) and cannot write your code at all. Nothing here touches git. Your environment
|
|
12
12
|
// never leaves this machine. Ctrl-C ends everything.
|
|
13
13
|
|
|
14
|
+
import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
|
|
14
15
|
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
15
16
|
import { createHash } from "node:crypto";
|
|
16
17
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
|
|
@@ -26,6 +27,7 @@ import { sampleHere } from "./lib/sample.mjs";
|
|
|
26
27
|
import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
|
|
27
28
|
import { listingUrl } from "./lib/listing.mjs";
|
|
28
29
|
import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
|
|
30
|
+
import { openSwitches } from "./lib/switches.mjs";
|
|
29
31
|
|
|
30
32
|
const argv = process.argv.slice(2);
|
|
31
33
|
const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
@@ -118,6 +120,11 @@ function shareable(rel) {
|
|
|
118
120
|
try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
|
|
119
121
|
}
|
|
120
122
|
let listed = null;
|
|
123
|
+
// Read the same way for the upload and for any read the engine asks for later.
|
|
124
|
+
let envKeys = [];
|
|
125
|
+
function carriesKey(rel) {
|
|
126
|
+
try { return holdsKeys(readFileSync(join(root, rel), "utf8"), envKeys); } catch { return false; }
|
|
127
|
+
}
|
|
121
128
|
|
|
122
129
|
// Values from your env files, read here and only here, so nothing a command prints can carry one.
|
|
123
130
|
function secretValues(envFiles) {
|
|
@@ -279,7 +286,8 @@ const readable = (p) => {
|
|
|
279
286
|
let landed; try { landed = realpathSync(r); } catch { return null; }
|
|
280
287
|
const home = realpathSync(root);
|
|
281
288
|
if (!landed.startsWith(home + sep) || ENV_FILE.test(basename(landed)) || SECRET_PATH.test(landed)) return null;
|
|
282
|
-
|
|
289
|
+
const rel = relative(home, landed);
|
|
290
|
+
return shareable(rel) && !carriesKey(rel) ? landed : null;
|
|
283
291
|
};
|
|
284
292
|
// A shell line runs with a plain environment: the app's own process reads its .env itself, and
|
|
285
293
|
// nothing a world sends inherits this terminal's keys.
|
|
@@ -510,15 +518,23 @@ async function startApp() {
|
|
|
510
518
|
appDir = plan?.cwd ?? root;
|
|
511
519
|
pinned = pinnedNode();
|
|
512
520
|
if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
|
|
513
|
-
const
|
|
514
|
-
|
|
521
|
+
const sourceFiles = files.map((f) => join(root, f));
|
|
522
|
+
const lifted = { ...liftedLimits(envFiles, sourceFiles), ...openSwitches(envFiles, sourceFiles) };
|
|
523
|
+
const raised = Object.keys(liftedLimits(envFiles, sourceFiles));
|
|
524
|
+
const opened = Object.keys(openSwitches(envFiles, sourceFiles));
|
|
525
|
+
if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
|
|
526
|
+
// Said out loud, because it changes who their app lets in for as long as this command runs.
|
|
527
|
+
if (opened.length) say(`your app's own sign-in switch, for this session only: ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
|
|
515
528
|
launched = { cmd, lifted };
|
|
516
529
|
step(`starting your app: ${cmd}`);
|
|
517
530
|
const up = await launch(180_000);
|
|
518
531
|
if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
|
|
519
532
|
// Already running: a second start dies on the port the first one holds. The one that is running
|
|
520
533
|
// is the app, so it is used as it stands rather than treated as a failure.
|
|
521
|
-
|
|
534
|
+
// "Another next dev server is already running" is the same fact in a framework's own words: their
|
|
535
|
+
// app is up, started from the terminal they were already working in, and the port it holds is not
|
|
536
|
+
// always the one we asked for. Their running app is the app.
|
|
537
|
+
if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)|another .{0,20}(?:dev )?server is already running|already running (?:on|at) (?:http|port)/i.test(up.tail)) {
|
|
522
538
|
const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
|
|
523
539
|
for (const port of new Set(ports)) {
|
|
524
540
|
if (await answers(port)) {
|
|
@@ -621,7 +637,7 @@ let installSaid = "";
|
|
|
621
637
|
async function installOnce(said) {
|
|
622
638
|
if (installed) return false;
|
|
623
639
|
const name = missingDependency(said);
|
|
624
|
-
const cmd = name && installPlan(appDir, onPath);
|
|
640
|
+
const cmd = name && installPlan(appDir, onPath, root);
|
|
625
641
|
if (!cmd) return false;
|
|
626
642
|
installed = true;
|
|
627
643
|
stepDone(`your app needs ${name}, which is not installed here`);
|
|
@@ -737,6 +753,10 @@ const envFiles = [];
|
|
|
737
753
|
const files = [];
|
|
738
754
|
listed = gitListed();
|
|
739
755
|
walk(root, 0, files, envFiles, 0);
|
|
756
|
+
secrets = secretValues(envFiles);
|
|
757
|
+
envKeys = secretEnvValues(envFiles, (f) => readFileSync(f, "utf8"));
|
|
758
|
+
const kept = [];
|
|
759
|
+
for (let i = files.length - 1; i >= 0; i -= 1) if (carriesKey(files[i])) kept.unshift(...files.splice(i, 1));
|
|
740
760
|
// --explain: what this would send and start, from this folder, and nothing else. No network, no
|
|
741
761
|
// app started, nothing written. For the person (or the agent) who reads before running.
|
|
742
762
|
if (explain) {
|
|
@@ -748,6 +768,7 @@ if (explain) {
|
|
|
748
768
|
``,
|
|
749
769
|
`talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
|
|
750
770
|
`would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
|
|
771
|
+
...(kept.length ? [`kept here ${kept.length} file${kept.length === 1 ? " that holds" : "s that hold"} keys: ${kept.slice(0, 6).join(", ")}${kept.length > 6 ? ", ..." : ""}`] : []),
|
|
751
772
|
`not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, data files, node_modules, .git`,
|
|
752
773
|
`env files values read here only: to hide them in replies, to sign in a test account, and to ask your providers what your keys reach. Variable names and whether a switch is on or off go up; no value does`,
|
|
753
774
|
`would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan?.cmd ? `${plan.cmd} (in ${rel(plan.cwd)})` : plan?.noServer ? "nothing: this repository has no server to run" : "asks you how your app starts"}`,
|
|
@@ -760,7 +781,7 @@ if (explain) {
|
|
|
760
781
|
].join("\n"));
|
|
761
782
|
process.exit(0);
|
|
762
783
|
}
|
|
763
|
-
|
|
784
|
+
if (kept.length) say(`kept on this machine, ${kept.length === 1 ? "it holds" : "they hold"} keys: ${kept.join(", ")}`);
|
|
764
785
|
const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
|
|
765
786
|
identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
|
|
766
787
|
// One message sent in their own app tells us the door for certain. The route and the body go up,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"lib/door.mjs",
|
|
12
12
|
"lib/identities.mjs",
|
|
13
13
|
"lib/listing.mjs",
|
|
14
|
+
"lib/keys.mjs",
|
|
14
15
|
"lib/lock.mjs",
|
|
15
16
|
"lib/mint.mjs",
|
|
16
17
|
"lib/pyhook/sitecustomize.py",
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
"lib/sample.mjs",
|
|
19
20
|
"lib/service.mjs",
|
|
20
21
|
"lib/start.mjs",
|
|
22
|
+
"lib/switches.mjs",
|
|
21
23
|
"lib/trace.cjs",
|
|
22
24
|
"local.mjs"
|
|
23
25
|
],
|