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/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
+ }