cortad 0.1.12 → 0.1.14

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 (3) hide show
  1. package/lib/keys.mjs +38 -0
  2. package/local.mjs +17 -2
  3. package/package.json +2 -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/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";
@@ -119,6 +120,11 @@ function shareable(rel) {
119
120
  try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
120
121
  }
121
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
+ }
122
128
 
123
129
  // Values from your env files, read here and only here, so nothing a command prints can carry one.
124
130
  function secretValues(envFiles) {
@@ -280,7 +286,8 @@ const readable = (p) => {
280
286
  let landed; try { landed = realpathSync(r); } catch { return null; }
281
287
  const home = realpathSync(root);
282
288
  if (!landed.startsWith(home + sep) || ENV_FILE.test(basename(landed)) || SECRET_PATH.test(landed)) return null;
283
- return shareable(relative(home, landed)) ? landed : null;
289
+ const rel = relative(home, landed);
290
+ return shareable(rel) && !carriesKey(rel) ? landed : null;
284
291
  };
285
292
  // A shell line runs with a plain environment: the app's own process reads its .env itself, and
286
293
  // nothing a world sends inherits this terminal's keys.
@@ -746,9 +753,15 @@ const envFiles = [];
746
753
  const files = [];
747
754
  listed = gitListed();
748
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));
749
760
  // --explain: what this would send and start, from this folder, and nothing else. No network, no
750
761
  // app started, nothing written. For the person (or the agent) who reads before running.
751
762
  if (explain) {
763
+ const going = new Set([...files, ...kept]);
764
+ const left = listed ? [...listed].filter((f) => !going.has(f) && !ENV_FILE.test(basename(f))).sort() : [];
752
765
  const bytes = files.reduce((n, f) => { try { return n + statSync(join(root, f)).size; } catch { return n; } }, 0);
753
766
  const plan = startPlan({ root, typed: flag("--start"), onPath });
754
767
  const rel = (f) => relative(root, f) || ".";
@@ -757,6 +770,8 @@ if (explain) {
757
770
  ``,
758
771
  `talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
759
772
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
773
+ ...(kept.length ? [`kept here ${kept.length} file${kept.length === 1 ? " that holds" : "s that hold"} keys: ${kept.slice(0, 6).join(", ")}${kept.length > 6 ? ", ..." : ""}`] : []),
774
+ ...(left.length ? [`also not sent ${left.length} tracked data, media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
760
775
  `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`,
761
776
  `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`,
762
777
  `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"}`,
@@ -769,7 +784,7 @@ if (explain) {
769
784
  ].join("\n"));
770
785
  process.exit(0);
771
786
  }
772
- secrets = secretValues(envFiles);
787
+ if (kept.length) say(`kept on this machine, ${kept.length === 1 ? "it holds" : "they hold"} keys: ${kept.join(", ")}`);
773
788
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
774
789
  identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
775
790
  // 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.12",
3
+ "version": "0.1.14",
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",