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 +18 -21
- package/lib/identities.mjs +9 -7
- package/lib/lock.mjs +36 -13
- package/lib/mint.mjs +39 -3
- package/local.mjs +56 -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,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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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("
|
|
429
|
-
step("
|
|
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
|
-
`
|
|
445
|
-
`env files read
|
|
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
|
|
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(
|
|
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 ? `
|
|
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 ·
|
|
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("
|
|
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}
|
|
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.
|
|
4
|
-
"description": "
|
|
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/
|
|
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"
|