cortad 0.1.1 → 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 +50 -20
- package/package.json +7 -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";
|
|
@@ -54,9 +54,10 @@ 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
|
|
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
|
-
|
|
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;
|
|
61
62
|
const MAX_TOTAL = 80_000_000;
|
|
62
63
|
const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
|
|
@@ -73,12 +74,37 @@ function walk(dir, depth, out, envs, total) {
|
|
|
73
74
|
let size;
|
|
74
75
|
try { size = statSync(full).size; } catch { continue; }
|
|
75
76
|
if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
|
|
77
|
+
if (!shareable(relative(root, full))) continue;
|
|
76
78
|
out.push(relative(root, full));
|
|
77
79
|
total += size;
|
|
78
80
|
}
|
|
79
81
|
return total;
|
|
80
82
|
}
|
|
81
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
|
+
|
|
82
108
|
// Values from your env files, read here and only here, so nothing a command prints can carry one.
|
|
83
109
|
function secretValues(envFiles) {
|
|
84
110
|
const values = new Set();
|
|
@@ -177,7 +203,8 @@ const readable = (p) => {
|
|
|
177
203
|
if (r.startsWith(work + sep)) return r;
|
|
178
204
|
let landed; try { landed = realpathSync(r); } catch { return null; }
|
|
179
205
|
const home = realpathSync(root);
|
|
180
|
-
|
|
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;
|
|
181
208
|
};
|
|
182
209
|
// A shell line runs with a plain environment: the app's own process reads its .env itself, and
|
|
183
210
|
// nothing a world sends inherits this terminal's keys.
|
|
@@ -237,7 +264,9 @@ async function verb(job) {
|
|
|
237
264
|
const res = await fetch(`http://127.0.0.1:${port}${path}`, { ...init, signal: AbortSignal.timeout(170_000) });
|
|
238
265
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
239
266
|
const LIMIT = 262_144;
|
|
240
|
-
|
|
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 };
|
|
241
270
|
};
|
|
242
271
|
try { return await ask(); }
|
|
243
272
|
catch (e) {
|
|
@@ -266,7 +295,7 @@ async function verb(job) {
|
|
|
266
295
|
}
|
|
267
296
|
case "changes": return door.changes();
|
|
268
297
|
case "diff": return door.diff(String(b.path ?? ""));
|
|
269
|
-
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: [] };
|
|
270
299
|
case "restore": return door.restore(String(b.checkpoint ?? ""));
|
|
271
300
|
case "keep": return door.keep(typeof b.checkpoint === "string" && b.checkpoint ? b.checkpoint : undefined);
|
|
272
301
|
case "restart": return restartApp();
|
|
@@ -309,7 +338,7 @@ async function startApp() {
|
|
|
309
338
|
pinned = pinnedNode();
|
|
310
339
|
if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
|
|
311
340
|
const lifted = liftedLimits(envFiles);
|
|
312
|
-
if (Object.keys(lifted).length) say(`
|
|
341
|
+
if (Object.keys(lifted).length) say(`higher request limits for this session: ${Object.keys(lifted).join(", ")}`);
|
|
313
342
|
launched = { cmd, lifted };
|
|
314
343
|
step(`starting your app: ${cmd}`);
|
|
315
344
|
const up = await launch(180_000);
|
|
@@ -425,10 +454,11 @@ async function stopApp(pid) {
|
|
|
425
454
|
}
|
|
426
455
|
|
|
427
456
|
// ---- go
|
|
428
|
-
say("
|
|
429
|
-
step("
|
|
457
|
+
say("getting ready");
|
|
458
|
+
step("looking at this folder");
|
|
430
459
|
const envFiles = [];
|
|
431
460
|
const files = [];
|
|
461
|
+
listed = gitListed();
|
|
432
462
|
walk(root, 0, files, envFiles, 0);
|
|
433
463
|
// --explain: what this would send and start, from this folder, and nothing else. No network, no
|
|
434
464
|
// app started, nothing written. For the person (or the agent) who reads before running.
|
|
@@ -440,13 +470,13 @@ if (explain) {
|
|
|
440
470
|
`cortad --explain (nothing is sent or started by this)`,
|
|
441
471
|
``,
|
|
442
472
|
`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
|
-
`
|
|
445
|
-
`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`,
|
|
446
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"}`,
|
|
447
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`,
|
|
448
478
|
`agent edits in your files, each with an undo kept in ~/.cortad/checkpoints; git is never touched`,
|
|
449
|
-
`agent shell
|
|
479
|
+
`agent shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
|
|
450
480
|
``,
|
|
451
481
|
`first files ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", ..." : ""}`,
|
|
452
482
|
].join("\n"));
|
|
@@ -454,7 +484,7 @@ if (explain) {
|
|
|
454
484
|
}
|
|
455
485
|
secrets = secretValues(envFiles);
|
|
456
486
|
const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
|
|
457
|
-
identities = makeIdentities({ root, work, envFiles, say, keepSecret, appDir: () => appDir });
|
|
487
|
+
identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
|
|
458
488
|
// One message sent in their own app tells us the door for certain. The route and the body go up,
|
|
459
489
|
// masked like everything else; the sign-in that message carried stays here.
|
|
460
490
|
capture = makeCapture({ work, keepSecret, onDoor: (door) => {
|
|
@@ -487,7 +517,7 @@ const treeDigest = (() => {
|
|
|
487
517
|
return h.digest("hex");
|
|
488
518
|
})();
|
|
489
519
|
const archive = join(work, "tree.tgz");
|
|
490
|
-
step(
|
|
520
|
+
step("connecting");
|
|
491
521
|
await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
|
|
492
522
|
const bytes = readFileSync(archive);
|
|
493
523
|
const PART = 1_500_000;
|
|
@@ -495,17 +525,17 @@ let resumed = false;
|
|
|
495
525
|
const parts = Math.max(1, Math.ceil(bytes.length / PART));
|
|
496
526
|
for (let off = 0; off < bytes.length; off += PART) {
|
|
497
527
|
const last = off + PART >= bytes.length;
|
|
498
|
-
step(parts > 1 ? `
|
|
528
|
+
step(parts > 1 ? `connecting, ${Math.floor(off / PART) + 1} of ${parts}` : "connecting");
|
|
499
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 });
|
|
500
530
|
if (!put.ok) fail(put.data?.error ?? `upload failed (${put.status})`);
|
|
501
531
|
if (last) resumed = put.data?.resumed === true;
|
|
502
532
|
}
|
|
503
533
|
// Unchanged code has already been read: coming back says so instead of claiming a second read.
|
|
504
|
-
stepDone(resumed ? "connected ·
|
|
534
|
+
stepDone(resumed ? "connected · nothing changed since last time" : "connected");
|
|
505
535
|
|
|
506
536
|
lock = await makeLock({ root, work });
|
|
507
537
|
if (lock && !lockHolds(lock, root)) lock = null;
|
|
508
|
-
if (!lock) say("
|
|
538
|
+
if (!lock) say("shell commands are off on this machine (no sandbox-exec or bubblewrap). Everything else works.");
|
|
509
539
|
|
|
510
540
|
// A change to their own files, settled: what "I fixed it" looks like from here. Folders an app
|
|
511
541
|
// writes to by itself are not a fix.
|
|
@@ -606,7 +636,7 @@ async function close(code = 0) {
|
|
|
606
636
|
await call("DELETE", `/local/${box}`).catch(() => {});
|
|
607
637
|
if (child?.pid) await stopApp(child.pid);
|
|
608
638
|
const pending = door.pending();
|
|
609
|
-
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.`);
|
|
610
640
|
await exec("rm", ["-rf", work]).catch(() => {});
|
|
611
641
|
process.exit(code);
|
|
612
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"
|