cortad 0.1.0 → 0.1.2

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 CHANGED
@@ -1,45 +1,42 @@
1
1
  # cortad
2
2
 
3
- Runs Cortad test conversations against the app on your machine. Your env stays on your machine.
3
+ Connects the AI app on your machine to Cortad, so Cortad can run test conversations against it.
4
4
 
5
5
  ```
6
6
  npx cortad <code>
7
7
  ```
8
8
 
9
- Run it in your app's folder. The code comes from the connect screen at cortad.com. Nothing is installed. Ctrl-C disconnects.
9
+ Run it in your app's folder with the code from cortad.com. Nothing is installed. Ctrl-C disconnects.
10
10
 
11
- ## Behavior
11
+ ## What it does
12
12
 
13
- - Starts your app with its dev script. If it is already running, uses it.
14
- - Finds the route your AI answers on. Sends requests to that port on `localhost`, nowhere else.
15
- - If it cannot find the route, the screen asks you to send one message in your app. It reads the route and body from that request.
16
- - Restarts your app when it stops and you save a fix.
17
- - The Cortad agent edits your files in place. Every edit can be undone from the screen. Git is never touched: no commit, stage, stash or push.
13
+ - Starts your app with its dev script, or uses it if it is already running.
14
+ - Sends Cortad's test conversations to your app on localhost and returns the replies.
15
+ - Signs in as a test account when your app needs one: an account made for the session, never an existing user and never an admin.
16
+ - Lets the Cortad agent edit files in your project. Every edit can be undone from the browser. Git is left alone.
18
17
 
19
- ## Sent to cortad.com
18
+ ## What Cortad receives
20
19
 
21
- - Source files, once. Not `.env*`, key files, `node_modules` or `.git`.
22
- - Your app's replies to the test requests. Values from your env files are masked first.
20
+ - The files git would commit, once. Nothing git ignores, and no env, key or data files.
21
+ - Your app's replies to the test conversations, with values from your env files hidden and cookies removed.
23
22
 
24
- ## Never sent
23
+ ## What stays on your machine
25
24
 
26
- - Env values.
27
- - Tokens, cookies, API keys. Signed-in requests get their token attached on your machine.
25
+ - Your env files and their values.
26
+ - Tokens and cookies. A signed-in test request gets its token added here.
28
27
 
29
- ## Never done
28
+ ## The agent's shell
30
29
 
31
- - Writes to `.env*`, key files, `.git`, `node_modules`.
32
- - Requests to any port but your app's.
33
- - Agent shell access to the network or to files outside temp and git-ignored build folders.
30
+ Commands the agent runs are confined by the operating system (Seatbelt on macOS, bubblewrap on Linux). They can read your project and your toolchains, write only to temp and build folders, and reach only localhost.
34
31
 
35
32
  ## Loaded into your app
36
33
 
37
- When cortad starts your app it preloads one file: `lib/trace.cjs` for Node (`NODE_OPTIONS=--require`), `lib/pyhook/sitecustomize.py` for Python (`PYTHONPATH`). It records the one request during which your app called a model, to a file in cortad's temp folder, mode 0600. It makes no network calls. Read it; it is short.
34
+ When cortad starts your app it preloads one short file, `lib/trace.cjs` for Node or `lib/pyhook/sitecustomize.py` for Python. It notes which request called a model, so Cortad finds your chat route. It writes only to cortad's temp folder.
38
35
 
39
36
  ## Flags
40
37
 
41
38
  ```
42
- --explain print what would be sent and started, then exit; no network
39
+ --explain show what would be sent and started, then exit
43
40
  --port 3000 use an app that is already running
44
41
  --start "make dev" how your app starts
45
42
  --verbose print your app's output
@@ -50,4 +47,4 @@ When cortad starts your app it preloads one file: `lib/trace.cjs` for Node (`NOD
50
47
  - `~/.cortad/checkpoints/` undo data for agent edits
51
48
  - `$TMPDIR/cortad-<pid>/` removed on exit
52
49
 
53
- macOS, Linux. Node 20+.
50
+ macOS and Linux, Node 20+. No dependencies, no install scripts.
@@ -68,6 +68,7 @@ PLAN = json.loads(base64.b64decode("__PLAN__").decode("utf-8"))
68
68
  OUT = "__OUT__"
69
69
  FILE = "__FILE__"
70
70
  TTL = 6 * 3600
71
+ TEST = "cortad-test-user"
71
72
  # A dev server compiles its auth route on the first request: better-chatbot's sign-in timed out at 10s
72
73
  # and the caller was filed unreachable. The whole program stays inside the step's two minutes.
73
74
  DEADLINE = time.time() + 100
@@ -98,10 +99,10 @@ def slug(role):
98
99
  def gen(role):
99
100
  tag = secrets.token_hex(4)
100
101
  return {
101
- "username": "bl-%s-%s" % (slug(role), tag),
102
- "email": "bl-%s-%s@example.invalid" % (slug(role), tag),
102
+ "username": "cortad-%s-%s" % (slug(role), tag),
103
+ "email": "cortad-%s-%s@example.invalid" % (slug(role), tag),
103
104
  "password": secrets.token_urlsafe(18) + "A1!",
104
- "name": "Brainsless %s" % role,
105
+ "name": "Cortad test %s" % role,
105
106
  }
106
107
 
107
108
 
@@ -237,12 +238,13 @@ def jwt(recipe, env):
237
238
  if not secret:
238
239
  return {"status": "absent", "note": " or ".join(recipe["env"])}
239
240
  now = int(time.time())
240
- # ponytail: subject 1, the first account of a restored copy; read a real id off the stand-in
241
- # database when a customer's users table does not start there.
242
- claims = {"sub": "1", "id": 1, "iat": now, "exp": now + TTL}
241
+ # On their own machine the database is theirs and live: the token names a test id that belongs
242
+ # to nobody, never account 1, which is usually the person who built the app. (The sandbox copy
243
+ # of this program signs account 1 of a restored copy; this one differs on purpose.)
244
+ claims = {"sub": TEST, "id": TEST, "iat": now, "exp": now + TTL}
243
245
  claim = recipe.get("claim") or "sub"
244
246
  if claim not in claims:
245
- claims[claim] = 1
247
+ claims[claim] = TEST
246
248
  head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
247
249
  body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
248
250
  sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
package/lib/lock.mjs CHANGED
@@ -15,22 +15,31 @@ const real = (p) => { try { return realpathSync(p); } catch { return null; } };
15
15
  const q = (p) => `"${p.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
16
16
  // Dependencies are not disposable even though git ignores them; their caches are.
17
17
  const CACHES = [".cache", ".vite", ".vitest", ".bin/.cache"];
18
+ // What git ignores splits in two: build output and dependency caches a test may rewrite, and
19
+ // everything else, which is where a project keeps what it never commits (keys, exports, notes).
20
+ const BUILT = /(^|\/)(dist|build|out|coverage|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.venv|venv|\.parcel-cache|\.svelte-kit|\.output|\.wrangler|\.terraform|\.vite|\.vitest)$/;
18
21
 
19
- async function disposableDirs(root) {
22
+ async function ignoredPaths(root) {
20
23
  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 = [];
24
+ .then((r) => r.stdout.split("\n").filter(Boolean), () => []);
25
+ const writable = [];
26
+ const hidden = [];
23
27
  for (const line of out) {
24
- if (!line.endsWith("/")) continue;
25
- const rel = line.slice(0, -1);
28
+ const dir = line.endsWith("/");
29
+ const rel = dir ? line.slice(0, -1) : line;
26
30
  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;
31
+ if (/(^|\/)node_modules$/.test(rel)) { for (const c of CACHES) writable.push(join(root, rel, c)); continue; }
32
+ if (dir && BUILT.test(rel)) { if (writable.length < 200) writable.push(join(root, rel)); continue; }
33
+ if (/(^|\/)\.env(\..*)?$/.test(rel)) continue;
34
+ if (hidden.length < 400) hidden.push({ path: join(root, rel), dir });
30
35
  }
31
- return dirs;
36
+ return { writable, hidden };
32
37
  }
33
38
 
39
+ // Where a shell line looks for its tools inside your home folder. The rest of home is not read.
40
+ const TOOLCHAINS = [".nvm", ".npm", ".node-gyp", ".pnpm-store", ".yarn", ".bun", ".deno", ".volta", ".asdf", ".cache", ".local",
41
+ ".pyenv", ".rbenv", ".gem", ".cargo", ".rustup", "go", ".gradle", ".m2", "Library/pnpm", "Library/Caches", ".gitconfig", ".config/git"];
42
+
34
43
  const SECRET_DIRS = [".ssh", ".aws", ".gnupg", ".config/gh", ".docker", ".kube"];
35
44
  const SECRET_FILES = [".netrc", ".npmrc", ".pypirc"];
36
45
 
@@ -40,17 +49,28 @@ export async function makeLock({ root, work }) {
40
49
  // never opened whole, whatever it is called.
41
50
  const home0 = real(root) ?? root;
42
51
  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));
52
+ const ignored = await ignoredPaths(root);
53
+ const writable = [work, tmpdir(), "/tmp", ...ignored.writable].map((p) => real(p) ?? p).filter((dir) => !holdsProject(dir));
54
+ const project = real(root) ?? root;
55
+ const tools = TOOLCHAINS.map((t) => join(home, t)).filter(existsSync).map((p) => real(p) ?? p);
44
56
  if (process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec")) {
57
+ // Later rules win: home is shut, the project and the toolchains are opened again inside it, and
58
+ // what the project ignores and your keys are shut last.
59
+ const hide = ignored.hidden.map(({ path, dir }) => `(${dir ? "subpath" : "literal"} ${q(real(path) ?? path)})`);
45
60
  const profile = [
46
61
  "(version 1)(allow default)",
62
+ `(deny file-read-data (subpath ${q(real(home) ?? home)}))`,
63
+ `(allow file-read-data (subpath ${q(project)}) ${[work, ...tools].map((p) => `(subpath ${q(p)})`).join(" ")})`,
64
+ ...(hide.length ? [`(deny file-read-data file-write* ${hide.join(" ")})`] : []),
47
65
  "(deny network*)",
48
66
  // This machine only. Unix sockets stay shut: through one, a request rides out to the internet
49
67
  // by name on a system network helper even with every direct connection refused.
50
68
  '(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))',
51
69
  "(deny file-write*)",
52
70
  `(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)$"))`,
71
+ // The same operation the project is opened with: a rule naming file-read-data outranks one naming
72
+ // file-read*, so a wildcard deny here let every .env be read again.
73
+ `(deny file-read* file-read-data ${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
74
  ].join("");
55
75
  return { name: "seatbelt", wrap: (cmd) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", profile, "/bin/sh", "-c", cmd] }) };
56
76
  }
@@ -58,9 +78,12 @@ export async function makeLock({ root, work }) {
58
78
  if (bwrap) {
59
79
  // ponytail: writes and secrets are locked on Linux, the network is not; add a network
60
80
  // namespace with a loopback bridge when a customer's threat model asks for it.
81
+ // Home is an empty tmpfs; the project and the toolchains are bound back into it, then the
82
+ // project's ignored files are covered and the writable folders opened.
83
+ const back = [project, ...tools].filter(existsSync).flatMap((p) => ["--ro-bind", p, p]);
61
84
  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] }) };
85
+ const covered = ignored.hidden.filter(({ path }) => existsSync(path)).flatMap(({ path, dir }) => (dir ? ["--tmpfs", path] : ["--ro-bind", "/dev/null", path]));
86
+ return { name: "bubblewrap", wrap: (cmd) => ({ file: bwrap, args: ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", home, ...back, ...covered, ...binds, "/bin/sh", "-c", cmd] }) };
64
87
  }
65
88
  return null;
66
89
  }
package/lib/mint.mjs CHANGED
@@ -17,6 +17,38 @@ const ROLE = /^[a-z][a-z0-9_-]{0,30}(?::[a-z][a-z0-9_-]{0,30})?$/;
17
17
  const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
18
18
  const TEST_UID = "brainsless-test-user";
19
19
 
20
+ // Who this machine signs in as, whatever a recipe asks: an account made for the session, or a test
21
+ // id that belongs to nobody. Never a person who already has an account, never an admin, and never a
22
+ // key the app does not already hand to every browser. The recipes come from the cloud; these rules
23
+ // are here, beside the secrets, so a recipe cannot talk its way past them.
24
+ const ELEVATED = /admin|owner|super|root/i;
25
+ const PUBLIC_NAME = /^(NEXT_PUBLIC_|VITE_|REACT_APP_|EXPO_PUBLIC_|NUXT_PUBLIC_|PUBLIC_|GATSBY_)/;
26
+ const ACCOUNT_FIELD = /^(e-?mail|user(_?name)?|login|phone|account|identifier)$/i;
27
+ const ID_CLAIM = /^(sub|id|uid|user_?id|userid)$/i;
28
+
29
+ // Whether a value is already in the code the app ships: then every browser holds it, and sending it
30
+ // as the caller's key tells the app nothing a visitor could not.
31
+ function inSource(root, files, value) {
32
+ for (const rel of files) {
33
+ try { if (readFileSync(join(root, rel), "utf8").includes(value)) return true; } catch { /* gone */ }
34
+ }
35
+ return false;
36
+ }
37
+
38
+ export function refusal(recipe, env, shipped) {
39
+ if ([recipe?.role, ...(recipe?.roles ?? [])].some((r) => ELEVATED.test(String(r ?? "")))) return "an admin role; only an ordinary test account is signed in";
40
+ if (recipe?.kind === "jwt" && recipe.claim && !ID_CLAIM.test(recipe.claim)) return `a token field (${recipe.claim}) other than the user's id`;
41
+ if (recipe?.kind === "header") {
42
+ const names = recipe.env ?? [];
43
+ const value = names.map((n) => env[n]).find(Boolean);
44
+ if (value && !names.some((n) => PUBLIC_NAME.test(n)) && !shipped(value)) return `${names.join(" or ")} is a private key, never sent as a caller's own`;
45
+ }
46
+ for (const step of [...(recipe?.steps ?? []), ...(recipe?.alt ?? [])])
47
+ for (const [field, spec] of Object.entries(step?.body ?? {}))
48
+ if (ACCOUNT_FIELD.test(field) && String(spec).startsWith("env:")) return "an existing account from your environment; only a new test account is signed in";
49
+ return null;
50
+ }
51
+
20
52
  export function readEnv(file) {
21
53
  const out = {};
22
54
  let text = "";
@@ -55,7 +87,7 @@ async function firebaseIdToken(account, apiKey) {
55
87
  return res.ok && out?.idToken ? { idToken: out.idToken } : { error: String(out?.error?.message ?? `status ${res.status}`).slice(0, 120) };
56
88
  }
57
89
 
58
- export function makeIdentities({ root, work, envFiles, say, keepSecret, appDir = () => root }) {
90
+ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], say, keepSecret, appDir = () => root }) {
59
91
  const dir = join(work, "identities");
60
92
  const headerFile = (role) => join(dir, `bl-identity-${slug(role)}.header`);
61
93
  const firebaseRoles = new Map();
@@ -93,8 +125,11 @@ export function makeIdentities({ root, work, envFiles, say, keepSecret, appDir =
93
125
  // A Firebase caller with no emulator on this machine is signed in at their real project, below.
94
126
  // Handed to the program it waited its whole deadline on an emulator that was never there, and
95
127
  // 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);
128
+ const merged = Object.assign({}, ...[...envs().all].reverse());
129
+ const emulated = Boolean(merged.FIREBASE_AUTH_EMULATOR_HOST);
130
+ const refused = new Map();
131
+ for (const r of recipes) { const why = refusal(r, merged, (value) => inSource(root, sourceFiles(), value)); if (why && typeof r?.role === "string") refused.set(r.role, why); }
132
+ const forProgram = recipes.filter((r) => !refused.has(r?.role) && (r?.kind !== "firebase" || emulated));
98
133
  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
134
  const file = join(dir, "mint.py");
100
135
  writeFileSync(file, script, { mode: 0o600 });
@@ -106,6 +141,7 @@ export function makeIdentities({ root, work, envFiles, say, keepSecret, appDir =
106
141
  for (const recipe of recipes) {
107
142
  if (typeof recipe?.role !== "string" || !ROLE.test(recipe.role)) continue;
108
143
  const blank = { role: recipe.role, kind: recipe.kind, header: recipe.header, opens: [], door: recipe.header, where: recipe.where };
144
+ if (refused.has(recipe.role)) { rows.push({ ...blank, status: "refused", note: refused.get(recipe.role) }); continue; }
109
145
  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
146
  rows.push(recipe.kind === "firebase" && row.status !== "minted" ? await firebase(row) : row);
111
147
  }
package/local.mjs CHANGED
@@ -11,7 +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 { spawn, execFile } from "node:child_process";
14
+ import { spawn, execFile, execFileSync } from "node:child_process";
15
15
  import { createHash } from "node:crypto";
16
16
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, watch, writeFileSync } from "node:fs";
17
17
  import { homedir, tmpdir } from "node:os";
@@ -32,12 +32,19 @@ const say = (line) => console.log(`cortad ${line}`);
32
32
  const fail = (line) => { console.error(`cortad ${line}`); process.exit(1); };
33
33
 
34
34
  const explain = argv.includes("--explain");
35
+ // What is happening right now, on one line that rewrites itself. npx spends its own seconds fetching
36
+ // this package before anything here runs, and the first thing we printed used to be after the whole
37
+ // upload: a minute or more of a cursor sitting still, which reads as nothing happening.
38
+ const step = (line) => { if (process.stdout.isTTY) process.stdout.write(`\rcortad ${line}\x1b[K`); };
39
+ const clearStep = () => { if (process.stdout.isTTY) process.stdout.write("\r\x1b[K"); };
40
+ const stepDone = (line) => { clearStep(); say(line); };
35
41
  if (!explain && !/^[A-Z0-9]{8}$/.test(code)) fail("usage: npx cortad <code from the connect screen> [--port N] [--start \"cmd\"] | npx cortad --explain");
36
42
  // Where Brainsless is. The host is not on the command line: a code cannot point at an impostor.
37
43
  const origin = new URL(process.env.CORTAD_ORIGIN || "https://cortad.com");
38
44
  // Ours, and only ours. brainsless.com is the same service under its earlier name and stays trusted
39
45
  // while people still hold links to it.
40
- const OURS = ["cortad.com", "brainsless.com"];
46
+ // The last is our own Pages project: its subdomains are our branch deploys, staging among them.
47
+ const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
41
48
  const trusted = (origin.protocol === "https:" && OURS.some((host) => origin.hostname === host || origin.hostname.endsWith(`.${host}`)))
42
49
  || origin.hostname === "localhost" || origin.hostname === "127.0.0.1";
43
50
  if (!trusted) fail(`refusing: ${origin.host} is not Cortad.`);
@@ -47,9 +54,10 @@ const root = process.cwd();
47
54
  const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
48
55
  if (!MANIFEST.some((f) => existsSync(join(root, f)))) fail(`this folder has no package.json or pyproject.toml. Run it from your repository's root: ${root}`);
49
56
 
50
- // ---- what leaves the machine: source files only
51
- const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode)$/;
52
- const SKIP_FILE = /^\.env(\..*)?$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|parquet|csv|xlsx?|numbers|DS_Store)$/i;
57
+ // ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
58
+ const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
59
+ // Env files by any name (scripts/books.env is one), keys, and data rather than code.
60
+ const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|parquet|csv|tsv|jsonl|ndjson|xlsx?|numbers|DS_Store)$/i;
53
61
  const MAX_FILE = 1_000_000;
54
62
  const MAX_TOTAL = 80_000_000;
55
63
  const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
@@ -66,12 +74,37 @@ function walk(dir, depth, out, envs, total) {
66
74
  let size;
67
75
  try { size = statSync(full).size; } catch { continue; }
68
76
  if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
77
+ if (!shareable(relative(root, full))) continue;
69
78
  out.push(relative(root, full));
70
79
  total += size;
71
80
  }
72
81
  return total;
73
82
  }
74
83
 
84
+ // What git lists here: tracked files and new ones it would pick up, never an ignored one. A folder
85
+ // that is not a git repository has no such list and is read by the rules above alone.
86
+ function gitListed() {
87
+ if (!existsSync(join(root, ".git")) && !onPath("git")) return null;
88
+ let inside = false;
89
+ try { inside = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() === "true"; } catch { /* not a repository */ }
90
+ if (!inside) return null;
91
+ // A repository whose list cannot be read is not read by guesswork: it stops here.
92
+ try {
93
+ const out = execFileSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
94
+ return new Set(out.toString().split("\0").filter(Boolean));
95
+ } catch (e) { fail(`could not list this repository's files with git: ${e.message}`); }
96
+ }
97
+ // One rule for every file that could leave: the upload, and a read the engine asks for later.
98
+ function shareable(rel) {
99
+ const parts = rel.split(sep);
100
+ if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
101
+ if (listed === null) return true;
102
+ if (listed.has(rel)) return true;
103
+ // A file made after the list was taken (an agent edit) is asked of git directly.
104
+ try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
105
+ }
106
+ let listed = null;
107
+
75
108
  // Values from your env files, read here and only here, so nothing a command prints can carry one.
76
109
  function secretValues(envFiles) {
77
110
  const values = new Set();
@@ -170,7 +203,8 @@ const readable = (p) => {
170
203
  if (r.startsWith(work + sep)) return r;
171
204
  let landed; try { landed = realpathSync(r); } catch { return null; }
172
205
  const home = realpathSync(root);
173
- return (landed === home || landed.startsWith(home + sep)) && !ENV_FILE.test(basename(landed)) && !SECRET_PATH.test(landed) ? landed : null;
206
+ if (!landed.startsWith(home + sep) || ENV_FILE.test(basename(landed)) || SECRET_PATH.test(landed)) return null;
207
+ return shareable(relative(home, landed)) ? landed : null;
174
208
  };
175
209
  // A shell line runs with a plain environment: the app's own process reads its .env itself, and
176
210
  // nothing a world sends inherits this terminal's keys.
@@ -230,7 +264,9 @@ async function verb(job) {
230
264
  const res = await fetch(`http://127.0.0.1:${port}${path}`, { ...init, signal: AbortSignal.timeout(170_000) });
231
265
  const buf = Buffer.from(await res.arrayBuffer());
232
266
  const LIMIT = 262_144;
233
- return { status: res.status, headers: Object.fromEntries(res.headers), body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
267
+ // A session cookie your app sets is its business: it is dropped here, and every other header masked.
268
+ const headers = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
269
+ return { status: res.status, headers, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
234
270
  };
235
271
  try { return await ask(); }
236
272
  catch (e) {
@@ -259,7 +295,7 @@ async function verb(job) {
259
295
  }
260
296
  case "changes": return door.changes();
261
297
  case "diff": return door.diff(String(b.path ?? ""));
262
- case "mint": return identities ? identities.mint(b, app?.port) : { identities: [] };
298
+ case "mint": return identities ? JSON.parse(mask(JSON.stringify(await identities.mint(b, app?.port)))) : { identities: [] };
263
299
  case "restore": return door.restore(String(b.checkpoint ?? ""));
264
300
  case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
265
301
  case "restart": return restartApp();
@@ -302,8 +338,9 @@ async function startApp() {
302
338
  pinned = pinnedNode();
303
339
  if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
304
340
  const lifted = liftedLimits(envFiles);
305
- if (Object.keys(lifted).length) say(`your own request limits are raised for this session: ${Object.keys(lifted).join(", ")}`);
341
+ if (Object.keys(lifted).length) say(`higher request limits for this session: ${Object.keys(lifted).join(", ")}`);
306
342
  launched = { cmd, lifted };
343
+ step(`starting your app: ${cmd}`);
307
344
  const up = await launch(180_000);
308
345
  if (up.port) return { port: up.port, cmd, lifted: Object.keys(lifted) };
309
346
  // Already running: a second start dies on the port the first one holds. The one that is running
@@ -417,8 +454,11 @@ async function stopApp(pid) {
417
454
  }
418
455
 
419
456
  // ---- go
457
+ say("getting ready");
458
+ step("looking at this folder");
420
459
  const envFiles = [];
421
460
  const files = [];
461
+ listed = gitListed();
422
462
  walk(root, 0, files, envFiles, 0);
423
463
  // --explain: what this would send and start, from this folder, and nothing else. No network, no
424
464
  // app started, nothing written. For the person (or the agent) who reads before running.
@@ -430,13 +470,13 @@ if (explain) {
430
470
  `cortad --explain (nothing is sent or started by this)`,
431
471
  ``,
432
472
  `talks to ${origin.origin} and localhost (your app's port only)`,
433
- `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once`,
434
- `never sent .env* (${envFiles.length} found: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
435
- `env files read on this machine only: to mask their values in your app's replies, and to sign test requests in`,
473
+ `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
474
+ `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`,
475
+ `env files read here only: to hide their values in replies, and to sign in a test account`,
436
476
  `would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan ? `${plan.cmd} (in ${rel(plan.cwd)})` : "asks you how your app starts"}`,
437
477
  `loads into app lib/trace.cjs (Node) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
438
478
  `agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
439
- `agent shell sandboxed by the OS: no network but localhost, no writes outside temp and ignored build folders, no .env reads`,
479
+ `agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
440
480
  ``,
441
481
  `first files ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", ..." : ""}`,
442
482
  ].join("\n"));
@@ -444,7 +484,7 @@ if (explain) {
444
484
  }
445
485
  secrets = secretValues(envFiles);
446
486
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
447
- identities = makeIdentities({ root, work, envFiles, say, keepSecret, appDir: () => appDir });
487
+ identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
448
488
  // One message sent in their own app tells us the door for certain. The route and the body go up,
449
489
  // masked like everything else; the sign-in that message carried stays here.
450
490
  capture = makeCapture({ work, keepSecret, onDoor: (door) => {
@@ -464,23 +504,38 @@ key = attach.data.key;
464
504
 
465
505
  const list = join(work, "files.txt");
466
506
  writeFileSync(list, files.join("\n") + "\n");
507
+ // Which code this is, from what the files say rather than from the archive: gzip stamps the time into
508
+ // every archive, so the same folder packed twice never matched and every reconnect bought a whole new
509
+ // read of code that had not changed, emptying the page's counts and its price while it ran.
510
+ const treeDigest = (() => {
511
+ const h = createHash("sha256");
512
+ for (const rel of [...files].sort()) {
513
+ let body = Buffer.alloc(0);
514
+ try { body = readFileSync(join(root, rel)); } catch { continue; }
515
+ h.update(rel).update("\0").update(createHash("sha256").update(body).digest("hex")).update("\n");
516
+ }
517
+ return h.digest("hex");
518
+ })();
467
519
  const archive = join(work, "tree.tgz");
520
+ step("connecting");
468
521
  await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
469
522
  const bytes = readFileSync(archive);
470
523
  const PART = 1_500_000;
471
524
  let resumed = false;
525
+ const parts = Math.max(1, Math.ceil(bytes.length / PART));
472
526
  for (let off = 0; off < bytes.length; off += PART) {
473
527
  const last = off + PART >= bytes.length;
474
- const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
528
+ step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
529
+ const put = await call("PUT", `/local/${box}/tree?last=${last ? 1 : 0}${last ? `&digest=${treeDigest}` : ""}`, bytes.subarray(off, off + PART), { raw: true, timeoutMs: 120_000 });
475
530
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
476
531
  if (last) resumed = put.data?.resumed === true;
477
532
  }
478
533
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
479
- say(resumed ? "connected · your code is unchanged, picking up where you left off" : `connected · reading your code (${files.length} files, ${Math.round(bytes.length / 1024)} KB)`);
534
+ stepDone(resumed ? "connected · nothing changed since last time" : "connected");
480
535
 
481
536
  lock = await makeLock({ root, work });
482
537
  if (lock && !lockHolds(lock, root)) lock = null;
483
- if (!lock) say("no sandbox tool on this machine (sandbox-exec or bubblewrap), so no shell line will be run here. Runs and edits still work.");
538
+ if (!lock) say("shell commands are off on this machine (no sandbox-exec or bubblewrap). Everything else works.");
484
539
 
485
540
  // A change to their own files, settled: what "I fixed it" looks like from here. Folders an app
486
541
  // writes to by itself are not a fix.
@@ -517,6 +572,7 @@ async function appLife() {
517
572
  say("saw your change, starting your app again");
518
573
  }
519
574
  const told = await announce();
575
+ clearStep();
520
576
  if (!told.ok) fail(told.data?.error ?? `could not register your app (${told.status})`);
521
577
  say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
522
578
  say("leave this open. Go back to the browser; Ctrl-C disconnects.");
@@ -580,7 +636,7 @@ async function close(code = 0) {
580
636
  await call("DELETE", `/local/${box}`).catch(() => {});
581
637
  if (child?.pid) await stopApp(child.pid);
582
638
  const pending = door.pending();
583
- if (pending) say(`${pending} file${pending === 1 ? "" : "s"} changed by the agent ${pending === 1 ? "is" : "are"} still in your folder, not yet kept or undone. Run this command again to decide from the browser.`);
639
+ if (pending) say(`${pending} agent edit${pending === 1 ? " is" : "s are"} waiting for you to keep or undo. Run the command again to review ${pending === 1 ? "it" : "them"} in the browser.`);
584
640
  await exec("rm", ["-rf", work]).catch(() => {});
585
641
  process.exit(code);
586
642
  }
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.0",
4
- "description": "Runs Cortad test conversations against the app on your machine. Env values never leave it. Zero dependencies, no install scripts.",
3
+ "version": "0.1.2",
4
+ "description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
7
7
  },
8
8
  "type": "module",
9
9
  "files": [
10
- "local.mjs",
11
- "lib/door.mjs",
12
- "lib/lock.mjs",
13
10
  "README.md",
11
+ "lib/door.mjs",
14
12
  "lib/identities.mjs",
13
+ "lib/lock.mjs",
15
14
  "lib/mint.mjs",
16
- "lib/trace.cjs",
15
+ "lib/pyhook/sitecustomize.py",
17
16
  "lib/replay.mjs",
18
17
  "lib/start.mjs",
19
- "lib/pyhook/sitecustomize.py"
18
+ "lib/trace.cjs",
19
+ "local.mjs"
20
20
  ],
21
21
  "engines": {
22
22
  "node": ">=20"
@@ -25,5 +25,12 @@
25
25
  "homepage": "https://cortad.com",
26
26
  "publishConfig": {
27
27
  "access": "public"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/brainsless/cortad.git"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/brainsless/cortad/issues"
28
35
  }
29
36
  }