cortad 0.1.1 → 0.1.3

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";
@@ -54,10 +54,16 @@ const root = process.cwd();
54
54
  const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
55
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}`);
56
56
 
57
- // ---- what leaves the machine: source files only
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)$/;
59
- 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;
60
61
  const MAX_FILE = 1_000_000;
62
+ // A JSON or YAML file this large is a dataset (exports, catalogues, fixtures), not code or a prompt,
63
+ // and datasets are where projects keep what they would never paste into a chat.
64
+ const MAX_DATA = 200_000;
65
+ const DATA_FILE = /\.(json|ya?ml)$/i;
66
+ const MANIFEST_FILE = /^(package|tsconfig|composer|app|manifest)\.json$/i;
61
67
  const MAX_TOTAL = 80_000_000;
62
68
  const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
63
69
 
@@ -73,12 +79,38 @@ function walk(dir, depth, out, envs, total) {
73
79
  let size;
74
80
  try { size = statSync(full).size; } catch { continue; }
75
81
  if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
82
+ if (size > MAX_DATA && DATA_FILE.test(e.name) && !MANIFEST_FILE.test(e.name)) continue;
83
+ if (!shareable(relative(root, full))) continue;
76
84
  out.push(relative(root, full));
77
85
  total += size;
78
86
  }
79
87
  return total;
80
88
  }
81
89
 
90
+ // What git lists here: tracked files and new ones it would pick up, never an ignored one. A folder
91
+ // that is not a git repository has no such list and is read by the rules above alone.
92
+ function gitListed() {
93
+ if (!existsSync(join(root, ".git")) && !onPath("git")) return null;
94
+ let inside = false;
95
+ try { inside = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim() === "true"; } catch { /* not a repository */ }
96
+ if (!inside) return null;
97
+ // A repository whose list cannot be read is not read by guesswork: it stops here.
98
+ try {
99
+ const out = execFileSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
100
+ return new Set(out.toString().split("\0").filter(Boolean));
101
+ } catch (e) { fail(`could not list this repository's files with git: ${e.message}`); }
102
+ }
103
+ // One rule for every file that could leave: the upload, and a read the engine asks for later.
104
+ function shareable(rel) {
105
+ const parts = rel.split(sep);
106
+ if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
107
+ if (listed === null) return true;
108
+ if (listed.has(rel)) return true;
109
+ // A file made after the list was taken (an agent edit) is asked of git directly.
110
+ try { execFileSync("git", ["-C", root, "check-ignore", "-q", rel], { stdio: "ignore" }); return false; } catch (e) { return e.status === 1; }
111
+ }
112
+ let listed = null;
113
+
82
114
  // Values from your env files, read here and only here, so nothing a command prints can carry one.
83
115
  function secretValues(envFiles) {
84
116
  const values = new Set();
@@ -177,7 +209,8 @@ const readable = (p) => {
177
209
  if (r.startsWith(work + sep)) return r;
178
210
  let landed; try { landed = realpathSync(r); } catch { return null; }
179
211
  const home = realpathSync(root);
180
- return (landed === home || landed.startsWith(home + sep)) && !ENV_FILE.test(basename(landed)) && !SECRET_PATH.test(landed) ? landed : null;
212
+ if (!landed.startsWith(home + sep) || ENV_FILE.test(basename(landed)) || SECRET_PATH.test(landed)) return null;
213
+ return shareable(relative(home, landed)) ? landed : null;
181
214
  };
182
215
  // A shell line runs with a plain environment: the app's own process reads its .env itself, and
183
216
  // nothing a world sends inherits this terminal's keys.
@@ -237,7 +270,9 @@ async function verb(job) {
237
270
  const res = await fetch(`http://127.0.0.1:${port}${path}`, { ...init, signal: AbortSignal.timeout(170_000) });
238
271
  const buf = Buffer.from(await res.arrayBuffer());
239
272
  const LIMIT = 262_144;
240
- return { status: res.status, headers: Object.fromEntries(res.headers), body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
273
+ // A session cookie your app sets is its business: it is dropped here, and every other header masked.
274
+ const headers = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
275
+ return { status: res.status, headers, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
241
276
  };
242
277
  try { return await ask(); }
243
278
  catch (e) {
@@ -266,7 +301,7 @@ async function verb(job) {
266
301
  }
267
302
  case "changes": return door.changes();
268
303
  case "diff": return door.diff(String(b.path ?? ""));
269
- case "mint": return identities ? identities.mint(b, app?.port) : { identities: [] };
304
+ case "mint": return identities ? JSON.parse(mask(JSON.stringify(await identities.mint(b, app?.port)))) : { identities: [] };
270
305
  case "restore": return door.restore(String(b.checkpoint ?? ""));
271
306
  case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
272
307
  case "restart": return restartApp();
@@ -309,7 +344,7 @@ async function startApp() {
309
344
  pinned = pinnedNode();
310
345
  if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
311
346
  const lifted = liftedLimits(envFiles);
312
- if (Object.keys(lifted).length) say(`your own request limits are raised for this session: ${Object.keys(lifted).join(", ")}`);
347
+ if (Object.keys(lifted).length) say(`higher request limits for this session: ${Object.keys(lifted).join(", ")}`);
313
348
  launched = { cmd, lifted };
314
349
  step(`starting your app: ${cmd}`);
315
350
  const up = await launch(180_000);
@@ -425,10 +460,11 @@ async function stopApp(pid) {
425
460
  }
426
461
 
427
462
  // ---- go
428
- say("starting");
429
- step("reading this folder");
463
+ say("getting ready");
464
+ step("looking at this folder");
430
465
  const envFiles = [];
431
466
  const files = [];
467
+ listed = gitListed();
432
468
  walk(root, 0, files, envFiles, 0);
433
469
  // --explain: what this would send and start, from this folder, and nothing else. No network, no
434
470
  // app started, nothing written. For the person (or the agent) who reads before running.
@@ -440,13 +476,13 @@ if (explain) {
440
476
  `cortad --explain (nothing is sent or started by this)`,
441
477
  ``,
442
478
  `talks to ${origin.origin} and localhost (your app's port only)`,
443
- `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once`,
444
- `never sent .env* (${envFiles.length} found: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
445
- `env files read on this machine only: to mask their values in your app's replies, and to sign test requests in`,
479
+ `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
480
+ `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`,
481
+ `env files read here only: to hide their values in replies, and to sign in a test account`,
446
482
  `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"}`,
447
483
  `loads into app lib/trace.cjs (Node) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
448
484
  `agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
449
- `agent shell sandboxed by the OS: no network but localhost, no writes outside temp and ignored build folders, no .env reads`,
485
+ `agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
450
486
  ``,
451
487
  `first files ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", ..." : ""}`,
452
488
  ].join("\n"));
@@ -454,7 +490,7 @@ if (explain) {
454
490
  }
455
491
  secrets = secretValues(envFiles);
456
492
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
457
- identities = makeIdentities({ root, work, envFiles, say, keepSecret, appDir: () => appDir });
493
+ identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
458
494
  // One message sent in their own app tells us the door for certain. The route and the body go up,
459
495
  // masked like everything else; the sign-in that message carried stays here.
460
496
  capture = makeCapture({ work, keepSecret, onDoor: (door) => {
@@ -487,7 +523,7 @@ const treeDigest = (() => {
487
523
  return h.digest("hex");
488
524
  })();
489
525
  const archive = join(work, "tree.tgz");
490
- step(`packing ${files.length} files`);
526
+ step("connecting");
491
527
  await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
492
528
  const bytes = readFileSync(archive);
493
529
  const PART = 1_500_000;
@@ -495,17 +531,17 @@ let resumed = false;
495
531
  const parts = Math.max(1, Math.ceil(bytes.length / PART));
496
532
  for (let off = 0; off < bytes.length; off += PART) {
497
533
  const last = off + PART >= bytes.length;
498
- step(parts > 1 ? `sending your code, part ${Math.floor(off / PART) + 1} of ${parts}` : "sending your code");
534
+ step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
499
535
  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 });
500
536
  if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
501
537
  if (last) resumed = put.data?.resumed === true;
502
538
  }
503
539
  // Unchanged code has already been read: coming back says so instead of claiming a second read.
504
- stepDone(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)`);
540
+ stepDone(resumed ? "connected · nothing changed since last time" : "connected");
505
541
 
506
542
  lock = await makeLock({ root, work });
507
543
  if (lock && !lockHolds(lock, root)) lock = null;
508
- 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.");
544
+ if (!lock) say("shell commands are off on this machine (no sandbox-exec or bubblewrap). Everything else works.");
509
545
 
510
546
  // A change to their own files, settled: what "I fixed it" looks like from here. Folders an app
511
547
  // writes to by itself are not a fix.
@@ -606,7 +642,7 @@ async function close(code = 0) {
606
642
  await call("DELETE", `/local/${box}`).catch(() => {});
607
643
  if (child?.pid) await stopApp(child.pid);
608
644
  const pending = door.pending();
609
- 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.`);
645
+ 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.`);
610
646
  await exec("rm", ["-rf", work]).catch(() => {});
611
647
  process.exit(code);
612
648
  }
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.1",
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.3",
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"