Public: anyone can read this; the crew can change it.
Machines: configurations crew run in, and a shell in the browser #9462
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9418-machines-configurations-crew-run-in-and → main 2 commits fork 3 threads · 3 unresolved
7 files
changed +445
−2
docs/features/machines.md added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | # Machines (#9418, first slice) | |
| 2 | ||
| 3 | A machine is a named configuration a crew member's shell runs in: the tools | |
| 4 | its role is given, what it may use up, and how far it may reach. | |
| 5 | `src/crew/machines.ts` holds the catalog and the validation; | |
| 6 | `src/crew/machines_read.ts` reads a repository's choice. | |
| 7 | ||
| 8 | ## Configure | |
| 9 | ||
| 10 | At the top level of `forge.json`, read at the **default branch's tip** (a run | |
| 11 | cannot widen its own machine by editing the file on its branch): | |
| 12 | ||
| 13 | json | |
| 14 | "machines": { "audit": { "tools": ["gitleaks", "trivy"], "reach": [] } }, | |
| 15 | "machineFor": { "reviewer": "audit" } | |
| 16 | ||
| 17 | ||
| 18 | - `tools` are picked from a list (`TOOLS`): gitleaks, trivy, osv-scanner, | |
| 19 | nuclei, zap, testssl.sh, nmap. A name is never a command. | |
| 20 | - `reach` is `["apps"]` (the repository's own production and previews, as | |
| 21 | today) or `[]`. A machine cannot name a host, so it can only narrow the | |
| 22 | sandbox; the forge's secrets stay denied by `crew/sandbox.ts` whatever a | |
| 23 | machine says. | |
| 24 | - `machineFor` maps a crew role (planner, builder, reviewer) to a machine. A | |
| 25 | role with none runs in `standard`, which is today's sandbox exactly. | |
| 26 | - `standard` and `security` are built in. `security` carries the investigation | |
| 27 | tools, aimed at the forge's own copies only. | |
| 28 | - An invalid declaration falls back to `standard`; it never stops the crew. | |
| 29 | ||
| 30 | ## What a run gets | |
| 31 | ||
| 32 | `FORGE_MACHINE` and `FORGE_MACHINE_TOOLS` (names only) in its environment, a | |
| 33 | line in its brief naming the tools its machine carries, and its `apps` reach. | |
| 34 | ||
| 35 | ## Not done yet (split from #9418) | |
| 36 | ||
| 37 | Memory and CPU ceilings for a machine wait for the cgroup work (#9460); the tools are declared, not yet installed in an image; the browser | |
| 38 | shell (ghostty-web, tmux, WebSocket, session records) is not started. |
src/crew/brief.ts +10
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * rules (docs/APPS.md) hold whether or not the agent reads them. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { machineNote, type Machine } from "./machines.ts"; | |
| 10 | 11 | import type { OwnApp } from "./app_hosts.ts"; |
| 11 | 12 | import type { Owed } from "./team_share.ts"; |
| 12 | 13 | import type { Agent, CrewRole } from "../core/agents.ts"; |
| @@ -50,6 +51,8 @@ export interface BriefInput { | ||
| 50 | 51 | readonly resuming: boolean; |
| 51 | 52 | /** What this agent is for on this repository; a planner writes maps. */ |
| 52 | 53 | readonly role?: CrewRole | undefined; |
| 54 | /** The machine this role's shell runs in (`crew/machines.ts`). */ | |
| 55 | readonly machine?: Machine | undefined; | |
| 53 | 56 | /** |
| 54 | 57 | * The policy in force: the app's contract as a table, when it has one. |
| 55 | 58 | * A builder is told the room it works in before it starts, so the doors |
| @@ -100,6 +103,12 @@ export function fenced(text: string, info = ""): string[] { | ||
| 100 | 103 | return [fence + info, text, fence]; |
| 101 | 104 | } |
| 102 | 105 | |
| 106 | /** The brief line naming a machine's tools; none for a machine without any. */ | |
| 107 | function machineLines(machine: Machine | undefined): string[] { | |
| 108 | const note = machine === undefined ? undefined : machineNote(machine); | |
| 109 | return note === undefined ? [] : [note, ""]; | |
| 110 | } | |
| 111 | ||
| 103 | 112 | /** |
| 104 | 113 | * A short name for the prompt an agent ran on: a hash of its brief, its |
| 105 | 114 | * role and the standing system-prompt text. Per-run facts (issue, branch) |
| @@ -123,6 +132,7 @@ export function systemPrompt(input: BriefInput): string { | ||
| 123 | 132 | "", |
| 124 | 133 | "Read docs/VISION.md first: it says what the forge is for and the order of work. Done means reachable: a function nobody can call is not done. Where the repository has docs/PRINCIPLES.md, read it too: what it holds to, and what enforces each.", |
| 125 | 134 | "", |
| 135 | ...machineLines(input.machine), | |
| 126 | 136 | "How to finish:", |
| 127 | 137 | "- Commit your work with clear messages and push: `git push`.", |
| 128 | 138 | "- If `forge.json` declares a `check`, the forge runs it against every push to your pull, and that verdict is what lands it; a failing check comes back to you with its log.", |
src/crew/machines.ts added +194
| @@ -0,0 +1,194 @@ | ||
| 1 | /** | |
| 2 | * Machines (#9418): the named configuration a crew member's shell runs in. | |
| 3 | * A machine says which investigation tools its role is given, what it may | |
| 4 | * use up, and how far it may reach. It can only narrow the sandbox | |
| 5 | * (`crew/sandbox.ts`), never widen it: reach is a choice between names the | |
| 6 | * forge owns (`apps`), not a host list, so no machine configuration | |
| 7 | * can name a system the forge does not own, and the forge's secrets stay | |
| 8 | * denied by the sandbox whatever a machine says. | |
| 9 | * | |
| 10 | * A repository declares machines at the top level of `forge.json`, read at | |
| 11 | * the default branch's tip, never from the agent's own branch (an agent | |
| 12 | * cannot hand itself a bigger machine): | |
| 13 | * | |
| 14 | * "machines": { "audit": { "tools": ["gitleaks"], "reach": [] } }, | |
| 15 | * "machineFor": { "reviewer": "audit" } | |
| 16 | * | |
| 17 | * `standard` (today's sandbox) and `security` ship built in. Pure: text in, | |
| 18 | * plain data out. | |
| 19 | */ | |
| 20 | ||
| 21 | import { CREW_ROLES, type CrewRole } from "../core/agents.ts"; | |
| 22 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 23 | ||
| 24 | /** | |
| 25 | * What a machine may reach beyond the forge itself (always reachable: the | |
| 26 | * shell pushes and reads through it): the repository's own app, production | |
| 27 | * and previews. A machine can drop it; it cannot add anything. | |
| 28 | */ | |
| 29 | export const REACHES = ["apps"] as const; | |
| 30 | export type Reach = (typeof REACHES)[number]; | |
| 31 | ||
| 32 | /** | |
| 33 | * The tools a machine can be given, from a list (not free text, so a name | |
| 34 | * is never a command). The security machine's first set, from the research | |
| 35 | * on #9418; they investigate the forge's own copies only. | |
| 36 | */ | |
| 37 | export const TOOLS: Readonly<Record<string, string>> = { | |
| 38 | gitleaks: "finds secrets committed to a repository", | |
| 39 | trivy: "scans dependencies and images for known vulnerabilities", | |
| 40 | "osv-scanner": "matches lockfiles against the OSV vulnerability database", | |
| 41 | nuclei: "runs template-driven checks against a running app", | |
| 42 | zap: "probes a running web app for common weaknesses", | |
| 43 | "testssl.sh": "checks a TLS endpoint's protocols and ciphers", | |
| 44 | nmap: "maps open ports on a host the machine may reach", | |
| 45 | }; | |
| 46 | ||
| 47 | export interface Machine { | |
| 48 | readonly name: string; | |
| 49 | readonly label: string; | |
| 50 | readonly tools: readonly string[]; | |
| 51 | readonly reach: readonly Reach[]; | |
| 52 | } | |
| 53 | ||
| 54 | /** Today's sandbox, unchanged: the forge and the repository's own app. */ | |
| 55 | export const STANDARD: Machine = { | |
| 56 | name: "standard", | |
| 57 | label: "Standard — the crew's usual sandbox", | |
| 58 | tools: [], | |
| 59 | reach: ["apps"], | |
| 60 | }; | |
| 61 | ||
| 62 | /** The investigation machine: scanners aimed at the forge's own copies. */ | |
| 63 | export const SECURITY: Machine = { | |
| 64 | name: "security", | |
| 65 | label: "Security — investigation tools, the forge's own copies only", | |
| 66 | tools: ["gitleaks", "trivy", "osv-scanner", "nuclei", "zap", "testssl.sh", "nmap"], | |
| 67 | reach: ["apps"], | |
| 68 | }; | |
| 69 | ||
| 70 | export const BUILT_IN: readonly Machine[] = [STANDARD, SECURITY]; | |
| 71 | ||
| 72 | export interface MachineConfig { | |
| 73 | /** Built-ins first, then the repository's, a same name replacing. */ | |
| 74 | readonly machines: readonly Machine[]; | |
| 75 | /** Crew role to machine name; a role not here runs in `standard`. */ | |
| 76 | readonly byRole: Readonly<Partial<Record<CrewRole, string>>>; | |
| 77 | } | |
| 78 | ||
| 79 | export const NO_CONFIG: MachineConfig = { machines: BUILT_IN, byRole: {} }; | |
| 80 | ||
| 81 | const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/; | |
| 82 | const MACHINES_MAX = 16; | |
| 83 | ||
| 84 | const isObject = (v: unknown): v is Record<string, unknown> => | |
| 85 | typeof v === "object" && v !== null && !Array.isArray(v); | |
| 86 | ||
| 87 | const invalid = (message: string) => err(`forge.json: ${message}`); | |
| 88 | ||
| 89 | /** The machines `forge.json` text declares, validated; absent text is none. */ | |
| 90 | export function parseMachines( | |
| 91 | forgeJson: string | undefined, | |
| 92 | ): Result<MachineConfig, string> { | |
| 93 | if (forgeJson === undefined) return ok(NO_CONFIG); | |
| 94 | let root: unknown; | |
| 95 | try { | |
| 96 | root = JSON.parse(forgeJson); | |
| 97 | } catch { | |
| 98 | // The manifest parser owns the report of a file that is not JSON. | |
| 99 | return ok(NO_CONFIG); | |
| 100 | } | |
| 101 | if (!isObject(root)) return ok(NO_CONFIG); | |
| 102 | const declared = root["machines"]; | |
| 103 | const mapping = root["machineFor"]; | |
| 104 | const own: Machine[] = []; | |
| 105 | if (declared !== undefined) { | |
| 106 | if (!isObject(declared)) | |
| 107 | return invalid("machines must be an object of named machines"); | |
| 108 | const names = Object.keys(declared); | |
| 109 | if (names.length > MACHINES_MAX) | |
| 110 | return invalid(`machines may name at most ${MACHINES_MAX}`); | |
| 111 | for (const name of names) { | |
| 112 | const made = parseMachine(name, declared[name]); | |
| 113 | if (!made.ok) return made; | |
| 114 | own.push(made.value); | |
| 115 | } | |
| 116 | } | |
| 117 | const machines = [ | |
| 118 | ...BUILT_IN.filter((b) => !own.some((m) => m.name === b.name)), | |
| 119 | ...own, | |
| 120 | ]; | |
| 121 | const byRole: Partial<Record<CrewRole, string>> = {}; | |
| 122 | if (mapping !== undefined) { | |
| 123 | if (!isObject(mapping)) | |
| 124 | return invalid("machineFor must map a crew role to a machine name"); | |
| 125 | for (const [role, name] of Object.entries(mapping)) { | |
| 126 | if (!(CREW_ROLES as readonly string[]).includes(role)) | |
| 127 | return invalid(`machineFor "${role}" is not a crew role (${CREW_ROLES.join(", ")})`); | |
| 128 | if (typeof name !== "string" || !machines.some((m) => m.name === name)) | |
| 129 | return invalid( | |
| 130 | `machineFor ${role} names a machine that is not declared (${machines.map((m) => m.name).join(", ")})`, | |
| 131 | ); | |
| 132 | byRole[role as CrewRole] = name; | |
| 133 | } | |
| 134 | } | |
| 135 | return ok({ machines, byRole }); | |
| 136 | } | |
| 137 | ||
| 138 | function parseMachine(name: string, value: unknown): Result<Machine, string> { | |
| 139 | if (!NAME_RE.test(name)) | |
| 140 | return invalid(`machine name "${name.slice(0, 40)}" must be lowercase letters, digits and dashes`); | |
| 141 | if (!isObject(value)) return invalid(`machines.${name} must be an object`); | |
| 142 | const tools = value["tools"] ?? []; | |
| 143 | if (!Array.isArray(tools)) return invalid(`machines.${name}.tools must be a list`); | |
| 144 | for (const tool of tools) | |
| 145 | if (typeof tool !== "string" || !Object.hasOwn(TOOLS, tool)) | |
| 146 | return invalid(`machines.${name}.tools: pick from ${Object.keys(TOOLS).join(", ")}`); | |
| 147 | const reach = value["reach"] ?? ["apps"]; | |
| 148 | if ( | |
| 149 | !Array.isArray(reach) || | |
| 150 | reach.some((r) => !(REACHES as readonly unknown[]).includes(r)) | |
| 151 | ) | |
| 152 | return invalid(`machines.${name}.reach: pick from ${REACHES.join(", ")} (a machine cannot name a host)`); | |
| 153 | const label = value["label"]; | |
| 154 | if (label !== undefined && (typeof label !== "string" || label.length > 80)) | |
| 155 | return invalid(`machines.${name}.label must be short text`); | |
| 156 | return ok({ | |
| 157 | name, | |
| 158 | label: typeof label === "string" && label !== "" ? label : name, | |
| 159 | tools: [...new Set(tools as string[])], | |
| 160 | reach: [...new Set(reach as Reach[])], | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | /** The machine a role runs in: its choice, else the standard one. */ | |
| 165 | export function machineFor(config: MachineConfig, role: CrewRole | undefined): Machine { | |
| 166 | const name = role === undefined ? undefined : config.byRole[role]; | |
| 167 | return config.machines.find((m) => m.name === name) ?? STANDARD; | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * The repository's own app URLs a machine lets the sandbox allow: none for | |
| 172 | * a machine without `apps` reach. Never adds a host. | |
| 173 | */ | |
| 174 | export function reachableApps(machine: Machine, appUrls: readonly string[]): string[] { | |
| 175 | return machine.reach.includes("apps") ? [...appUrls] : []; | |
| 176 | } | |
| 177 | ||
| 178 | /** The run's environment for its machine: names only, nothing secret. */ | |
| 179 | export function machineEnv(machine: Machine): Record<string, string> { | |
| 180 | return { | |
| 181 | FORGE_MACHINE: machine.name, | |
| 182 | FORGE_MACHINE_TOOLS: machine.tools.join(","), | |
| 183 | }; | |
| 184 | } | |
| 185 | ||
| 186 | /** One line for the brief: where the shell runs and what it was given. */ | |
| 187 | export function machineNote(machine: Machine): string | undefined { | |
| 188 | if (machine.tools.length === 0) return undefined; | |
| 189 | return ( | |
| 190 | `Your shell runs in the \`${machine.name}\` machine. Tools it is meant to carry (use those on PATH; none is installed for you, and each may be aimed only at the forge's own copies): ` + | |
| 191 | machine.tools.map((t) => `${t} (${TOOLS[t]})`).join("; ") + | |
| 192 | "." | |
| 193 | ); | |
| 194 | } |
src/crew/machines_read.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * A repository's machines as the default branch declares them (#9418). | |
| 3 | * Read at the default branch's tip, never at the agent's own branch: a run | |
| 4 | * cannot widen the machine it runs in by editing `forge.json`. | |
| 5 | */ | |
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { readFileAt } from "../apps/checkout.ts"; | |
| 8 | import type { Repo } from "../core/repos.ts"; | |
| 9 | import { resolveOid } from "../git/merge.ts"; | |
| 10 | import { repoDir } from "../git/repos.ts"; | |
| 11 | import { NO_CONFIG, parseMachines, type MachineConfig } from "./machines.ts"; | |
| 12 | ||
| 13 | /** The declared machines, or the built-ins when there are none or they are invalid. */ | |
| 14 | export async function repoMachines(app: App, repo: Repo): Promise<MachineConfig> { | |
| 15 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 16 | const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`); | |
The key claim (read at the default tip, not the agent's branch) has no test. Add one with two branches that declare different machines. |
||
| 17 | if (tip === undefined) return NO_CONFIG; | |
| 18 | const parsed = parseMachines(await readFileAt(dir, tip, "forge.json")); | |
| 19 | // A broken declaration must not stop the crew: the standard sandbox runs. | |
| 20 | return parsed.ok ? parsed.value : NO_CONFIG; | |
| 21 | } |
src/crew/runner.ts +9 −2
| @@ -35,6 +35,8 @@ import { previewEnv } from "../apps/data.ts"; | ||
| 35 | 35 | import { appsRuntime } from "../apps/runtime.ts"; |
| 36 | 36 | import { chooseModel, modelFacts } from "./model_choice.ts"; |
| 37 | 37 | import { ownApp, ownAppUrls } from "./app_hosts.ts"; |
| 38 | import { machineEnv, machineFor, reachableApps } from "./machines.ts"; | |
| 39 | import { repoMachines } from "./machines_read.ts"; | |
| 38 | 40 | import { authorsInRepoPull } from "./authors_pull.ts"; |
| 39 | 41 | import { crewRole, getAgent, type Agent } from "../core/agents.ts"; |
| 40 | 42 | import { getAppByRepo, type AppRecord } from "../core/apps.ts"; |
| @@ -1090,9 +1092,13 @@ async function work( | ||
| 1090 | 1092 | : undefined; |
| 1091 | 1093 | const policy = |
| 1092 | 1094 | record === undefined ? undefined : await policyBrief(app, repo); |
| 1095 | // The machine this seat runs in, from the default branch's forge.json. | |
| 1096 | const role = crewRole(app.db, repo.id, agent.user.id); | |
| 1097 | const machine = machineFor(await repoMachines(app, repo), role); | |
The env and the reach narrowing are wired here with no test that would fail if this wiring were removed. |
||
| 1093 | 1098 | const brief = { |
| 1094 | 1099 | agent, |
| 1095 | 1100 | repo, |
| 1101 | machine, | |
| 1096 | 1102 | issue, |
| 1097 | 1103 | pull, |
| 1098 | 1104 | branch, |
| @@ -1107,7 +1113,7 @@ async function work( | ||
| 1107 | 1113 | !reviewing && |
| 1108 | 1114 | previous?.sessionId !== undefined && |
| 1109 | 1115 | previous.sessionId !== "", |
| 1110 | role: crewRole(app.db, repo.id, agent.user.id), | |
| 1116 | role, | |
| 1111 | 1117 | }; |
| 1112 | 1118 | const failedCheck = |
| 1113 | 1119 | reason === "check_failed" && pull |
| @@ -1320,6 +1326,7 @@ async function work( | ||
| 1320 | 1326 | ...passportEnv(app.config.dataDir), |
| 1321 | 1327 | ...credentialEnv(picked), |
| 1322 | 1328 | ...grant.env, |
| 1329 | ...machineEnv(machine), | |
| 1323 | 1330 | ...(options.configDir === undefined |
| 1324 | 1331 | ? {} |
| 1325 | 1332 | : { CLAUDE_CONFIG_DIR: options.configDir }), |
| @@ -1339,7 +1346,7 @@ async function work( | ||
| 1339 | 1346 | dataDir: app.config.dataDir, |
| 1340 | 1347 | agentName: agent.user.username, |
| 1341 | 1348 | urls: [options.localUrl, options.forgeUrl], |
| 1342 | appUrls: ownAppUrls(ownApp(app, repo)), | |
| 1349 | appUrls: reachableApps(machine, ownAppUrls(ownApp(app, repo))), | |
| 1343 | 1350 | extraHosts: [ |
| 1344 | 1351 | ...(options.allowHosts ?? []), |
| 1345 | 1352 | ...hosts, |
test/crew-machines-wiring.test.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * Where machines meet the rest of the crew (#9418): the brief names the | |
| 3 | * machine's tools, and a repository's machines are read at the default | |
| 4 | * branch's tip, never at a side branch an agent could have edited. | |
| 5 | */ | |
| 6 | import { afterAll, expect, test } from "bun:test"; | |
| 7 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import type { App } from "../src/app.ts"; | |
| 11 | import { systemPrompt, type BriefInput } from "../src/crew/brief.ts"; | |
| 12 | import { machineFor, SECURITY, STANDARD } from "../src/crew/machines.ts"; | |
| 13 | import { repoMachines } from "../src/crew/machines_read.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 16 | ||
| 17 | const prompt = (machine: BriefInput["machine"]) => | |
| 18 | systemPrompt({ | |
| 19 | agent: { user: { username: "bot", displayName: "" }, brief: "" }, | |
| 20 | repo: { ownerName: "alice", name: "notes", defaultBranch: "main", autonomyDirection: "" }, | |
| 21 | issue: { number: 7, title: "t", body: "", authorName: "alice" }, | |
| 22 | branch: "bot/7", | |
| 23 | forgeUrl: "http://forge.test", | |
| 24 | isApp: true, | |
| 25 | resuming: false, | |
| 26 | machine, | |
| 27 | } as unknown as BriefInput); | |
| 28 | ||
| 29 | test("the system prompt names a machine's tools, and the standard machine adds nothing", () => { | |
| 30 | expect(prompt(SECURITY)).toContain("Your shell runs in the `security` machine"); | |
| 31 | expect(prompt(SECURITY)).toContain("gitleaks"); | |
| 32 | expect(prompt(STANDARD)).not.toContain("Your shell runs in"); | |
| 33 | expect(prompt(undefined)).toBe(prompt(STANDARD)); | |
| 34 | }); | |
| 35 | ||
| 36 | const env = gitClientEnv({ | |
| 37 | GIT_AUTHOR_NAME: "A", | |
| 38 | GIT_AUTHOR_EMAIL: "[email protected]", | |
| 39 | GIT_COMMITTER_NAME: "A", | |
| 40 | GIT_COMMITTER_EMAIL: "[email protected]", | |
| 41 | }); | |
| 42 | const roots: string[] = []; | |
| 43 | afterAll(() => Promise.all(roots.map((d) => rm(d, { recursive: true, force: true })))); | |
| 44 | ||
| 45 | async function git(args: string[], cwd: string): Promise<void> { | |
| 46 | const p = Bun.spawn(["git", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" }); | |
| 47 | const [err, code] = await Promise.all([new Response(p.stderr).text(), p.exited]); | |
| 48 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 49 | } | |
| 50 | ||
| 51 | test("a repository's machines are read at the default branch's tip, not a side branch's", async () => { | |
| 52 | const root = await mkdtemp(join(tmpdir(), "forge-machines-")); | |
| 53 | roots.push(root); | |
| 54 | const dir = join(root, "alice", "notes.git"); | |
| 55 | await mkdir(dir, { recursive: true }); | |
| 56 | await git(["init", "-q", "-b", "main", dir], dir); | |
| 57 | // A bare repository has no work tree; build commits with plumbing. | |
| 58 | const commit = async (ref: string, text: string) => { | |
| 59 | await writeFile(join(root, "forge.json"), text); | |
| 60 | const blob = await hashObject(dir, join(root, "forge.json")); | |
| 61 | const tree = await plumb(dir, ["mktree"], `100644 blob ${blob}\tforge.json\n`); | |
| 62 | const oid = await plumb(dir, ["commit-tree", tree, "-m", ref], ""); | |
| 63 | await git(["update-ref", `refs/heads/${ref}`, oid], dir); | |
| 64 | }; | |
| 65 | await commit("main", JSON.stringify({ machines: { shut: { reach: [] } }, machineFor: { reviewer: "shut" } })); | |
| 66 | await commit("bot/7", JSON.stringify({ machines: { wide: { tools: ["nmap"] } }, machineFor: { reviewer: "wide" } })); | |
| 67 | ||
| 68 | const app = { reposRoot: root } as unknown as App; | |
| 69 | const repo = { ownerName: "alice", name: "notes", defaultBranch: "main" } as unknown as Repo; | |
| 70 | const config = await repoMachines(app, repo); | |
| 71 | expect(machineFor(config, "reviewer").name).toBe("shut"); | |
| 72 | expect(config.machines.some((m) => m.name === "wide")).toBe(false); | |
| 73 | ||
| 74 | // And the default branch moving is what changes the answer. | |
| 75 | await git(["update-ref", "refs/heads/main", (await plumb(dir, ["rev-parse", "refs/heads/bot/7"], "")) ], dir); | |
| 76 | expect(machineFor(await repoMachines(app, repo), "reviewer").name).toBe("wide"); | |
| 77 | }); | |
| 78 | ||
| 79 | async function plumb(dir: string, args: string[], stdin: string): Promise<string> { | |
| 80 | const p = Bun.spawn(["git", ...args], { cwd: dir, env, stdin: new TextEncoder().encode(stdin), stdout: "pipe", stderr: "pipe" }); | |
| 81 | const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]); | |
| 82 | if (code !== 0) throw new Error(`git ${args.join(" ")}: ${err}`); | |
| 83 | return out.trim(); | |
| 84 | } | |
| 85 | const hashObject = (dir: string, file: string) => plumb(dir, ["hash-object", "-w", file], ""); |
test/crew-machines.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | BUILT_IN, | |
| 4 | machineEnv, | |
| 5 | machineFor, | |
| 6 | machineNote, | |
| 7 | NO_CONFIG, | |
| 8 | parseMachines, | |
| 9 | reachableApps, | |
| 10 | SECURITY, | |
| 11 | STANDARD, | |
| 12 | TOOLS, | |
| 13 | } from "../src/crew/machines.ts"; | |
| 14 | ||
| 15 | const config = (root: unknown) => { | |
| 16 | const parsed = parseMachines(JSON.stringify(root)); | |
| 17 | if (!parsed.ok) throw new Error(parsed.error); | |
| 18 | return parsed.value; | |
| 19 | }; | |
| 20 | const refusal = (root: unknown) => { | |
| 21 | const parsed = parseMachines(JSON.stringify(root)); | |
| 22 | return parsed.ok ? undefined : parsed.error; | |
| 23 | }; | |
| 24 | ||
| 25 | describe("machines", () => { | |
| 26 | test("no forge.json, or none that declares machines, is the built-ins", () => { | |
| 27 | expect(parseMachines(undefined)).toEqual({ ok: true, value: NO_CONFIG }); | |
| 28 | expect(config({ app: {} }).machines).toEqual(BUILT_IN); | |
| 29 | expect(parseMachines("{not json")).toEqual({ ok: true, value: NO_CONFIG }); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a role with no choice runs in the standard machine, which is today's sandbox", () => { | |
| 33 | expect(machineFor(NO_CONFIG, "reviewer")).toBe(STANDARD); | |
| 34 | expect(machineFor(NO_CONFIG, undefined)).toBe(STANDARD); | |
| 35 | expect(STANDARD.tools).toEqual([]); | |
| 36 | expect(STANDARD.reach).toEqual(["apps"]); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a repository picks a machine for a role from the list", () => { | |
| 40 | const c = config({ | |
| 41 | machines: { audit: { tools: ["gitleaks", "gitleaks", "nmap"], reach: [] } }, | |
| 42 | machineFor: { reviewer: "audit", planner: "security" }, | |
| 43 | }); | |
| 44 | const audit = machineFor(c, "reviewer"); | |
| 45 | expect(audit.name).toBe("audit"); | |
| 46 | expect(audit.tools).toEqual(["gitleaks", "nmap"]); | |
| 47 | expect(audit.reach).toEqual([]); | |
| 48 | expect(machineFor(c, "planner")).toBe(SECURITY); | |
| 49 | expect(machineFor(c, "builder")).toBe(STANDARD); | |
| 50 | }); | |
| 51 | ||
| 52 | test("the security machine ships with the investigation tools from the research", () => { | |
| 53 | expect([...SECURITY.tools].sort()).toEqual( | |
| 54 | ["gitleaks", "trivy", "osv-scanner", "nuclei", "zap", "testssl.sh", "nmap"].sort(), | |
| 55 | ); | |
| 56 | for (const tool of SECURITY.tools) expect(TOOLS[tool]).toBeTruthy(); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a machine cannot name a host, a tool off the list, or a role that is not one", () => { | |
| 60 | expect(refusal({ machines: { a: { reach: ["evil.example.com"] } } })).toContain("cannot name a host"); | |
| 61 | expect(refusal({ machines: { a: { reach: ["forge"] } } })).toContain("reach"); | |
| 62 | expect(refusal({ machines: { a: { tools: ["rm -rf /"] } } })).toContain("pick from"); | |
| 63 | expect(refusal({ machines: { a: { tools: ["__proto__"] } } })).toContain("pick from"); | |
| 64 | expect(refusal({ machines: { "A b": {} } })).toContain("lowercase"); | |
| 65 | expect(refusal({ machines: [] })).toContain("object"); | |
| 66 | expect(refusal({ machineFor: { admin: "standard" } })).toContain("not a crew role"); | |
| 67 | expect(refusal({ machineFor: { builder: "nope" } })).toContain("not declared"); | |
| 68 | }); | |
| 69 | ||
| 70 | test("reach only ever narrows the app URLs the sandbox allows", () => { | |
| 71 | const urls = ["https://notes.example", "https://notes--3.example"]; | |
| 72 | expect(reachableApps(STANDARD, urls)).toEqual(urls); | |
| 73 | const closed = config({ machines: { shut: { reach: [] } } }).machines.find((m) => m.name === "shut")!; | |
| 74 | expect(reachableApps(closed, urls)).toEqual([]); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a machine's environment carries names, never a secret", () => { | |
| 78 | expect(machineEnv(SECURITY).FORGE_MACHINE).toBe("security"); | |
| 79 | expect(machineEnv(SECURITY).FORGE_MACHINE_TOOLS).toContain("gitleaks"); | |
| 80 | expect(Object.keys(machineEnv(SECURITY)).sort()).toEqual(["FORGE_MACHINE", "FORGE_MACHINE_TOOLS"]); | |
| 81 | }); | |
| 82 | ||
| 83 | test("the brief names the tools a machine carries, and says nothing for the standard one", () => { | |
| 84 | expect(machineNote(STANDARD)).toBeUndefined(); | |
| 85 | expect(machineNote(SECURITY)).toContain("gitleaks"); | |
| 86 | expect(machineNote(SECURITY)).toContain("forge's own copies"); | |
| 87 | }); | |
| 88 | }); |
new above
offline
This function now sits between promptVersion's doc comment and promptVersion. Move it above that comment.