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 +18 -21
- package/lib/identities.mjs +9 -7
- package/lib/lock.mjs +36 -13
- package/lib/mint.mjs +39 -3
- package/local.mjs +74 -18
- package/package.json +14 -7
package/README.md
CHANGED
|
@@ -1,45 +1,42 @@
|
|
|
1
1
|
# cortad
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
9
|
+
Run it in your app's folder with the code from cortad.com. Nothing is installed. Ctrl-C disconnects.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## What it does
|
|
12
12
|
|
|
13
|
-
- Starts your app with its dev script
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
-
##
|
|
18
|
+
## What Cortad receives
|
|
20
19
|
|
|
21
|
-
-
|
|
22
|
-
- Your app's replies to the test
|
|
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
|
-
##
|
|
23
|
+
## What stays on your machine
|
|
25
24
|
|
|
26
|
-
-
|
|
27
|
-
- Tokens
|
|
25
|
+
- Your env files and their values.
|
|
26
|
+
- Tokens and cookies. A signed-in test request gets its token added here.
|
|
28
27
|
|
|
29
|
-
##
|
|
28
|
+
## The agent's shell
|
|
30
29
|
|
|
31
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
+
macOS and Linux, Node 20+. No dependencies, no install scripts.
|
package/lib/identities.mjs
CHANGED
|
@@ -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": "
|
|
102
|
-
"email": "
|
|
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": "
|
|
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
|
-
#
|
|
241
|
-
#
|
|
242
|
-
|
|
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] =
|
|
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
|
|
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
|
|
24
|
+
.then((r) => r.stdout.split("\n").filter(Boolean), () => []);
|
|
25
|
+
const writable = [];
|
|
26
|
+
const hidden = [];
|
|
23
27
|
for (const line of out) {
|
|
24
|
-
|
|
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)
|
|
28
|
-
|
|
29
|
-
if (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
63
|
-
return { name: "bubblewrap", wrap: (cmd) => ({ file: bwrap, args: ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", ...
|
|
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
|
|
97
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
-
`
|
|
435
|
-
`env files read
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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}
|
|
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.
|
|
4
|
-
"description": "
|
|
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/
|
|
15
|
+
"lib/pyhook/sitecustomize.py",
|
|
17
16
|
"lib/replay.mjs",
|
|
18
17
|
"lib/start.mjs",
|
|
19
|
-
"lib/
|
|
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
|
}
|