cortad 0.1.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.
- package/README.md +53 -0
- package/lib/door.mjs +171 -0
- package/lib/identities.mjs +397 -0
- package/lib/lock.mjs +76 -0
- package/lib/mint.mjs +133 -0
- package/lib/pyhook/sitecustomize.py +301 -0
- package/lib/replay.mjs +49 -0
- package/lib/start.mjs +85 -0
- package/lib/trace.cjs +103 -0
- package/local.mjs +620 -0
- package/package.json +29 -0
package/lib/lock.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The lock on the shell a world may run on your machine. Enforced by the operating system, not by
|
|
2
|
+
// reading the command: on a Mac the same Seatbelt sandbox Codex and Claude Code use, on Linux
|
|
3
|
+
// bubblewrap. Inside it a shell line can read your project and run your tests, and it can write
|
|
4
|
+
// only where your own .gitignore already says files are disposable (caches, build output, coverage)
|
|
5
|
+
// and in temp. It cannot write a tracked file, cannot read your keys or environment files, and on a
|
|
6
|
+
// Mac cannot reach the network beyond this machine. With neither tool present there is no shell.
|
|
7
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const real = (p) => { try { return realpathSync(p); } catch { return null; } };
|
|
15
|
+
const q = (p) => `"${p.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
16
|
+
// Dependencies are not disposable even though git ignores them; their caches are.
|
|
17
|
+
const CACHES = [".cache", ".vite", ".vitest", ".bin/.cache"];
|
|
18
|
+
|
|
19
|
+
async function disposableDirs(root) {
|
|
20
|
+
const out = await run("git", ["-C", root, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], { maxBuffer: 16 * 1024 * 1024 })
|
|
21
|
+
.then((r) => r.stdout.split("\n"), () => []);
|
|
22
|
+
const dirs = [];
|
|
23
|
+
for (const line of out) {
|
|
24
|
+
if (!line.endsWith("/")) continue;
|
|
25
|
+
const rel = line.slice(0, -1);
|
|
26
|
+
if (/(^|\/)\.git($|\/)/.test(rel)) continue;
|
|
27
|
+
if (/(^|\/)node_modules$/.test(rel)) { for (const c of CACHES) dirs.push(join(root, rel, c)); continue; }
|
|
28
|
+
dirs.push(join(root, rel));
|
|
29
|
+
if (dirs.length >= 200) break;
|
|
30
|
+
}
|
|
31
|
+
return dirs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SECRET_DIRS = [".ssh", ".aws", ".gnupg", ".config/gh", ".docker", ".kube"];
|
|
35
|
+
const SECRET_FILES = [".netrc", ".npmrc", ".pypirc"];
|
|
36
|
+
|
|
37
|
+
export async function makeLock({ root, work }) {
|
|
38
|
+
const home = homedir();
|
|
39
|
+
// Temp is writable, unless the project itself lives under it: a folder that contains your code is
|
|
40
|
+
// never opened whole, whatever it is called.
|
|
41
|
+
const home0 = real(root) ?? root;
|
|
42
|
+
const holdsProject = (dir) => home0 === dir || home0.startsWith(`${dir}/`);
|
|
43
|
+
const writable = [work, tmpdir(), "/tmp", ...(await disposableDirs(root))].map((p) => real(p) ?? p).filter((dir) => !holdsProject(dir));
|
|
44
|
+
if (process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec")) {
|
|
45
|
+
const profile = [
|
|
46
|
+
"(version 1)(allow default)",
|
|
47
|
+
"(deny network*)",
|
|
48
|
+
// This machine only. Unix sockets stay shut: through one, a request rides out to the internet
|
|
49
|
+
// by name on a system network helper even with every direct connection refused.
|
|
50
|
+
'(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))',
|
|
51
|
+
"(deny file-write*)",
|
|
52
|
+
`(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")} (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/tty") (subpath "/dev/fd"))`,
|
|
53
|
+
`(deny file-read* ${SECRET_DIRS.map((d) => `(subpath ${q(join(home, d))})`).join(" ")} ${SECRET_FILES.map((f) => `(literal ${q(join(home, f))})`).join(" ")} (regex #"/\\.env$") (regex #"/\\.env\\.(local|staging|stage|development|dev|test|production|prod)$"))`,
|
|
54
|
+
].join("");
|
|
55
|
+
return { name: "seatbelt", wrap: (cmd) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", profile, "/bin/sh", "-c", cmd] }) };
|
|
56
|
+
}
|
|
57
|
+
const bwrap = process.platform === "linux" ? ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync) : null;
|
|
58
|
+
if (bwrap) {
|
|
59
|
+
// ponytail: writes and secrets are locked on Linux, the network is not; add a network
|
|
60
|
+
// namespace with a loopback bridge when a customer's threat model asks for it.
|
|
61
|
+
const binds = writable.filter(existsSync).flatMap((p) => ["--bind", p, p]);
|
|
62
|
+
const hidden = [...SECRET_DIRS.map((d) => join(home, d)).filter(existsSync).flatMap((p) => ["--tmpfs", p])];
|
|
63
|
+
return { name: "bubblewrap", wrap: (cmd) => ({ file: bwrap, args: ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", ...binds, ...hidden, "/bin/sh", "-c", cmd] }) };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One line proving the lock holds here, before any world is allowed a shell.
|
|
69
|
+
export function lockHolds(lock, root) {
|
|
70
|
+
const probe = join(root, `.bl-lock-probe-${process.pid}`);
|
|
71
|
+
const { file, args } = lock.wrap(`touch ${JSON.stringify(probe)}`);
|
|
72
|
+
try { execFileSync(file, args, { stdio: "ignore" }); } catch { /* refused, as it must be */ }
|
|
73
|
+
if (!existsSync(probe)) return true;
|
|
74
|
+
try { execFileSync("rm", ["-f", probe]); } catch { /* best effort */ }
|
|
75
|
+
return false;
|
|
76
|
+
}
|
package/lib/mint.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Signing in as the app's own callers, on the machine that holds its secrets. The cloud sends
|
|
2
|
+
// recipes: names of env variables, routes and body fields, never a value and never code. They are
|
|
3
|
+
// gated again here, the values are opened here, and what the app's own door issues is kept in a
|
|
4
|
+
// folder only this user can read and wiped when the command exits. A request that should speak as
|
|
5
|
+
// a role arrives carrying a marker; the real header is put in its place here, so neither a secret
|
|
6
|
+
// nor a token ever leaves this machine.
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { createSign } from "node:crypto";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { identityScript, parseIdentityReport } from "./identities.mjs";
|
|
13
|
+
|
|
14
|
+
const exec = promisify(execFile);
|
|
15
|
+
export const AS_HEADER = "x-cortad-as";
|
|
16
|
+
const ROLE = /^[a-z][a-z0-9_-]{0,30}(?::[a-z][a-z0-9_-]{0,30})?$/;
|
|
17
|
+
const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
|
|
18
|
+
const TEST_UID = "brainsless-test-user";
|
|
19
|
+
|
|
20
|
+
export function readEnv(file) {
|
|
21
|
+
const out = {};
|
|
22
|
+
let text = "";
|
|
23
|
+
try { text = readFileSync(file, "utf8"); } catch { return out; }
|
|
24
|
+
for (const line of text.split("\n")) {
|
|
25
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
26
|
+
if (m) out[m[1]] = m[2].trim().replace(/^(['"])([\s\S]*)\1$/, "$2");
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The admin credential the app itself runs with, under the names projects really use.
|
|
32
|
+
function serviceAccountOf(env) {
|
|
33
|
+
const parse = (s) => { try { const j = JSON.parse(s); return j?.private_key && j?.client_email ? j : null; } catch { return null; } };
|
|
34
|
+
for (const [name, value] of Object.entries(env)) {
|
|
35
|
+
if (!/SERVICE_ACCOUNT|FIREBASE_ADMIN|GOOGLE_CREDENTIALS/.test(name) || !value) continue;
|
|
36
|
+
const found = parse(value) ?? parse(Buffer.from(value, "base64").toString("utf8"));
|
|
37
|
+
if (found) return found;
|
|
38
|
+
}
|
|
39
|
+
if (env.GOOGLE_APPLICATION_CREDENTIALS) { try { const found = parse(readFileSync(env.GOOGLE_APPLICATION_CREDENTIALS, "utf8")); if (found) return found; } catch { /* not a file here */ } }
|
|
40
|
+
return env.FIREBASE_PRIVATE_KEY && env.FIREBASE_CLIENT_EMAIL ? { private_key: env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"), client_email: env.FIREBASE_CLIENT_EMAIL } : null;
|
|
41
|
+
}
|
|
42
|
+
const webApiKeyOf = (envs) => envs.flatMap((env) => Object.entries(env)).find(([name, value]) => /FIREBASE\w*API_KEY$/.test(name) && /^AIza[\w-]{20,}$/.test(value))?.[1] ?? null;
|
|
43
|
+
|
|
44
|
+
// A custom token signed with their own admin credential, exchanged at their own project for the ID
|
|
45
|
+
// token their app verifies. The first exchange creates the test user in that project, by that uid.
|
|
46
|
+
async function firebaseIdToken(account, apiKey) {
|
|
47
|
+
const now = Math.floor(Date.now() / 1000);
|
|
48
|
+
const part = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
49
|
+
const unsigned = `${part({ alg: "RS256", typ: "JWT" })}.${part({ iss: account.client_email, sub: account.client_email, aud: "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit", iat: now, exp: now + 3600, uid: TEST_UID })}`;
|
|
50
|
+
const token = `${unsigned}.${createSign("RSA-SHA256").update(unsigned).sign(account.private_key).toString("base64url")}`;
|
|
51
|
+
const res = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`, {
|
|
52
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token, returnSecureToken: true }), signal: AbortSignal.timeout(20_000),
|
|
53
|
+
});
|
|
54
|
+
const out = await res.json().catch(() => null);
|
|
55
|
+
return res.ok && out?.idToken ? { idToken: out.idToken } : { error: String(out?.error?.message ?? `status ${res.status}`).slice(0, 120) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function makeIdentities({ root, work, envFiles, say, keepSecret, appDir = () => root }) {
|
|
59
|
+
const dir = join(work, "identities");
|
|
60
|
+
const headerFile = (role) => join(dir, `bl-identity-${slug(role)}.header`);
|
|
61
|
+
const firebaseRoles = new Map();
|
|
62
|
+
const envs = () => {
|
|
63
|
+
// The app's own folder first: in a monorepo the root's env file is not the backend's.
|
|
64
|
+
const homes = [appDir(), root];
|
|
65
|
+
const own = homes.flatMap((h) => [envFiles.find((f) => dirname(f) === h && basename(f) === ".env"), envFiles.find((f) => dirname(f) === h)]).find(Boolean) ?? envFiles[0];
|
|
66
|
+
return { path: own ?? join(root, ".env"), all: [process.env, ...envFiles.map(readEnv)] };
|
|
67
|
+
};
|
|
68
|
+
const keep = (role, header, value) => { writeFileSync(headerFile(role), `${header}: ${value}\n`, { mode: 0o600 }); keepSecret(value.replace(/^Bearer\s+/i, "")); };
|
|
69
|
+
|
|
70
|
+
async function firebase(row) {
|
|
71
|
+
const { all } = envs();
|
|
72
|
+
const merged = Object.assign({}, ...[...all].reverse());
|
|
73
|
+
if (merged.FIREBASE_AUTH_EMULATOR_HOST) return row;
|
|
74
|
+
const account = serviceAccountOf(merged);
|
|
75
|
+
const apiKey = webApiKeyOf(all);
|
|
76
|
+
if (!account || !apiKey) return { ...row, status: "absent", note: !account ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
|
|
77
|
+
const got = await firebaseIdToken(account, apiKey).catch((e) => ({ error: String(e?.message ?? e).slice(0, 120) }));
|
|
78
|
+
if (!got.idToken) return { ...row, status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
|
|
79
|
+
keep(row.role, row.header, `Bearer ${got.idToken}`);
|
|
80
|
+
if (!firebaseRoles.has(row.role)) say(`signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)`);
|
|
81
|
+
firebaseRoles.set(row.role, { header: row.header, at: Date.now() });
|
|
82
|
+
return { ...row, status: "minted", door: "your Firebase project", expires: Math.floor(Date.now() / 1000) + 3600 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function mint(body, port) {
|
|
86
|
+
const recipes = Array.isArray(body?.recipes) ? body.recipes.slice(0, 12) : [];
|
|
87
|
+
if (!recipes.length || !port) return { identities: [] };
|
|
88
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
89
|
+
const out = join(dir, "bl-identities.json");
|
|
90
|
+
rmSync(out, { force: true });
|
|
91
|
+
// ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
|
|
92
|
+
// nothing and says so; port the program to this file if that turns out to be common.
|
|
93
|
+
// A Firebase caller with no emulator on this machine is signed in at their real project, below.
|
|
94
|
+
// Handed to the program it waited its whole deadline on an emulator that was never there, and
|
|
95
|
+
// planless's sign-in arrived after the world had given up waiting for it.
|
|
96
|
+
const emulated = Boolean(Object.assign({}, ...[...envs().all].reverse()).FIREBASE_AUTH_EMULATOR_HOST);
|
|
97
|
+
const forProgram = recipes.filter((r) => r?.kind !== "firebase" || emulated);
|
|
98
|
+
const script = identityScript(forProgram, { envPath: envs().path, base: `http://127.0.0.1:${port}`, headers: body?.headers ?? {} }).split("/tmp/bl-identit").join(join(dir, "bl-identit"));
|
|
99
|
+
const file = join(dir, "mint.py");
|
|
100
|
+
writeFileSync(file, script, { mode: 0o600 });
|
|
101
|
+
const ran = forProgram.length ? await exec("python3", [file], { cwd: root, timeout: 115_000, maxBuffer: 1 << 20 }).then(() => true, (e) => e?.code !== "ENOENT") : true;
|
|
102
|
+
rmSync(file, { force: true });
|
|
103
|
+
const report = parseIdentityReport(existsSync(out) ? readFileSync(out, "utf8") : "");
|
|
104
|
+
const known = new Map((report?.identities ?? []).map((r) => [r.role, r]));
|
|
105
|
+
const rows = [];
|
|
106
|
+
for (const recipe of recipes) {
|
|
107
|
+
if (typeof recipe?.role !== "string" || !ROLE.test(recipe.role)) continue;
|
|
108
|
+
const blank = { role: recipe.role, kind: recipe.kind, header: recipe.header, opens: [], door: recipe.header, where: recipe.where };
|
|
109
|
+
const row = known.get(recipe.role) ?? (recipe.kind === "firebase" && !emulated ? { ...blank, status: "upstream" } : { ...blank, status: ran ? "unreachable" : "absent", ...(ran ? {} : { note: "python3 is not installed on this machine" }) });
|
|
110
|
+
rows.push(recipe.kind === "firebase" && row.status !== "minted" ? await firebase(row) : row);
|
|
111
|
+
}
|
|
112
|
+
for (const row of rows) if (row.status === "minted" && existsSync(headerFile(row.role))) keepSecret(readFileSync(headerFile(row.role), "utf8").split(": ").slice(1).join(": ").trim().replace(/^Bearer\s+/i, ""));
|
|
113
|
+
return { identities: rows };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The header a role speaks with, or nothing. A Firebase token lives an hour; it is renewed here
|
|
117
|
+
// when a run outlasts it.
|
|
118
|
+
async function headerFor(role) {
|
|
119
|
+
if (!ROLE.test(role)) return null;
|
|
120
|
+
const fb = firebaseRoles.get(role);
|
|
121
|
+
if (fb && Date.now() - fb.at > 50 * 60_000) await firebase({ role, header: fb.header, kind: "firebase" }).catch(() => null);
|
|
122
|
+
// A token the app's door issued, or a credential that is itself one of their secrets (an admin
|
|
123
|
+
// key). In the sandbox the second never leaves the box; here it never leaves this machine, which
|
|
124
|
+
// is the same promise, because this is where the request is sent from.
|
|
125
|
+
const file = [headerFile(role), headerFile(role).replace(/\.header$/, ".sealed")].find((f) => { try { return statSync(f).isFile(); } catch { return false; } });
|
|
126
|
+
if (!file) return null;
|
|
127
|
+
const line = readFileSync(file, "utf8").split("\n")[0] ?? "";
|
|
128
|
+
const at = line.indexOf(": ");
|
|
129
|
+
return at > 0 ? { name: line.slice(0, at).trim().toLowerCase(), value: line.slice(at + 2).trim() } : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { mint, headerFor };
|
|
133
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Loaded into your Python app by the command that started it (through PYTHONPATH), and only then.
|
|
2
|
+
# The same job as trace.cjs does for Node: it watches for a request to your app during which your
|
|
3
|
+
# app called a model. That request is your AI's door, with the exact body it takes and the sign-in
|
|
4
|
+
# it carried. What it sees is written to a file only you can read, in the command's own folder on
|
|
5
|
+
# this machine. Nothing here talks to a network, and nothing here may ever stop your app starting.
|
|
6
|
+
# ponytail: shadows a sitecustomize of the project's own, which is rare; chain to it if one shows up.
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
_FILE = os.environ.get("CORTAD_TRACE_FILE")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _install():
|
|
13
|
+
import contextvars
|
|
14
|
+
import importlib.abc
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
ctx = contextvars.ContextVar("cortad_request", default=None)
|
|
23
|
+
limit = 65536
|
|
24
|
+
said = set()
|
|
25
|
+
model_host = re.compile(r"(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|openai\.azure\.com|cognitiveservices\.azure\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn)$", re.I)
|
|
26
|
+
model_path = re.compile(r"/(?:chat/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|/invoke(?:-with-response-stream)?$|/api/(?:chat|generate)$", re.I)
|
|
27
|
+
|
|
28
|
+
def write(row):
|
|
29
|
+
try:
|
|
30
|
+
fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
31
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
32
|
+
f.write(json.dumps(row) + "\n")
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def is_model_call(url):
|
|
37
|
+
try:
|
|
38
|
+
parts = urlsplit(str(url))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return False
|
|
41
|
+
host = (parts.hostname or "").lower()
|
|
42
|
+
path = parts.path or ""
|
|
43
|
+
if host.endswith("googleapis.com"):
|
|
44
|
+
return bool(re.search(r"generativelanguage|aiplatform", host)) and bool(model_path.search(path))
|
|
45
|
+
if host.endswith("amazonaws.com"):
|
|
46
|
+
return host.startswith("bedrock")
|
|
47
|
+
return bool(model_host.search(host)) or (bool(model_path.search(path)) and bool(re.search(r"/v\d|/api/", path)))
|
|
48
|
+
|
|
49
|
+
def text(body):
|
|
50
|
+
if body is None:
|
|
51
|
+
return ""
|
|
52
|
+
if isinstance(body, (bytes, bytearray)):
|
|
53
|
+
return bytes(body[:limit]).decode("utf-8", "replace")
|
|
54
|
+
if isinstance(body, str):
|
|
55
|
+
return body[:limit]
|
|
56
|
+
try:
|
|
57
|
+
return json.dumps(body)[:limit]
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
def note(url, body):
|
|
62
|
+
req = ctx.get()
|
|
63
|
+
if not req or req["noted"] or not is_model_call(url):
|
|
64
|
+
return
|
|
65
|
+
req["noted"] = True
|
|
66
|
+
key = req["method"] + " " + req["path"].split("?")[0]
|
|
67
|
+
if key in said:
|
|
68
|
+
return
|
|
69
|
+
said.add(key)
|
|
70
|
+
write({"at": int(time.time() * 1000), "method": req["method"], "path": req["path"], "headers": req["headers"],
|
|
71
|
+
"body": b"".join(req["chunks"]).decode("utf-8", "replace")[:limit], "sent": text(body)})
|
|
72
|
+
|
|
73
|
+
def started(method, path, headers):
|
|
74
|
+
return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False}
|
|
75
|
+
|
|
76
|
+
def keep(req, chunk):
|
|
77
|
+
if chunk and req["size"] < limit:
|
|
78
|
+
req["chunks"].append(bytes(chunk))
|
|
79
|
+
req["size"] += len(chunk)
|
|
80
|
+
|
|
81
|
+
# Inbound, ASGI: FastAPI, Starlette, Django under uvicorn, Quart, Litestar.
|
|
82
|
+
class Asgi:
|
|
83
|
+
def __init__(self, app):
|
|
84
|
+
self.app = app
|
|
85
|
+
|
|
86
|
+
async def __call__(self, scope, receive, send):
|
|
87
|
+
if scope.get("type") != "http" or scope.get("method") in ("GET", "HEAD", "OPTIONS"):
|
|
88
|
+
return await self.app(scope, receive, send)
|
|
89
|
+
query = scope.get("query_string") or b""
|
|
90
|
+
req = started(scope["method"], scope.get("path", "/") + ("?" + query.decode("latin-1") if query else ""),
|
|
91
|
+
{k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers") or []})
|
|
92
|
+
|
|
93
|
+
async def seen():
|
|
94
|
+
message = await receive()
|
|
95
|
+
if message.get("type") == "http.request":
|
|
96
|
+
keep(req, message.get("body") or b"")
|
|
97
|
+
return message
|
|
98
|
+
|
|
99
|
+
token = ctx.set(req)
|
|
100
|
+
try:
|
|
101
|
+
return await self.app(scope, seen, send)
|
|
102
|
+
finally:
|
|
103
|
+
ctx.reset(token)
|
|
104
|
+
|
|
105
|
+
# Inbound, WSGI: Flask, Django's runserver, anything under werkzeug.
|
|
106
|
+
class Tee:
|
|
107
|
+
def __init__(self, stream, req):
|
|
108
|
+
self.stream, self.req = stream, req
|
|
109
|
+
|
|
110
|
+
def read(self, *a):
|
|
111
|
+
chunk = self.stream.read(*a)
|
|
112
|
+
keep(self.req, chunk)
|
|
113
|
+
return chunk
|
|
114
|
+
|
|
115
|
+
def readline(self, *a):
|
|
116
|
+
chunk = self.stream.readline(*a)
|
|
117
|
+
keep(self.req, chunk)
|
|
118
|
+
return chunk
|
|
119
|
+
|
|
120
|
+
def __iter__(self):
|
|
121
|
+
for chunk in self.stream:
|
|
122
|
+
keep(self.req, chunk)
|
|
123
|
+
yield chunk
|
|
124
|
+
|
|
125
|
+
def __getattr__(self, name):
|
|
126
|
+
return getattr(self.stream, name)
|
|
127
|
+
|
|
128
|
+
def inside(req, iterable):
|
|
129
|
+
# A streamed reply is produced after the handler returns: the model call happens here.
|
|
130
|
+
it = iter(iterable)
|
|
131
|
+
try:
|
|
132
|
+
while True:
|
|
133
|
+
token = ctx.set(req)
|
|
134
|
+
try:
|
|
135
|
+
item = next(it)
|
|
136
|
+
except StopIteration:
|
|
137
|
+
return
|
|
138
|
+
finally:
|
|
139
|
+
ctx.reset(token)
|
|
140
|
+
yield item
|
|
141
|
+
finally:
|
|
142
|
+
close = getattr(iterable, "close", None)
|
|
143
|
+
if close:
|
|
144
|
+
close()
|
|
145
|
+
|
|
146
|
+
def wsgi(app):
|
|
147
|
+
if getattr(app, "_brainsless", False):
|
|
148
|
+
return app
|
|
149
|
+
|
|
150
|
+
def wrapped(environ, start_response):
|
|
151
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
152
|
+
if method in ("GET", "HEAD", "OPTIONS"):
|
|
153
|
+
return app(environ, start_response)
|
|
154
|
+
headers = {k[5:].replace("_", "-").lower(): v for k, v in environ.items() if k.startswith("HTTP_")}
|
|
155
|
+
if environ.get("CONTENT_TYPE"):
|
|
156
|
+
headers["content-type"] = environ["CONTENT_TYPE"]
|
|
157
|
+
query = environ.get("QUERY_STRING") or ""
|
|
158
|
+
req = started(method, (environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "/")) + ("?" + query if query else ""), headers)
|
|
159
|
+
if environ.get("wsgi.input") is not None:
|
|
160
|
+
environ["wsgi.input"] = Tee(environ["wsgi.input"], req)
|
|
161
|
+
token = ctx.set(req)
|
|
162
|
+
try:
|
|
163
|
+
return inside(req, app(environ, start_response))
|
|
164
|
+
finally:
|
|
165
|
+
ctx.reset(token)
|
|
166
|
+
|
|
167
|
+
wrapped._brainsless = True
|
|
168
|
+
return wrapped
|
|
169
|
+
|
|
170
|
+
# What is patched, once the module it lives in has been imported by the app itself.
|
|
171
|
+
def patch_uvicorn(module):
|
|
172
|
+
load = module.Config.load
|
|
173
|
+
|
|
174
|
+
def loaded(self, *a, **k):
|
|
175
|
+
out = load(self, *a, **k)
|
|
176
|
+
if not isinstance(self.loaded_app, Asgi):
|
|
177
|
+
self.loaded_app = Asgi(self.loaded_app)
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
module.Config.load = loaded
|
|
181
|
+
|
|
182
|
+
def patch_werkzeug(module):
|
|
183
|
+
run_simple = module.run_simple
|
|
184
|
+
|
|
185
|
+
def run(hostname, port, application, *a, **k):
|
|
186
|
+
return run_simple(hostname, port, wsgi(application), *a, **k)
|
|
187
|
+
|
|
188
|
+
module.run_simple = run
|
|
189
|
+
|
|
190
|
+
def patch_django(module):
|
|
191
|
+
call = module.WSGIHandler.__call__
|
|
192
|
+
|
|
193
|
+
def called(self, environ, start_response):
|
|
194
|
+
return wsgi(lambda e, s: call(self, e, s))(environ, start_response)
|
|
195
|
+
|
|
196
|
+
module.WSGIHandler.__call__ = called
|
|
197
|
+
|
|
198
|
+
def patch_httpx(module):
|
|
199
|
+
for cls in (module.Client, module.AsyncClient):
|
|
200
|
+
send = cls.send
|
|
201
|
+
if cls is module.Client:
|
|
202
|
+
def sync_send(self, request, *a, _send=send, **k):
|
|
203
|
+
try:
|
|
204
|
+
note(request.url, request.content)
|
|
205
|
+
except Exception:
|
|
206
|
+
try:
|
|
207
|
+
note(request.url, None)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
return _send(self, request, *a, **k)
|
|
211
|
+
cls.send = sync_send
|
|
212
|
+
else:
|
|
213
|
+
async def async_send(self, request, *a, _send=send, **k):
|
|
214
|
+
try:
|
|
215
|
+
note(request.url, request.content)
|
|
216
|
+
except Exception:
|
|
217
|
+
try:
|
|
218
|
+
note(request.url, None)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
return await _send(self, request, *a, **k)
|
|
222
|
+
cls.send = async_send
|
|
223
|
+
|
|
224
|
+
def patch_requests(module):
|
|
225
|
+
send = module.Session.send
|
|
226
|
+
|
|
227
|
+
def sent(self, request, *a, **k):
|
|
228
|
+
try:
|
|
229
|
+
note(request.url, request.body)
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
return send(self, request, *a, **k)
|
|
233
|
+
|
|
234
|
+
module.Session.send = sent
|
|
235
|
+
|
|
236
|
+
def patch_aiohttp(module):
|
|
237
|
+
request = module.ClientSession._request
|
|
238
|
+
|
|
239
|
+
async def requested(self, method, str_or_url, *a, **k):
|
|
240
|
+
try:
|
|
241
|
+
note(str_or_url, k.get("json") if k.get("json") is not None else k.get("data"))
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
return await request(self, method, str_or_url, *a, **k)
|
|
245
|
+
|
|
246
|
+
module.ClientSession._request = requested
|
|
247
|
+
|
|
248
|
+
exact = {"uvicorn.config": patch_uvicorn, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
|
|
249
|
+
"requests.sessions": patch_requests, "aiohttp.client": patch_aiohttp}
|
|
250
|
+
# By family, not by name: the OpenAI SDK moved to a renamed copy of httpx (httpx2), and a patch
|
|
251
|
+
# keyed on "httpx" alone saw none of its calls.
|
|
252
|
+
families = [(re.compile(r"httpx\d*$"), patch_httpx)]
|
|
253
|
+
|
|
254
|
+
def patch_for(name):
|
|
255
|
+
if name in exact:
|
|
256
|
+
return exact[name]
|
|
257
|
+
for pattern, patch in families:
|
|
258
|
+
if pattern.match(name):
|
|
259
|
+
return patch
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
busy = set()
|
|
263
|
+
|
|
264
|
+
class Finder(importlib.abc.MetaPathFinder):
|
|
265
|
+
def find_spec(self, name, path=None, target=None):
|
|
266
|
+
patch = patch_for(name)
|
|
267
|
+
if patch is None or name in busy:
|
|
268
|
+
return None
|
|
269
|
+
busy.add(name)
|
|
270
|
+
try:
|
|
271
|
+
spec = importlib.util.find_spec(name)
|
|
272
|
+
except Exception:
|
|
273
|
+
spec = None
|
|
274
|
+
finally:
|
|
275
|
+
busy.discard(name)
|
|
276
|
+
if spec is None or spec.loader is None or not hasattr(spec.loader, "exec_module"):
|
|
277
|
+
return None
|
|
278
|
+
run = spec.loader.exec_module
|
|
279
|
+
|
|
280
|
+
def exec_module(module, _run=run, _patch=patch):
|
|
281
|
+
_run(module)
|
|
282
|
+
try:
|
|
283
|
+
_patch(module)
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
spec.loader.exec_module = exec_module
|
|
289
|
+
except Exception:
|
|
290
|
+
return None
|
|
291
|
+
return spec
|
|
292
|
+
|
|
293
|
+
sys.meta_path.insert(0, Finder())
|
|
294
|
+
write({"hello": "python", "pid": os.getpid()})
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if _FILE:
|
|
298
|
+
try:
|
|
299
|
+
_install()
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
package/lib/replay.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The other half of lib/trace.cjs: the command's side. It hands the hook to the app it starts,
|
|
2
|
+
// reads what the hook wrote, keeps the sign-in that request carried on this machine, and tells the
|
|
3
|
+
// cloud only what it needs to ask again: the route, the method and the body.
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const HOOK = join(HERE, "trace.cjs");
|
|
10
|
+
const PYHOOK = join(HERE, "pyhook");
|
|
11
|
+
export const CAPTURED = "captured";
|
|
12
|
+
// Never replayed: they describe one connection, not the caller.
|
|
13
|
+
const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as)$/i;
|
|
14
|
+
|
|
15
|
+
export function makeCapture({ work, keepSecret, onDoor }) {
|
|
16
|
+
const file = join(work, "trace.jsonl");
|
|
17
|
+
let read = 0;
|
|
18
|
+
let held = null;
|
|
19
|
+
let alive = false;
|
|
20
|
+
// Both hooks are handed to whatever is started: a Node app loads the first, a Python app the
|
|
21
|
+
// second, and each ignores the other's variable.
|
|
22
|
+
// ponytail: Node and Python. Go, Ruby, Java and PHP apps are asked for their route on the screen.
|
|
23
|
+
const env = (base) => ({
|
|
24
|
+
CORTAD_TRACE_FILE: file,
|
|
25
|
+
NODE_OPTIONS: `${base.NODE_OPTIONS ?? ""} --require ${JSON.stringify(HOOK)}`.trim(),
|
|
26
|
+
PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function poll() {
|
|
30
|
+
let size = 0;
|
|
31
|
+
try { size = statSync(file).size; } catch { return; }
|
|
32
|
+
if (size <= read) return;
|
|
33
|
+
const fresh = readFileSync(file, "utf8").slice(read);
|
|
34
|
+
read = size;
|
|
35
|
+
for (const line of fresh.split("\n").filter(Boolean)) {
|
|
36
|
+
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
37
|
+
if (row.hello) { alive = true; continue; }
|
|
38
|
+
let body; try { body = JSON.parse(row.body); } catch { continue; }
|
|
39
|
+
if (!body || typeof body !== "object" || typeof row.path !== "string" || !row.path.startsWith("/")) continue;
|
|
40
|
+
const headers = Object.fromEntries(Object.entries(row.headers ?? {}).filter(([k, v]) => !HOP.test(k) && typeof v === "string"));
|
|
41
|
+
for (const [k, v] of Object.entries(headers)) if (/authorization|cookie|token|secret|session|csrf|api-?key/i.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
|
|
42
|
+
held = { headers, at: Date.now() };
|
|
43
|
+
onDoor({ method: String(row.method || "POST").toUpperCase(), path: row.path, body, headerNames: Object.keys(headers).sort() });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const timer = setInterval(poll, 700);
|
|
47
|
+
timer.unref();
|
|
48
|
+
return { env, headers: () => held?.headers ?? null, alive: () => { poll(); return alive; } };
|
|
49
|
+
}
|
package/lib/start.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// How their app is started, and from which folder, worked out from the repository itself so nobody
|
|
2
|
+
// is asked. A monorepo root has no app of its own: the app is the workspace that serves the AI.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
const read = (file) => { try { return readFileSync(file, "utf8"); } catch { return ""; } };
|
|
7
|
+
const json = (file) => { try { return JSON.parse(read(file)); } catch { return null; } };
|
|
8
|
+
const MODEL_SDK = /^(?:openai|ai|@ai-sdk\/.+|@anthropic-ai\/sdk|langchain|@langchain\/.+|llamaindex|@google\/generative-ai|@google\/genai|groq-sdk|@mistralai\/.+|cohere-ai|ollama|replicate|together-ai)$/;
|
|
9
|
+
const SERVER = /^(?:express|fastify|hono|koa|@nestjs\/core|@strapi\/strapi|next|nuxt|@remix-run\/.+|@sveltejs\/kit|@hapi\/hapi|restify|elysia)$/;
|
|
10
|
+
const PY_MODEL = /^\s*["']?(?:openai|anthropic|langchain|langgraph|litellm|llama[-_]index|google-generativeai|google-genai|groq|cohere|mistralai|ollama|fireworks-ai|together)\b/im;
|
|
11
|
+
const PY_SERVER = /^\s*["']?(?:fastapi|flask|django|starlette|quart|litestar|sanic|aiohttp|uvicorn|gunicorn)\b/im;
|
|
12
|
+
const ENTRIES = ["main.py", "app.py", "server.py", "run.py", "api.py", "wsgi.py", "asgi.py", "src/main.py", "app/main.py", "src/app.py", "backend/main.py", "api/main.py"];
|
|
13
|
+
|
|
14
|
+
function nodeStart(dir, onPath) {
|
|
15
|
+
const pkg = json(join(dir, "package.json"));
|
|
16
|
+
if (!pkg) return null;
|
|
17
|
+
const script = ["dev", "develop", "start:dev", "serve", "start"].find((s) => pkg.scripts?.[s]);
|
|
18
|
+
if (!script) return null;
|
|
19
|
+
const locked = 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";
|
|
20
|
+
// A lockfile names the manager the repository was installed with, not one this machine has.
|
|
21
|
+
return `${onPath(locked) ? locked : "npm"} run ${script}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pythonStart(dir, onPath) {
|
|
25
|
+
if (!["requirements.txt", "pyproject.toml", "manage.py", "Pipfile", "uv.lock"].some((f) => existsSync(join(dir, f)))) return null;
|
|
26
|
+
const venv = [".venv/bin/python", "venv/bin/python", "env/bin/python"].map((p) => join(dir, p)).find(existsSync);
|
|
27
|
+
const py = venv ? JSON.stringify(venv) : existsSync(join(dir, "uv.lock")) && onPath("uv") ? "uv run python" : existsSync(join(dir, "poetry.lock")) && onPath("poetry") ? "poetry run python" : "python3";
|
|
28
|
+
if (existsSync(join(dir, "manage.py"))) return `${py} manage.py runserver`;
|
|
29
|
+
for (const entry of ENTRIES) {
|
|
30
|
+
const text = read(join(dir, entry));
|
|
31
|
+
if (!text) continue;
|
|
32
|
+
// Their own way of starting it carries their own port and settings.
|
|
33
|
+
if (/if\s+__name__\s*==\s*["']__main__["']/.test(text)) return `${py} ${entry}`;
|
|
34
|
+
const module = entry.replace(/\.py$/, "").split("/").join(".");
|
|
35
|
+
const fast = /^(\w+)\s*=\s*(?:FastAPI|Starlette|Litestar|Quart)\(/m.exec(text);
|
|
36
|
+
if (fast) return `${py} -m uvicorn ${module}:${fast[1]} --host 127.0.0.1 --port 8000`;
|
|
37
|
+
const flask = /^(\w+)\s*=\s*Flask\(/m.exec(text);
|
|
38
|
+
if (flask) return `${py} -m flask --app ${module} run --port 5000`;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const startOf = (dir, onPath) => nodeStart(dir, onPath) ?? pythonStart(dir, onPath);
|
|
44
|
+
|
|
45
|
+
// How much a folder looks like the thing that serves the AI.
|
|
46
|
+
function weight(dir, name) {
|
|
47
|
+
const pkg = json(join(dir, "package.json"));
|
|
48
|
+
const deps = Object.keys({ ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) });
|
|
49
|
+
const py = `${read(join(dir, "requirements.txt"))}\n${read(join(dir, "pyproject.toml"))}`;
|
|
50
|
+
const model = deps.some((d) => MODEL_SDK.test(d)) || PY_MODEL.test(py);
|
|
51
|
+
const server = deps.some((d) => SERVER.test(d)) || PY_SERVER.test(py);
|
|
52
|
+
return (model ? 4 : 0) + (server ? 2 : 0) + (/back|api|server|service/i.test(name) ? 1 : 0) - (!model && /front|web|client|ui|mobile|docs|site|landing|admin/i.test(name) ? 3 : 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SKIP = /^(?:node_modules|\.git|dist|build|\.next|coverage|\.venv|venv|__pycache__|\.turbo|\.cache)$/;
|
|
56
|
+
function workspaces(root) {
|
|
57
|
+
const found = [];
|
|
58
|
+
const visit = (dir, depth) => {
|
|
59
|
+
let names = [];
|
|
60
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
if (SKIP.test(name) || name.startsWith(".")) continue;
|
|
63
|
+
const at = join(dir, name);
|
|
64
|
+
try { if (!statSync(at).isDirectory()) continue; } catch { continue; }
|
|
65
|
+
if (["package.json", "requirements.txt", "pyproject.toml", "manage.py"].some((f) => existsSync(join(at, f)))) found.push(at);
|
|
66
|
+
else if (depth < 2) visit(at, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
visit(root, 1);
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function startPlan({ root, typed, onPath }) {
|
|
74
|
+
if (typed) return { cmd: typed, cwd: root };
|
|
75
|
+
const pkg = json(join(root, "package.json"));
|
|
76
|
+
const mono = Boolean(pkg?.workspaces) || existsSync(join(root, "pnpm-workspace.yaml")) || existsSync(join(root, "turbo.json")) || existsSync(join(root, "lerna.json"));
|
|
77
|
+
const own = mono ? null : startOf(root, onPath);
|
|
78
|
+
if (own) return { cmd: own, cwd: root };
|
|
79
|
+
const ranked = workspaces(root).map((dir) => ({ dir, cmd: startOf(dir, onPath), weight: weight(dir, relative(root, dir)) })).filter((w) => w.cmd).sort((a, b) => b.weight - a.weight);
|
|
80
|
+
const best = ranked[0];
|
|
81
|
+
if (best && best.weight > 0 && (ranked.length === 1 || best.weight > ranked[1].weight)) return { cmd: best.cmd, cwd: best.dir, within: relative(root, best.dir) };
|
|
82
|
+
// A root that does have a start of its own after all (a monorepo whose root script runs everything).
|
|
83
|
+
const rootStart = startOf(root, onPath);
|
|
84
|
+
return rootStart ? { cmd: rootStart, cwd: root } : best ? { cmd: best.cmd, cwd: best.dir, within: relative(root, best.dir), unsure: true } : null;
|
|
85
|
+
}
|