diff --git a/docs/engines.md b/docs/engines.md index 7512b3a..ba2b288 100644 --- a/docs/engines.md +++ b/docs/engines.md @@ -81,6 +81,13 @@ the broker owns refresh and stale-credential recovery. The runtime checks broker readiness before admitting Grok agents and verifies their sandbox policy before turns. The older credential-lease helper is not the production host path. +The broker worker is pinned to Grok CLI 1.0.34 and runs lean: a fixed Daimon +system prompt, six tools (`run_terminal_command`, `read_file`, `grep`, +`list_dir`, and the MCP meta-tools `search_tool`/`use_tool`), no bundled +skills, workflows, plan mode, subagents, memory or web search, and a declared +model and reasoning effort from a closed list (default `grok-4.6` at `low`). +The broker proxy refuses any request outside that shape before it spends. + AGY uses OS-native secure storage through one private D-Bus and Secret Service realm. Enroll it once with: diff --git a/scripts/liveGrokBrokerSession.ts b/scripts/liveGrokBrokerSession.ts index 092bb9b..05cf30d 100644 --- a/scripts/liveGrokBrokerSession.ts +++ b/scripts/liveGrokBrokerSession.ts @@ -9,7 +9,8 @@ import { terminateChild, trackCliChild } from "../src/pi/cliProcess.ts"; import { decodeGrokHeadlessTurn } from "../src/pi/grokHeadlessResult.ts"; import { readGrokBrokerCredential } from "../src/runtime/grokBrokerCredentialReader.ts"; import { startGrokBrokerProxy } from "../src/runtime/grokBrokerProxy.ts"; -import { renderGrokBrokerWorkerConfig } from "../src/runtime/grokBrokerWorkerConfig.ts"; +import { DEFAULT_GROK_BROKER_MODEL_POLICY } from "../src/runtime/grokBrokerModelPolicy.ts"; +import { GROK_BROKER_PROVIDER_CAPABILITY_ENV, renderGrokBrokerWorkerArgs, renderGrokBrokerWorkerConfigWith } from "../src/runtime/grokBrokerWorkerConfig.ts"; // Explicit live auth/transport check, not the Linux native worker/isolation E2E. // Read the operator credential only in this process; never stage or rotate it. @@ -37,20 +38,19 @@ try { const capability = proxy.capabilities.issue("local-auth-probe", turnId); // This local transport probe deliberately does not attest a native worker. proxy.registerIsolationGuard(turnId, async () => undefined); - const helper = path.join(home, "auth-helper"); - await writeFile(helper, `#!/bin/sh\nprintf '{"access_token":"${capability}","expires_in":600}\\n'\n`, { mode: 0o700 }); // No MCP tools are needed for this exact-reply authentication probe. - await writeFile(path.join(home, "config.toml"), renderGrokBrokerWorkerConfig(helper, proxy.port).split("[mcp_servers.daimon]")[0]); + await writeFile(path.join(home, "config.toml"), renderGrokBrokerWorkerConfigWith(DEFAULT_GROK_BROKER_MODEL_POLICY, { proxyPort: proxy.port, mcpUrl: "http://127.0.0.1:43124/mcp" }).split("[mcp_servers.daimon]")[0]); const prompt = path.join(home, "prompt.txt"); await writeFile(prompt, `Reply exactly ${sentinel}. Do not use tools.`); stage = `model turn ${round}`; - const child = trackCliChild(spawn("grok", [ - "--sandbox", "strict", "--prompt-file", prompt, "--no-memory", "--no-subagents", - "--disable-web-search", "--max-turns", "1", "--permission-mode", "dontAsk", - "--model", "daimon-broker-grok", "--output-format", "streaming-messages-json", - ], { + // The proxy only forwards the lean worker request shape (pinned client + // version, exact tool set, declared effort), so the probe uses the same + // argv as the native launcher with the built-in strict profile. + const args = [...renderGrokBrokerWorkerArgs(prompt, home)].map((value) => value === "daimon-strict" ? "strict" : value); + args[args.indexOf("--max-turns") + 1] = "1"; + const child = trackCliChild(spawn("grok", args, { cwd: home, detached: process.platform !== "win32", - env: { PATH: process.env.PATH, HOME: home, GROK_HOME: home, LANG: "C", LC_ALL: "C", TZ: "UTC" }, + env: { PATH: process.env.PATH, HOME: home, GROK_HOME: home, LANG: "C", LC_ALL: "C", TZ: "UTC", [GROK_BROKER_PROVIDER_CAPABILITY_ENV]: capability }, stdio: ["ignore", "pipe", "pipe"], })); let output: string; diff --git a/scripts/scriptSourcePolicy.test.ts b/scripts/scriptSourcePolicy.test.ts index 1a73f68..bea5b23 100644 --- a/scripts/scriptSourcePolicy.test.ts +++ b/scripts/scriptSourcePolicy.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -37,3 +37,35 @@ test("script source policy detects a maintained JavaScript regression", async () await rm(root, { force: true, recursive: true }); } }); + +// Raw control bytes make review tooling classify a source file as binary and skip it. +// Escape them (`\u0000`) instead; tab, newline and carriage return are the only exceptions. +const RAW_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u; +const textSourcesWithRawControlBytes = async (roots: string[]): Promise => { + const results: string[] = []; + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { if (entry.name !== "artifacts" && entry.name !== "node_modules") await walk(entryPath); continue; } + if (!/\.(?:ts|mts|mjs|js|json|jsonl|md|c|h|inc|toml|sh|yml|yaml)$/u.test(entry.name)) continue; + if (RAW_CONTROL.test(await readFile(entryPath, "latin1"))) results.push(entryPath); + } + }; + await Promise.all(roots.map(walk)); + return results.sort(); +}; + +test("maintained text sources contain no raw control bytes", async () => { + assert.deepEqual(await textSourcesWithRawControlBytes(["src", "scripts", "docs"]), []); +}); + +test("raw control byte policy detects a regression", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-control-policy-")); + try { + await writeFile(path.join(root, "regex.ts"), `export const r = /[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}]/u;\n`); + await writeFile(path.join(root, "clean.ts"), "export const r = /[\\u0000-\\u001f]/u;\n"); + assert.deepEqual(await textSourcesWithRawControlBytes([root]), [path.join(root, "regex.ts")]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/contracts/grokWorkerContract.ts b/src/contracts/grokWorkerContract.ts new file mode 100644 index 0000000..c0cc666 --- /dev/null +++ b/src/contracts/grokWorkerContract.ts @@ -0,0 +1,42 @@ +/** + * Fixed operating contract every broker-launched Grok worker receives through + * `--system-prompt-override`. + * + * The override replaces Grok's ~12k-token coding-agent system prompt and stops + * cwd `AGENTS.md` injection; the agent's identity and instructions still arrive + * in the prompt file. It is compiled into the native launcher byte-for-byte + * (`DBL_GROK_SYSTEM_PROMPT`), pinned by sha256 in the runtime contract + * manifest, and must stay ASCII without quotes or backslashes so the C literal + * needs no escaping. + * + * Daimon's per-wake tools reach Grok as deferred MCP tools named + * `daimon__` (server `[mcp_servers.daimon]`). Naming them lets `use_tool` + * run directly and saves one `search_tool` round trip per tool (P0: 3 → 2 + * requests). + */ +export const DAIMON_GROK_SYSTEM_PROMPT = [ + "You are a headless Daimon agent; no human is present.", + "Your identity, instructions and wake event are in the user prompt.", + "Daimon tools are MCP tools on server daimon: call a known one directly with use_tool (tool_name daimon__moltnet_read, daimon__moltnet_send, daimon__memory_search, daimon__memory_register, or another daimon__ name you were given); use search_tool only for a name you do not know.", + "If a tool result says output was saved to a file, read that path with read_file.", + "If a tool fails, do not retry it in a loop: stop and report the failure.", + "Your final answer is a private note to the runtime: one line, or empty." +].join(" "); + +/** Closed declared-model vocabulary; defaults are `grok-4.6` at `low`. */ +export const GROK_BROKER_MODELS = Object.freeze(["grok-4.6", "grok-4.5", "grok-build"] as const); +export const GROK_BROKER_REASONING_EFFORTS = Object.freeze(["low", "medium", "high"] as const); + +/** `--tools` input ids. These are NOT the model-visible names (see below). */ +export const GROK_WORKER_TOOL_IDS = Object.freeze(["run_terminal_cmd", "read_file", "grep", "list_dir", "search_tool", "use_tool"] as const); + +/** + * The exact tool names a lean worker request body must carry. + * + * Grok 1.0.34 fails open on an unmappable `--tools` entry and ships all 19 + * tools, so the proxy compares every upstream body against this set. + */ +export const GROK_WORKER_VISIBLE_TOOLS = Object.freeze(["grep", "list_dir", "read_file", "run_terminal_command", "search_tool", "use_tool"] as const); + +/** `--max-turns` backstop compiled into the launcher; per-wake ceilings belong to the broker. */ +export const GROK_WORKER_MAX_TURNS = 48 as const; diff --git a/src/contracts/runtimeContractManifest.ts b/src/contracts/runtimeContractManifest.ts index b462d54..20ae6be 100644 --- a/src/contracts/runtimeContractManifest.ts +++ b/src/contracts/runtimeContractManifest.ts @@ -1,4 +1,5 @@ import { WORK_AVAILABILITY_SCHEMA, WORK_BLOCKED_SCHEMA } from "./attentionContract.js"; +import { GROK_BROKER_MODELS, GROK_BROKER_REASONING_EFFORTS, GROK_WORKER_MAX_TURNS, GROK_WORKER_TOOL_IDS, GROK_WORKER_VISIBLE_TOOLS } from "./grokWorkerContract.js"; import { ORGANIZATION_RUNTIME_CONFIG_SCHEMA, ORGANIZATION_RUNTIME_CONFIG_V2_SCHEMA, @@ -34,11 +35,43 @@ export const GROK_ENGINE_BROKER = { providerProxy: { host: "127.0.0.1", port: 43_123 }, mcpFacade: { host: "127.0.0.1", port: 43_124, path: "/mcp" }, identities: { organizationUid: 2_000, brokerUid: 2_100, firstWorkerUid: 2_200 }, + grokCliVersion: "1.0.34", + grokCliBuild: "3736acbc8658", + grokCliArtifacts: { + arm64: { url: "https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-aarch64", sha256: "39ab87666877d64ef3a40aa60fbe0c3b6a6acd7001b78fe60e2c76bb6cfc4a94", bytes: 136_090_504 }, + x64: { url: "https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-1.0.34-linux-x86_64", sha256: "be5905e107d2b8b5f3c142d21ecfe4c8fd32a913d2fd551b788707930c4dc80d", bytes: 163_035_648 } + }, + worker: { + modelId: "daimon-broker-grok", + models: GROK_BROKER_MODELS, + reasoningEfforts: GROK_BROKER_REASONING_EFFORTS, + defaultModel: "grok-4.6", + defaultReasoningEffort: "low", + toolIds: GROK_WORKER_TOOL_IDS, + visibleTools: GROK_WORKER_VISIBLE_TOOLS, + maxTurns: GROK_WORKER_MAX_TURNS, + systemPromptSha256: "2c31c0085a54a4efbf9c0cf0b8124c56e47f38691b7f0c7fa233a74abaa8ddf8", + // sha256 of `renderGrokBrokerWorkerConfig({ model, reasoningEffort })`, the only accepted config.toml bytes. + configSha256: { + "grok-4.6": { low: "eed6a451150a72b2cb528b30c23b3d51c7d3bc38c67a8985d4dcdf956ff214d3", medium: "8850502dbebf8918c5161c63efcc4ccf18719488300f4cec1deceb2c112b451f", high: "3ce44ace503362326b47149b528b942ce638fe146313d62502f248acf9c7333d" }, + "grok-4.5": { low: "7aa13e90b9bc08d1a018f48b7a84de1dab41db586627ee2d5a25f69011ba7e25", medium: "218ba37e57a6f02fa36b265b4e154e68e30bd2d4794feb130cc226fdda7732a9", high: "0bb4ad8bfa5062169b28422d1d534b45420d4e46b1e546bda1c578eb34303646" }, + "grok-build": { low: "83ac7202442286a65c359cc596b0b8db7bc4529ee70e98224f6cd6f66deb6878", medium: "0146313f28739888eb4e861f1bfb285f7ee4e0a9164669256ebdf6492a2790ce", high: "bbe72aaf70c417dc7007823a7e9e1a7d1fa8d57e50bde6f24036083b32bcc859" } + }, + // Worker `GROK_HOME` layout the broker attests before every turn. The home and + // its `sessions/` directory are root-owned, worker-group writable and sticky so + // Grok can create its own state but never replace a root-owned file. + home: { + directory: { uid: 0, group: "worker", mode: 0o1771 }, + sessionsDirectory: { relativePath: "sessions", uid: 0, group: "worker", mode: 0o1771 }, + readOnlyFiles: { names: ["config.toml", "managed_config.toml", "requirements.toml", "sandbox.toml", "trusted_folders.toml"], uid: 0, gid: 0, mode: 0o444 }, + sandboxEvents: { relativePath: "sessions/sandbox-events.jsonl", owner: "worker", group: "broker", mode: 0o640 } + } + }, bounds: { promptBytes: 65_536, capabilityBytes: 4_096, capabilityBundleBytes: 8_196, outputBytes: 65_536 }, artifacts: { - sourceSha256: "bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58", - x64Sha256: "e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd", - arm64Sha256: "ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d" + sourceSha256: "36f60689f0a8af0e3108f5f53d78ed52b7d4b6f934c75b6184606dfa82bc741e", + x64Sha256: "36dc76b134eb59cf5a6720b6f94228eb279108e20ea3343fa6efd9ffcb60a4d3", + arm64Sha256: "c93216cc6fa4ca50dc404fe41e68da9150a869b14f46eb42484ae77c3aa400a9" } } as const; export const AGY_SUBSCRIPTION_REALM = { diff --git a/src/pi/cliMcpRegistration.test.ts b/src/pi/cliMcpRegistration.test.ts index f725b3d..efa1776 100644 --- a/src/pi/cliMcpRegistration.test.ts +++ b/src/pi/cliMcpRegistration.test.ts @@ -5,18 +5,9 @@ import { renderAgyArgs } from "./cliEngineSpawn.js"; import { DAIMON_MCP_SERVER_NAME, renderAgyMcpAddArgs, - renderAgyMcpRemoveArgs, - renderGrokMcpAddArgs, - renderGrokMcpRemoveArgs + renderAgyMcpRemoveArgs } from "./cliMcpRegistration.js"; -test("Grok's registration arguments are unchanged by the AGY generalization", () => { - assert.deepEqual(renderGrokMcpAddArgs([], "strict", "http://127.0.0.1:1/mcp"), - ["--sandbox", "strict", "mcp", "add", "--transport", "http", "--scope", "project", "daimon", "http://127.0.0.1:1/mcp"]); - assert.deepEqual(renderGrokMcpRemoveArgs([], "strict"), - ["--sandbox", "strict", "mcp", "remove", "--scope", "project", "daimon"]); -}); - test("AGY registers the per-wake endpoint as an http server, flags before the name", () => { const args = renderAgyMcpAddArgs([], "http://127.0.0.1:54321/mcp"); assert.deepEqual(args, ["mcp", "add", "--type", "http", DAIMON_MCP_SERVER_NAME, "http://127.0.0.1:54321/mcp"]); diff --git a/src/pi/cliMcpRegistration.ts b/src/pi/cliMcpRegistration.ts index 83d5993..0d5118f 100644 --- a/src/pi/cliMcpRegistration.ts +++ b/src/pi/cliMcpRegistration.ts @@ -2,16 +2,17 @@ import { spawn, type ChildProcess } from "node:child_process"; import { readChild } from "./cliChildOutput.js"; import { terminateChild, trackCliChild } from "./cliProcess.js"; -import { renderGrokSandboxArgs } from "./cliEngineSpawn.js"; /** * Per-wake MCP endpoint registration for the CLI engines that cannot take the * endpoint on their own command line. * * Codex takes `-c mcp_servers.daimon.url=` per invocation and needs - * nothing here. Grok and AGY are both config-file driven, so Daimon registers - * the ephemeral endpoint before the turn and removes it afterwards, through - * each CLI's own `mcp add`/`mcp remove` subcommands. + * nothing here. AGY is config-file driven, so Daimon registers the ephemeral + * endpoint before the turn and removes it afterwards through its own `mcp + * add`/`mcp remove` subcommands. Grok no longer uses this path: its project + * scope is ignored in untrusted workspaces on 1.0.34, so its endpoint is written + * into the agent's Daimon-owned GROK_HOME (`grokHomeMcpRegistration.ts`). * * The registration is deliberately performed by the engine CLI rather than by * writing its config file directly: the file format belongs to the engine, and @@ -90,15 +91,9 @@ export const registerCliMcpServer = async ( }; }; -/** The MCP server name both engines register Daimon's per-wake endpoint under. */ +/** The MCP server name every CLI engine registers Daimon's per-wake endpoint under. */ export const DAIMON_MCP_SERVER_NAME = "daimon" as const; -export const renderGrokMcpAddArgs = (commandArgs: readonly string[] | undefined, profile: string, endpoint: string): string[] => - [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "add", "--transport", "http", "--scope", "project", DAIMON_MCP_SERVER_NAME, endpoint]; - -export const renderGrokMcpRemoveArgs = (commandArgs: readonly string[] | undefined, profile: string): string[] => - [...renderGrokSandboxArgs(commandArgs, profile), "mcp", "remove", "--scope", "project", DAIMON_MCP_SERVER_NAME]; - /** * `agy mcp add --type http `. * diff --git a/src/pi/cliSession.test.ts b/src/pi/cliSession.test.ts index 3e76d9d..4c9a489 100644 --- a/src/pi/cliSession.test.ts +++ b/src/pi/cliSession.test.ts @@ -471,16 +471,16 @@ test("disposing from a Server.prototype.listen interleaving never leaves an MCP } }); -test("disposing during Grok registration terminates setup before the engine starts", async (context) => { +test("disposing during AGY MCP registration terminates setup before the engine starts", async (context) => { if (!requirePosixProcessGroups(context)) return; - const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-grok-cancel-")); + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-cli-agy-cancel-")); const ready = path.join(root, "add-ready"); const marker = path.join(root, "engine-started"); const grok = path.join(root, "grok.mjs"); await writeFile(grok, `import { writeFileSync } from "node:fs"; const args = process.argv.slice(2); if (args.includes("add")) { writeFileSync(${JSON.stringify(ready)}, "ready"); process.on("SIGTERM", () => undefined); setInterval(() => undefined, 1000); } else if (args.includes("remove")) process.exit(0); else writeFileSync(${JSON.stringify(marker)}, "started");`); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 + command: process.execPath, commandArgs: [grok], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); const pending = session.prompt("cancel"); void pending.catch(() => undefined); diff --git a/src/pi/cliSession.ts b/src/pi/cliSession.ts index ac5982a..07b964c 100644 --- a/src/pi/cliSession.ts +++ b/src/pi/cliSession.ts @@ -18,7 +18,6 @@ import type { TurnUsageOutcome } from "../runtime/turnUsageLedger.js"; import { readChild } from "./cliChildOutput.js"; import { cliChildEnvironment } from "./cliEnvironment.js"; import { - GROK_STRICT_SANDBOX_PROFILE, renderCodexArgs, spawnEngine } from "./cliEngineSpawn.js"; @@ -26,10 +25,9 @@ import { registerCliMcpServer, renderAgyMcpAddArgs, renderAgyMcpRemoveArgs, - renderGrokMcpAddArgs, - renderGrokMcpRemoveArgs, type CliMcpRegistration } from "./cliMcpRegistration.js"; +import { registerGrokHomeMcpServer } from "./grokHomeMcpRegistration.js"; import { decodeAgyHeadlessTurn, type AgyTurnUsage } from "./agyHeadlessResult.js"; import { type CodexTurnUsage } from "./codexHeadlessResult.js"; import { createCliTurnMeter, decodeCodexTurn, failedTurnOutcome, publishTurnRequests, publishTurnUsage } from "./cliTurnMetering.js"; @@ -321,35 +319,33 @@ class CliSession implements PiSessionLike { const controller=new AbortController();this.activeBrokerTurn=controller; try{output=await this.options.grokBrokerTurn(`${this.options.identityPrompt ?? ""}${text}`,mount.endpoint,controller.signal);}finally{if(this.activeBrokerTurn===controller)this.activeBrokerTurn=undefined;} } else { - if ((this.options.engine === "grok" || this.options.engine === "agy") && mount !== undefined) { + if (this.options.engine === "grok" && mount !== undefined) { + await this.options.verifyExecutable?.(); + registration = await registerGrokHomeMcpServer({ + engineHomePath: this.options.engineHomePath, + endpoint: mount.endpoint, + ...(this.options.verifyGrokSandbox !== undefined ? { verify: this.options.verifyGrokSandbox } : {}) + }); + this.mcpRegistration = registration; + } else if (this.options.engine === "agy" && mount !== undefined) { await this.options.verifyExecutable?.(); - const profile = this.options.grokSandboxProfile ?? GROK_STRICT_SANDBOX_PROFILE; - const grok = this.options.engine === "grok"; registration = await registerCliMcpServer({ - addArgs: grok - ? renderGrokMcpAddArgs(this.options.commandArgs, profile, mount.endpoint) - : renderAgyMcpAddArgs(this.options.commandArgs, mount.endpoint), - removeArgs: grok - ? renderGrokMcpRemoveArgs(this.options.commandArgs, profile) - : renderAgyMcpRemoveArgs(this.options.commandArgs), + addArgs: renderAgyMcpAddArgs(this.options.commandArgs, mount.endpoint), + removeArgs: renderAgyMcpRemoveArgs(this.options.commandArgs), command: this.options.command ?? this.options.engine, cwd: this.input.cwd, env: cliChildEnvironment([ ...(this.options.redactedEnvironmentNames ?? []), ...(this.input.daimonSecretEnvironmentNames ?? []) ], this.input.runtimeHomePath, { - ...(this.options.engine === "agy" && this.options.dbusSessionBusAddress !== undefined - ? { dbusSessionBusAddress: this.options.dbusSessionBusAddress } - : {}), + ...(this.options.dbusSessionBusAddress !== undefined ? { dbusSessionBusAddress: this.options.dbusSessionBusAddress } : {}), engine: this.options.engine, executablePath: this.options.command, engineHomePath: this.options.engineHomePath }), - ...(grok ? { failureClassifier: classifyGrokAuthenticationDiagnostic } : {}), onChild: (setupChild) => { this.setupChildren.add(setupChild); }, onChildSettled: (setupChild) => this.setupChildren.delete(setupChild), - secretValues, - ...(grok && this.options.verifyGrokSandbox !== undefined ? { verify: this.options.verifyGrokSandbox } : {}) + secretValues }); this.mcpRegistration = registration; } diff --git a/src/pi/cliSessionOutput.test.ts b/src/pi/cliSessionOutput.test.ts index 2dc92ac..3e6ec4a 100644 --- a/src/pi/cliSessionOutput.test.ts +++ b/src/pi/cliSessionOutput.test.ts @@ -98,7 +98,7 @@ test("Grok replies redact both the staged credential and a credential rotated du let reads = 0; try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [engine], engine: "grok", + command: process.execPath, commandArgs: [engine], engine: "grok", engineHomePath: path.join(root, ".grok"), credentialSecretValues: async () => ++reads === 1 ? [oldSecret] : [rotatedSecret] })({ cwd: root }); let emitted = ""; @@ -123,7 +123,7 @@ test("Grok auth rejection is typed and never retains raw credential diagnostics" await writeFile(engine, `const a=process.argv.slice(2);if(a.includes("mcp"))process.stdout.write("ok");else{process.stderr.write("Authentication rejected by server ${secret}");process.exitCode=7;}`); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [engine], engine: "grok", + command: process.execPath, commandArgs: [engine], engine: "grok", engineHomePath: path.join(root, ".grok"), credentialSecretValues: async () => [secret] })({ cwd: root }); await assert.rejects(session.prompt("work"), (error: unknown) => { @@ -146,7 +146,7 @@ test("Grok auth rejection is classified before a long secret and verbose tail ar ].join("\n")); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [engine], engine: "grok", + command: process.execPath, commandArgs: [engine], engine: "grok", engineHomePath: path.join(root, ".grok"), credentialSecretValues: async () => [secret] })({ cwd: root }); await assert.rejects(session.prompt("work"), (error: unknown) => { diff --git a/src/pi/cliSessionProcess.test.ts b/src/pi/cliSessionProcess.test.ts index 937b347..7b150c9 100644 --- a/src/pi/cliSessionProcess.test.ts +++ b/src/pi/cliSessionProcess.test.ts @@ -6,10 +6,9 @@ import test from "node:test"; import { createCliSessionFactory, readChild, spawnEngine, terminateChild } from "./cliSession.js"; -const grokStream = (text: string): string => [ - { type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text }] } }, - { type: "result", subtype: "success", is_error: false, result: text, stop_reason: "end_turn", session_id: "fake" } -].map((event) => JSON.stringify(event)).join("\n"); +// Only AGY still registers its per-wake MCP endpoint through setup/removal +// children; Grok writes it into its own GROK_HOME (`grokHomeMcpRegistration.ts`). +const agyStream = (text: string): string => JSON.stringify({ event: "result", result: { conversation_id: "fake", status: "SUCCESS", response: text, num_turns: 1, usage: { input_tokens: 1, output_tokens: 1, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 2 } } }); test("terminates a process group after its leader has exited", async (context) => { if (!requirePosixProcessGroups(context)) return; @@ -29,9 +28,9 @@ test("terminates a process group after its leader has exited", async (context) = } }); -test("Grok setup reaps a stubborn descendant after its successful leader exits", async (context) => { +test("AGY MCP setup reaps a stubborn descendant after its successful leader exits", async (context) => { if (!requirePosixProcessGroups(context)) return; - const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-setup-group-")); + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-agy-setup-group-")); const descendant = path.join(root, "setup-descendant-pid"); const grok = path.join(root, "grok.mjs"); await writeFile(grok, `import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; @@ -41,10 +40,10 @@ test("Grok setup reaps a stubborn descendant after its successful leader exits", child.stdout.once("data", () => { writeFileSync(${JSON.stringify(descendant)}, String(child.pid)); process.exit(0); }); } if (args.includes("remove")) process.exit(0); - process.stdout.write(${JSON.stringify(grokStream("engine complete"))});`); + process.stdout.write(${JSON.stringify(agyStream("engine complete"))});`); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 + command: process.execPath, commandArgs: [grok], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); await session.prompt("research"); const pid = Number(await readFile(descendant, "utf8")); @@ -56,9 +55,9 @@ test("Grok setup reaps a stubborn descendant after its successful leader exits", } }); -test("Grok removal reaps a stubborn descendant after its successful leader exits", async (context) => { +test("AGY MCP removal reaps a stubborn descendant after its successful leader exits", async (context) => { if (!requirePosixProcessGroups(context)) return; - const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-group-")); + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-agy-remove-group-")); const descendant = path.join(root, "remove-descendant-pid"); const grok = path.join(root, "grok.mjs"); await writeFile(grok, `import { spawn } from "node:child_process"; import { writeFileSync } from "node:fs"; @@ -66,10 +65,10 @@ test("Grok removal reaps a stubborn descendant after its successful leader exits if (args.includes("remove")) { const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => undefined); process.stdout.write('ready'); setInterval(() => undefined, 1000)"], { stdio: ["ignore", "pipe", "ignore"] }); child.stdout.once("data", () => { writeFileSync(${JSON.stringify(descendant)}, String(child.pid)); process.exit(0); }); - } else if (args.includes("add")) process.exit(0); else process.stdout.write(${JSON.stringify(grokStream("engine complete"))});`); + } else if (args.includes("add")) process.exit(0); else process.stdout.write(${JSON.stringify(agyStream("engine complete"))});`); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 + command: process.execPath, commandArgs: [grok], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); await session.prompt("research"); const pid = Number(await readFile(descendant, "utf8")); diff --git a/src/pi/cliSessionRemoval.test.ts b/src/pi/cliSessionRemoval.test.ts index aa880ac..f8320f1 100644 --- a/src/pi/cliSessionRemoval.test.ts +++ b/src/pi/cliSessionRemoval.test.ts @@ -1,23 +1,21 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { createCliSessionFactory } from "./cliSession.js"; -import { GrokSubscriptionAuthenticationRejectedError } from "../runtime/grokAuthenticationError.js"; -const grokStream = (text: string): string => [ - { type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text }] } }, - { type: "result", subtype: "success", is_error: false, result: text, stop_reason: "end_turn", session_id: "fake" } -].map((event) => JSON.stringify(event)).join("\n"); +// Grok has no removal child any more (its endpoint lives in GROK_HOME config), +// so removal-failure semantics are exercised through AGY's `mcp remove`. +const agyStream = (text: string): string => JSON.stringify({ event: "result", result: { conversation_id: "fake", status: "SUCCESS", response: text, num_turns: 1, usage: { input_tokens: 1, output_tokens: 1, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 2 } } }); -test("Grok removal failure rejects without emitting a successful turn", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-failure-")); +test("MCP removal failure rejects without emitting a successful turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-agy-remove-failure-")); const grok = path.join(root, "grok.mjs"); - await writeFile(grok, `const args = process.argv.slice(2); if (args.includes("remove")) process.exit(23); else if (args.includes("add")) process.exit(0); else process.stdout.write(${JSON.stringify(grokStream("engine complete"))});`); + await writeFile(grok, `const args = process.argv.slice(2); if (args.includes("remove")) process.exit(23); else if (args.includes("add")) process.exit(0); else process.stdout.write(${JSON.stringify(agyStream("engine complete"))});`); try { - const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); + const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "agy", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); let turns = 0; session.subscribe((event) => { if (event.type === "turn_end") turns += 1; }); await assert.rejects(session.prompt("research"), /CLI engine exited 23/); @@ -29,43 +27,27 @@ test("Grok removal failure rejects without emitting a successful turn", async () } }); -test("Grok authentication rejection keeps precedence over bounded removal failure", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-auth-remove-failure-")); +test("Grok direct sessions register the per-wake MCP endpoint in GROK_HOME, never through a project-scoped child", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-home-mcp-")); const grok = path.join(root, "grok.mjs"); - const authSecret = "auth-rejection-secret-canary"; - const cleanupSecret = "cleanup-secret-canary"; - await writeFile(grok, `const args=process.argv.slice(2);if(args.includes("remove")){process.stderr.write("cleanup ${cleanupSecret}");process.exit(23)}else if(args.includes("add"))process.exit(0);else{process.stderr.write("RefreshTokenRejected ${authSecret}");process.exit(7)}`); + const engineHomePath = path.join(root, "home", ".grok"); + const seen = path.join(root, "seen"); + await writeFile(grok, `import { appendFileSync, readFileSync } from "node:fs"; const args = process.argv.slice(2); appendFileSync(${JSON.stringify(seen)}, args.includes("mcp") ? "MCP-CHILD\\n" : readFileSync(${JSON.stringify(path.join(engineHomePath, "config.toml"))}, "utf8")); process.stdout.write(${JSON.stringify([{ type: "assistant", parent_tool_use_id: null, session_id: "fake", message: { role: "assistant", stop_reason: "end_turn", content: [{ type: "text", text: "done" }] } }, { type: "result", subtype: "success", is_error: false, result: "done", stop_reason: "end_turn", session_id: "fake" }].map((event) => JSON.stringify(event)).join("\n"))});`); try { - const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, - timeoutMs: 10_000, credentialSecretValues: async () => [authSecret, cleanupSecret] - })({ cwd: root }); - await assert.rejects(session.prompt("research"), (error: unknown) => { - assert.ok(error instanceof GrokSubscriptionAuthenticationRejectedError); - assert.doesNotMatch(error.message, /canary|cleanup|RefreshTokenRejected/u); - assert.ok(error.cause instanceof Error); - assert.match(error.cause.message, /CLI engine exited 23/u); - assert.doesNotMatch(error.cause.message, /canary|cleanup-secret/u); - assert.ok(Buffer.byteLength(error.cause.message) < 1_024); - return true; - }); - } finally { await rm(root, { recursive: true, force: true }); } -}); + const { session } = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000, engineHomePath })({ cwd: root }); + await session.prompt("research"); + const during = await readFile(seen, "utf8"); + assert.doesNotMatch(during, /MCP-CHILD/u); + assert.match(during, /\[mcp_servers\.daimon\]\nurl = "http:\/\/127\.0\.0\.1:\d+\/mcp"/u); + assert.match(during, /\[skills\]\ndisabled = \[/u); + const after = await readFile(path.join(engineHomePath, "config.toml"), "utf8"); + assert.doesNotMatch(after, /mcp_servers/u); + await session.disposeAsync?.(); -test("Grok removal auth rejection is typed before verbose diagnostics are truncated", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-remove-auth-rejection-")); - const grok = path.join(root, "grok.mjs"); - const secret = `refresh-start-${"r".repeat(1970)}-refresh-end`; - await writeFile(grok, `const a=process.argv.slice(2);if(a.includes("remove")){process.stderr.write("RefreshTokenRejected "+${JSON.stringify(secret)}+" "+"tail".repeat(250));process.exit(23)}else if(a.includes("add"))process.exit(0);else process.stdout.write(${JSON.stringify(grokStream("complete"))});`); - try { - const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", - credentialSecretValues: async () => [secret] - })({ cwd: root }); - await assert.rejects(session.prompt("research"), (error: unknown) => { - assert.ok(error instanceof GrokSubscriptionAuthenticationRejectedError); - assert.doesNotMatch(error.message, /RefreshToken|refresh-start|refresh-end|r{32}|tail/u); - return true; - }); - } finally { await rm(root, { recursive: true, force: true }); } + const unowned = await createCliSessionFactory({ command: process.execPath, commandArgs: [grok], engine: "grok", maxToolTurns: 1, timeoutMs: 10_000 })({ cwd: root }); + await assert.rejects(unowned.session.prompt("research"), /Daimon-owned GROK_HOME/u); + await unowned.session.disposeAsync?.(); + } finally { + await rm(root, { recursive: true, force: true }); + } }); diff --git a/src/pi/grokHeadlessResult.test.ts b/src/pi/grokHeadlessResult.test.ts index 01d309b..376162e 100644 --- a/src/pi/grokHeadlessResult.test.ts +++ b/src/pi/grokHeadlessResult.test.ts @@ -77,7 +77,7 @@ test("exit-zero cancelled Grok sessions reject without emitting a turn", async ( ].join("\n")); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", timeoutMs: 10_000 + command: process.execPath, commandArgs: [grok], engine: "grok", engineHomePath: path.join(root, ".grok"), timeoutMs: 10_000 })({ cwd: root }); let turns = 0; session.subscribe((event) => { if (event.type === "turn_end") turns += 1; }); @@ -108,7 +108,7 @@ test("successful Grok sessions emit only decoded terminal text", async () => { ].join("\n")); try { const { session } = await createCliSessionFactory({ - command: process.execPath, commandArgs: [grok], engine: "grok", timeoutMs: 10_000 + command: process.execPath, commandArgs: [grok], engine: "grok", engineHomePath: path.join(root, ".grok"), timeoutMs: 10_000 })({ cwd: root }); let reply = ""; session.subscribe((event) => { diff --git a/src/pi/grokHomeMcpRegistration.ts b/src/pi/grokHomeMcpRegistration.ts new file mode 100644 index 0000000..2c1fcef --- /dev/null +++ b/src/pi/grokHomeMcpRegistration.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, mkdir, open, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { renderGrokLeanBaseConfig } from "../runtime/grokBrokerWorkerConfig.js"; +import type { CliMcpRegistration } from "./cliMcpRegistration.js"; + +/** + * Per-wake MCP registration for the direct (non-broker) Grok CLI path. + * + * `grok mcp add --scope project` writes `/.grok/config.toml`, which Grok + * 1.0.34 skips entirely in an untrusted workspace — and Daimon keeps every + * workspace untrusted so cwd `AGENTS.md` and project skills never load. The + * endpoint therefore goes into the agent's own Daimon-owned `GROK_HOME` + * config, rendered from the same lean base the broker worker uses + * (`renderGrokLeanBaseConfig`), and is removed again after the turn by + * rewriting the base alone. + * + * There is no fallback to the operator's `~/.grok`: without an explicit + * `engineHomePath` the session refuses, rather than editing a human's config. + */ +export async function registerGrokHomeMcpServer(input: Readonly<{ engineHomePath: string | undefined; endpoint: string; verify?: () => Promise }>): Promise { + const home = input.engineHomePath; + if (home === undefined || !path.isAbsolute(home)) { + throw new Error("Grok direct CLI sessions require a Daimon-owned GROK_HOME (engineHomePath): Grok 1.0.34 ignores project-scoped MCP servers in untrusted workspaces"); + } + if (!/^http:\/\/127\.0\.0\.1:\d{1,5}\/[A-Za-z0-9/_-]*$/u.test(input.endpoint)) throw new Error("Grok MCP endpoint must be a loopback http URL"); + await input.verify?.(); + await writeConfig(home, `${renderGrokLeanBaseConfig()}[mcp_servers.daimon]\nurl = ${JSON.stringify(input.endpoint)}\n`); + let closePromise: Promise | undefined; + return { + close: (): Promise => closePromise ??= (async () => { + await input.verify?.(); + await writeConfig(home, renderGrokLeanBaseConfig()); + })() + }; +} + +async function writeConfig(home: string, text: string): Promise { + await mkdir(home, { recursive: true, mode: 0o700 }); + const directory = await lstat(home); + if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("Grok engine home is not a private directory"); + const target = path.join(home, "config.toml"); + const temporary = path.join(home, `.config.toml.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); + await handle.writeFile(text); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, target); + } finally { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(() => undefined); + } +} diff --git a/src/pi/grokSandbox.test.ts b/src/pi/grokSandbox.test.ts index d8bd5d0..b34f194 100644 --- a/src/pi/grokSandbox.test.ts +++ b/src/pi/grokSandbox.test.ts @@ -45,7 +45,8 @@ test("rejects a protected root overlapping the selected agent workspace", async test("rotates its private enforcement receipt before the bounded log is exhausted", async () => { const fixture = await createFixture(); try { - const events = path.join(fixture.engineHomePath, "sandbox-events.jsonl"); + const events = path.join(fixture.engineHomePath, "sessions", "sandbox-events.jsonl"); + await mkdir(path.dirname(events), { mode: 0o700 }); await writeFile(events, "x".repeat(8 * 1024 * 1024), { mode: 0o600 }); await prepareAndVerifyGrokSandbox(fixture.authority); assert.ok((await readFile(events)).byteLength < 64 * 1024); @@ -75,8 +76,8 @@ const profile=fs.readFileSync(path.join(home,"sandbox.toml"),"utf8"); const deny=JSON.parse(profile.split("\\n").find((line)=>line.startsWith("deny = ")).slice(7)); const observed=${JSON.stringify(mode)}==="drop-deny"?deny.slice(1):deny; const event={event_type:"ProfileApplied",profile:"${GROK_DAIMON_SANDBOX_PROFILE}",workspace:fs.realpathSync(args[args.indexOf("--cwd")+1]),platform:"linux/landlock",enforced:${JSON.stringify(mode)}!=="unenforced",restrict_network:true,deny_paths:observed}; -fs.appendFileSync(path.join(home,"sandbox-events.jsonl"),JSON.stringify(event)+"\\n",{mode:0o600}); -fs.chmodSync(path.join(home,"sandbox-events.jsonl"),0o600); +fs.appendFileSync(path.join(home,"sessions","sandbox-events.jsonl"),JSON.stringify(event)+"\\n",{mode:0o600}); +fs.chmodSync(path.join(home,"sessions","sandbox-events.jsonl"),0o600); `); await chmod(command, 0o700); return { diff --git a/src/pi/grokSandbox.ts b/src/pi/grokSandbox.ts index 956095b..ddc9ee4 100644 --- a/src/pi/grokSandbox.ts +++ b/src/pi/grokSandbox.ts @@ -1,17 +1,19 @@ import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { constants } from "node:fs"; -import { lstat, open, realpath, rename, unlink } from "node:fs/promises"; +import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises"; import path from "node:path"; import { readChild } from "./cliChildOutput.js"; import { cliChildEnvironment } from "./cliEnvironment.js"; import { terminateChild, trackCliChild } from "./cliProcess.js"; import { renderGrokSandboxArgs } from "./cliEngineSpawn.js"; +import { GROK_WORKER_SANDBOX_EVENTS_RELATIVE_PATH, GROK_WORKER_SANDBOX_PROFILE, renderGrokWorkerSandboxProfile } from "../runtime/grokWorkerSandboxProfile.js"; -export const GROK_DAIMON_SANDBOX_PROFILE = "daimon-strict"; +export const GROK_DAIMON_SANDBOX_PROFILE = GROK_WORKER_SANDBOX_PROFILE; const SANDBOX_CONFIG = "sandbox.toml"; -const SANDBOX_EVENTS = "sandbox-events.jsonl"; +/** Grok 1.0.34 logs sandbox events under `sessions/`; the root file stays empty. */ +const SANDBOX_EVENTS = GROK_WORKER_SANDBOX_EVENTS_RELATIVE_PATH; const MAX_EVENTS_BYTES = 16 * 1024 * 1024; const ROTATE_EVENTS_BYTES = 8 * 1024 * 1024; @@ -61,13 +63,7 @@ export async function prepareAndVerifyGrokSandbox( await verifyProfile(engineHome, denied); } -const profileText = (denied: readonly string[]): string => [ - `[profiles.${GROK_DAIMON_SANDBOX_PROFILE}]`, - 'extends = "strict"', - "restrict_network = true", - `deny = [${denied.map((entry) => JSON.stringify(entry)).join(", ")}]`, - "" -].join("\n"); +const profileText = (denied: readonly string[]): string => renderGrokWorkerSandboxProfile(denied); async function writeProfile(engineHome: string, denied: readonly string[]): Promise { const target = path.join(engineHome, SANDBOX_CONFIG); @@ -155,6 +151,7 @@ async function eventFileSize(file: string): Promise { return Number(entry.size); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await mkdir(path.dirname(file), { mode: 0o700, recursive: true }); const handle = await open(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow(), 0o600); try { await handle.sync(); } finally { await handle.close(); } await syncDirectory(path.dirname(file)); diff --git a/src/runtime/AGENTS.md b/src/runtime/AGENTS.md index 93723c1..4ddaa1b 100644 --- a/src/runtime/AGENTS.md +++ b/src/runtime/AGENTS.md @@ -13,11 +13,44 @@ coordinate wakes. Every source file stays below 400 lines. Keep tests beside the contract they cover. -`grokBrokerProxyRequest.ts` preserves the worker CLI's bounded -`x-grok-client-version` and supplies its `grok-shell` client identity when -rebuilding provider headers. Dropping the version makes the subscription -provider reject an otherwise valid login with HTTP 426; never replace it with -a fabricated version or pass arbitrary worker headers through. +`grokBrokerProxyRequest.ts` preserves the worker CLI's `x-grok-client-version` +and supplies its `grok-shell` client identity when rebuilding provider headers. +Dropping the version makes the subscription provider reject an otherwise valid +login with HTTP 426; never replace it with a fabricated version or pass +arbitrary worker headers through. The version must equal the pinned +`GROK_ENGINE_BROKER.grokCliVersion` (1.0.34) exactly. + +The proxy is also the spend gate for the lean Grok worker. Before a bearer is +attached it refuses any body whose tool names are not exactly +`GROK_WORKER_VISIBLE_TOOLS` (Grok 1.0.34 turns an unmappable `--tools` entry +into its full 19-tool set, and its `session_title` request carries one forced +tool), and any body whose `model`/`reasoning_effort` differ from the declared +`grokBrokerModelPolicy.ts` policy (closed lists; default `grok-4.6`/`low`). The +model override header follows that declaration. + +`grokBrokerWorkerConfig.ts` is the only source of worker `config.toml` bytes; +the manifest pins the sha256 of every model/effort combination and the broker +refuses a turn whose worker config does not hash to the declared one. Three +1.0.34 facts shape it, each verified against a loopback stub model: +`[auth_provider.*]` helpers never run for a custom model, so the turn's proxy +capability reaches the model through `env_key = "DAIMON_PROVIDER_CAPABILITY"` +set by the native launcher (as exposed as `DAIMON_MCP_CAPABILITY`); the +per-turn `session_title` request cannot be disabled by any key, so +`[models] session_summary` points it at a hidden model on closed loopback port +9; and effort is only sent when the model declares it, so the declared effort is +the model's single `reasoning_efforts` entry. HTTP MCP needs CA certificates in +the image even for a loopback `http://` URL ("Failed to build HTTP client"). + +Worker `GROK_HOME` layout the deployment must provision (attested before every +turn by `grokWorkerHomeAttestation.ts`, recorded in `GROK_ENGINE_BROKER.worker.home`): +`$GROK_HOME` and `$GROK_HOME/sessions` `root: 1771`; `config.toml`, +`sandbox.toml`, `trusted_folders.toml` (empty), `managed_config.toml` (empty) +and `requirements.toml` (empty) `root:root 0444`; and +`sessions/sandbox-events.jsonl` `: 0640`. Grok 1.0.34 writes its +sandbox events there (the root `sandbox-events.jsonl` stays empty) and runs +every profile inside bubblewrap, where a non-empty `deny` list is enforced; +`grokWorkerSandboxProfile.ts` renders those profile bytes. A worker-uid process +can neither write, rename, nor unlink any of the root-owned files. `agySubscriptionRealm.ts` owns the one host-level private D-Bus/Secret Service realm, durable keyring lease, bounded unlock stdin, and cleanup. @@ -28,10 +61,13 @@ runtime-writable home without clobbering a newer CLI-refreshed credential. `grokSubscriptionRealm.ts` owns the single durable rotating Grok credential, the lifetime lease, crash journal, stale fence, and serialized per-turn stage/promote cycle while each agent retains private non-auth home state. -`../pi/grokSandbox.ts` owns the production Grok process boundary: it replaces -the provider's fail-open built-in profile with an exact custom profile denying -the realm, bootstrap, and peer roots, and requires a kernel-enforcement event -before every Grok setup, turn, and cleanup process. +`../pi/grokSandbox.ts` owns the direct (non-broker) Grok process boundary: it +replaces the provider's fail-open built-in profile with an exact custom profile +denying the realm, bootstrap, and peer roots, and requires a kernel-enforcement +event (read from `$GROK_HOME/sessions/sandbox-events.jsonl`) before every Grok +turn. The direct path registers its per-wake MCP endpoint in the agent's +Daimon-owned `GROK_HOME` config (`../pi/grokHomeMcpRegistration.ts`), because +1.0.34 skips project-scoped MCP servers in untrusted workspaces. Strict Codex uses its native permission profile only for model-run local commands: the profile denies current `.codex/auth.json`, current `.daimon-inbound`, `/proc`, `/run`, shared protected stores, and peer roots diff --git a/src/runtime/engineBrokerCapabilities.test.ts b/src/runtime/engineBrokerCapabilities.test.ts index af9835f..b29fc54 100644 --- a/src/runtime/engineBrokerCapabilities.test.ts +++ b/src/runtime/engineBrokerCapabilities.test.ts @@ -13,3 +13,11 @@ test("proxy resolves scope from opaque token without caller identity", () => { const capabilities = new EngineBrokerCapabilities(); const token = capabilities.issue("agent-a", "turn-a"); assert.deepEqual(capabilities.authorizeToken(token), { agentId: "agent-a", turnId: "turn-a" }); }); + +test("the live proxy lookups refuse an expired capability", async () => { + const capabilities = new EngineBrokerCapabilities(); const token = capabilities.issue("agent-a", "turn-a", 20, 64); + assert.deepEqual(capabilities.inspectToken(token), { agentId: "agent-a", turnId: "turn-a" }); + await new Promise((resolve) => setTimeout(resolve, 40)); + assert.equal(capabilities.inspectToken(token), undefined); + assert.equal(capabilities.authorizeToken(token), undefined); +}); diff --git a/src/runtime/engineBrokerServiceCli.test.ts b/src/runtime/engineBrokerServiceCli.test.ts index 7e5ff97..5d11cf6 100644 --- a/src/runtime/engineBrokerServiceCli.test.ts +++ b/src/runtime/engineBrokerServiceCli.test.ts @@ -11,5 +11,8 @@ test("rejects caller-selected commands, duplicate identities, and traversal",()= assert.throws(()=>parseEngineBrokerServiceConfig({...base,grokCommand:"evil"})); assert.throws(()=>parseEngineBrokerServiceConfig({...base,turnStore:"/var/lib/../secret"})); assert.throws(()=>parseEngineBrokerServiceConfig({...base,registrations:[reg("agent-a",0),reg("agent-a",1)]})); + // Grok 1.0.34 logs sandbox events under $GROK_HOME/sessions/; the 1.0.13 root path stays empty and must not be attested. + assert.throws(()=>parseEngineBrokerServiceConfig({...base,registrations:[{...reg("agent-a",0),eventsPath:"/workers/0/.grok/sandbox-events.jsonl"}]})); + assert.throws(()=>parseEngineBrokerServiceConfig({...base,registrations:[{...reg("agent-a",0),eventsPath:"/workers/1/.grok/sessions/sandbox-events.jsonl"}]})); }); -const reg=(agentId:string,slot:number)=>({agentId,slot,workerUid:2200+slot,workspace:`/workspace/${slot}`,profilePath:`/workers/${slot}/.grok/sandbox.toml`,eventsPath:`/workers/${slot}/.grok/sandbox-events.jsonl`,profileSha256:"a".repeat(64)}); +const reg=(agentId:string,slot:number)=>({agentId,slot,workerUid:2200+slot,workspace:`/workspace/${slot}`,profilePath:`/workers/${slot}/.grok/sandbox.toml`,eventsPath:`/workers/${slot}/.grok/sessions/sandbox-events.jsonl`,profileSha256:"a".repeat(64)}); diff --git a/src/runtime/engineBrokerServiceCli.ts b/src/runtime/engineBrokerServiceCli.ts index bbc89cd..d70839c 100644 --- a/src/runtime/engineBrokerServiceCli.ts +++ b/src/runtime/engineBrokerServiceCli.ts @@ -2,6 +2,7 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; import { startEngineBrokerService } from "./engineBrokerService.js"; import { startGrokEngineBroker, type GrokEngineBrokerRegistration } from "./grokEngineBroker.js"; +import { grokWorkerEventsPathFor } from "./grokWorkerSandboxProfile.js"; export const ENGINE_BROKER_SERVICE_CONFIG = "/etc/daimon-engine-broker/service.json"; const MAX_CONFIG_BYTES=65_536; @@ -19,7 +20,7 @@ export function parseEngineBrokerServiceConfig(value:unknown):Readonly<{credenti if(value===null||typeof value!=="object"||Array.isArray(value))throw new TypeError("invalid engine broker service config");const input=value as Record; if(Object.keys(input).length!==4||input.version!=="noopolis.daimon.engine-broker-service.v1"||typeof input.credentialHome!=="string"||typeof input.turnStore!=="string"||!Array.isArray(input.registrations))throw new TypeError("invalid engine broker service config"); const absolute=(item:string)=>item.startsWith("/")&&!item.includes("/../")&&!item.endsWith("/..");if(!absolute(input.credentialHome)||!absolute(input.turnStore))throw new TypeError("invalid engine broker service config"); - const seen=new Set();const registrations=input.registrations.map((entry)=>{if(entry===null||typeof entry!=="object"||Array.isArray(entry))throw new TypeError("invalid engine broker service config");const item=entry as Record;if(Object.keys(item).length!==7||typeof item.agentId!=="string"||!item.agentId.trim()||!Number.isSafeInteger(item.slot)||(item.slot as number)<0||!Number.isSafeInteger(item.workerUid)||(item.workerUid as number)<2200||typeof item.workspace!=="string"||!absolute(item.workspace)||typeof item.profilePath!=="string"||!absolute(item.profilePath)||typeof item.eventsPath!=="string"||!absolute(item.eventsPath)||typeof item.profileSha256!=="string"||!/^[a-f0-9]{64}$/u.test(item.profileSha256)||item.profilePath!==`${item.eventsPath.replace(/\/sandbox-events\.jsonl$/u,"")}/sandbox.toml`||seen.has(item.agentId))throw new TypeError("invalid engine broker service config");seen.add(item.agentId);return {agentId:item.agentId,slot:item.slot as number,workerUid:item.workerUid as number,workspace:item.workspace,profilePath:item.profilePath,eventsPath:item.eventsPath,profileSha256:item.profileSha256};}); + const seen=new Set();const registrations=input.registrations.map((entry)=>{if(entry===null||typeof entry!=="object"||Array.isArray(entry))throw new TypeError("invalid engine broker service config");const item=entry as Record;if(Object.keys(item).length!==7||typeof item.agentId!=="string"||!item.agentId.trim()||!Number.isSafeInteger(item.slot)||(item.slot as number)<0||!Number.isSafeInteger(item.workerUid)||(item.workerUid as number)<2200||typeof item.workspace!=="string"||!absolute(item.workspace)||typeof item.profilePath!=="string"||!absolute(item.profilePath)||typeof item.eventsPath!=="string"||!absolute(item.eventsPath)||typeof item.profileSha256!=="string"||!/^[a-f0-9]{64}$/u.test(item.profileSha256)||item.eventsPath!==grokWorkerEventsPathFor(item.profilePath)||!item.profilePath.endsWith("/sandbox.toml")||seen.has(item.agentId))throw new TypeError("invalid engine broker service config");seen.add(item.agentId);return {agentId:item.agentId,slot:item.slot as number,workerUid:item.workerUid as number,workspace:item.workspace,profilePath:item.profilePath,eventsPath:item.eventsPath,profileSha256:item.profileSha256};}); if(registrations.length===0)throw new TypeError("invalid engine broker service config");return {credentialHome:input.credentialHome,turnStore:input.turnStore,registrations}; } diff --git a/src/runtime/fixtures/grok-1.0.34-sandbox-events.jsonl b/src/runtime/fixtures/grok-1.0.34-sandbox-events.jsonl new file mode 100644 index 0000000..a728cef --- /dev/null +++ b/src/runtime/fixtures/grok-1.0.34-sandbox-events.jsonl @@ -0,0 +1,2 @@ +{"timestamp":"2026-09-17T02:33:59.302698678Z","event_type":"ProfileApplied","profile":"daimon-strict","workspace":"/var/lib/spawnfile/instance/workspace/agents/a1","platform":"linux/landlock","enforced":true,"restrict_network":true,"read_write_paths":["/var/lib/spawnfile/instance/workspace/agents/a1","/var/lib/daimon-workers/2200/.grok/sessions","/tmp","/var/tmp"],"read_only_paths":["/usr","/lib","/bin","/sbin","/etc","/dev","/proc","/sys","/tmp","/run","/var","/var/lib/spawnfile/instance/workspace/agents/a1","/var/lib/daimon-workers/2200/.grok"],"deny_paths":["/run/paideia"]} +{"timestamp":"2026-09-17T02:33:59.913077091Z","event_type":"FsViolation","profile":"daimon-strict","operation":"read","target":"/run/paideia/context.json"} diff --git a/src/runtime/grokBrokerModelPolicy.ts b/src/runtime/grokBrokerModelPolicy.ts new file mode 100644 index 0000000..266a1d7 --- /dev/null +++ b/src/runtime/grokBrokerModelPolicy.ts @@ -0,0 +1,32 @@ +/** + * The closed model and reasoning-effort vocabulary a Grok broker worker may be + * declared with. + * + * Both halves of one declaration are consumed from this single parser: the + * worker `config.toml` renderer (`grokBrokerWorkerConfig.ts`) writes them into + * the worker's only custom model, and the provider proxy + * (`grokBrokerProxyRequest.ts`) refuses any request body that does not carry + * exactly them. Nothing is inherited: Grok 1.0.34 silently drops an effort for + * a model that does not declare effort support, and its embedded catalog + * default for `grok-4.6` is `high`. + */ +import { GROK_BROKER_MODELS, GROK_BROKER_REASONING_EFFORTS } from "../contracts/grokWorkerContract.js"; + +export { GROK_BROKER_MODELS, GROK_BROKER_REASONING_EFFORTS }; +export type GrokBrokerModel = (typeof GROK_BROKER_MODELS)[number]; +export type GrokBrokerReasoningEffort = (typeof GROK_BROKER_REASONING_EFFORTS)[number]; +export type GrokBrokerModelPolicy = Readonly<{ model: GrokBrokerModel; reasoningEffort: GrokBrokerReasoningEffort }>; + +export const DEFAULT_GROK_BROKER_MODEL_POLICY: GrokBrokerModelPolicy = Object.freeze({ model: "grok-4.6", reasoningEffort: "low" }); + +export function parseGrokBrokerModelPolicy(value: unknown = {}): GrokBrokerModelPolicy { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("invalid Grok broker model policy"); + const input = value as Record; + if (Object.keys(input).some((key) => key !== "model" && key !== "reasoningEffort")) throw new TypeError("invalid Grok broker model policy"); + const model = input.model ?? DEFAULT_GROK_BROKER_MODEL_POLICY.model; + const reasoningEffort = input.reasoningEffort ?? DEFAULT_GROK_BROKER_MODEL_POLICY.reasoningEffort; + if (!(GROK_BROKER_MODELS as readonly unknown[]).includes(model) || !(GROK_BROKER_REASONING_EFFORTS as readonly unknown[]).includes(reasoningEffort)) { + throw new TypeError("invalid Grok broker model policy"); + } + return Object.freeze({ model: model as GrokBrokerModel, reasoningEffort: reasoningEffort as GrokBrokerReasoningEffort }); +} diff --git a/src/runtime/grokBrokerProxy.test.ts b/src/runtime/grokBrokerProxy.test.ts index 5f2a362..6c541b6 100644 --- a/src/runtime/grokBrokerProxy.test.ts +++ b/src/runtime/grokBrokerProxy.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; +import { request as httpRequest } from "node:http"; import test from "node:test"; import { startGrokBrokerProxy } from "./grokBrokerProxy.js"; +import { GROK_SESSION_TITLE_SINK_KEY } from "./grokBrokerWorkerConfig.js"; + +const lean = ["run_terminal_command", "read_file", "list_dir", "grep", "search_tool", "use_tool"].map((name) => ({ type: "function", function: { name } })); +const leanBody = (overrides: Record = {}): string => JSON.stringify({ model: "grok-4.6", reasoning_effort: "low", stream: true, messages: [], tools: lean, ...overrides }); test("proxy retries one 401 with refreshed broker bearer and shuts down", async () => { const calls: string[] = []; let refreshes = 0; @@ -9,19 +14,74 @@ test("proxy retries one 401 with refreshed broker bearer and shuts down", async }); const token = proxy.capabilities.issue("agent", "turn"); proxy.registerIsolationGuard("turn", async () => undefined); - const result = await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "x-grok-client-version": "1.0.25" }, body: JSON.stringify({ stream: true, messages: [] }) }); + const result = await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "x-grok-client-version": "1.0.34" }, body: leanBody() }); assert.equal(result.status, 200); assert.equal(await result.text(), "data: done\n\n"); assert.deepEqual(calls, ["Bearer first", "Bearer second"]); assert.equal(refreshes, 0); await proxy.close(); await assert.rejects(fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`)); }); -test("proxy stale-fences a refreshed credential rejected by upstream",async()=>{let rejected=0;const proxy=await startGrokBrokerProxy({accessToken:async(force)=>force?"second":"first",markRejected:async()=>{rejected++;throw new Error("stale");}},async()=>({status:401,headers:{"content-type":"application/json"},body:new Uint8Array()}));const token=proxy.capabilities.issue("agent","turn");proxy.registerIsolationGuard("turn",async()=>undefined);const response=await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`,{method:"POST",headers:{authorization:`Bearer ${token}`,"x-grok-client-version":"1.0.25"},body:JSON.stringify({stream:true,messages:[]})});assert.equal(response.status,503);assert.equal(rejected,1);await proxy.close();}); +test("proxy stale-fences a refreshed credential rejected by upstream",async()=>{let rejected=0;const proxy=await startGrokBrokerProxy({accessToken:async(force)=>force?"second":"first",markRejected:async()=>{rejected++;throw new Error("stale");}},async()=>({status:401,headers:{"content-type":"application/json"},body:new Uint8Array()}));const token=proxy.capabilities.issue("agent","turn");proxy.registerIsolationGuard("turn",async()=>undefined);const response=await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`,{method:"POST",headers:{authorization:`Bearer ${token}`,"x-grok-client-version":"1.0.34"},body:leanBody()});assert.equal(response.status,503);assert.equal(rejected,1);await proxy.close();}); test("proxy failures expose only a fixed diagnostic", async () => { const proxy = await startGrokBrokerProxy({ accessToken: async () => { throw new Error("secret-token"); }, markRejected: async () => undefined }, async () => { throw new Error("unreachable"); }); const token = proxy.capabilities.issue("agent", "turn"); proxy.registerIsolationGuard("turn", async () => undefined); - const result = await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${token}`, "x-grok-client-version": "1.0.25" }, body: JSON.stringify({ stream: true, messages: [] }) }); + const result = await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`, { method: "POST", headers: { authorization: `Bearer ${token}`, "x-grok-client-version": "1.0.34" }, body: leanBody() }); assert.equal(result.status, 503); const body = await result.text(); assert.equal(body, '{"error":"broker unavailable"}'); assert.doesNotMatch(body, /secret/u); await proxy.close(); }); -test("one turn capability supports multiple guarded cognition requests",async()=>{let guarded=0,calls=0;const proxy=await startGrokBrokerProxy({accessToken:async()=>"provider-token",markRejected:async()=>undefined},async()=>{calls++;return{status:200,headers:{"content-type":"application/json"},body:Buffer.from("{}")};});try{const token=proxy.capabilities.issue("agent","turn");proxy.registerIsolationGuard("turn",async()=>{guarded++;});for(let index=0;index<2;index++){const response=await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`,{method:"POST",headers:{authorization:`Bearer ${token}`,"x-grok-client-version":"1.0.25"},body:JSON.stringify({stream:true,messages:[]})});assert.equal(response.status,200);}assert.equal(calls,2);assert.equal(guarded,2);}finally{await proxy.close();}}); +test("one turn capability supports multiple guarded cognition requests",async()=>{let guarded=0,calls=0;const proxy=await startGrokBrokerProxy({accessToken:async()=>"provider-token",markRejected:async()=>undefined},async()=>{calls++;return{status:200,headers:{"content-type":"application/json"},body:Buffer.from("{}")};});try{const token=proxy.capabilities.issue("agent","turn");proxy.registerIsolationGuard("turn",async()=>{guarded++;});for(let index=0;index<2;index++){const response=await fetch(`http://127.0.0.1:${proxy.port}/v1/chat/completions`,{method:"POST",headers:{authorization:`Bearer ${token}`,"x-grok-client-version":"1.0.34"},body:leanBody()});assert.equal(response.status,200);}assert.equal(calls,2);assert.equal(guarded,2);}finally{await proxy.close();}}); + +test("proxy refuses a fail-open tool set or an undeclared effort without calling upstream", async () => { + let calls = 0; let accessed = 0; + const proxy = await startGrokBrokerProxy({ accessToken: async () => { accessed++; return "provider-token"; }, markRejected: async () => undefined }, async () => { calls++; return { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from("{}") }; }, { model: "grok-4.6", reasoningEffort: "low" }); + try { + const token = proxy.capabilities.issue("agent", "turn"); proxy.registerIsolationGuard("turn", async () => undefined); + const full = [...lean, ...["search_replace", "todo_write", "write", "monitor"].map((name) => ({ type: "function", function: { name } }))]; + for (const payload of [leanBody({ tools: full }), leanBody({ tools: [{ type: "function", function: { name: "session_title" } }] }), leanBody({ reasoning_effort: "high" }), leanBody({ reasoning_effort: undefined })]) { + assert.equal(await post(proxy.port, token, payload), 503); + } + assert.equal(calls, 0); + assert.equal(await post(proxy.port, token, leanBody()), 200); assert.equal(calls, 1); assert.ok(accessed >= 1); + } finally { await proxy.close(); } +}); + +// One unpooled connection per request: the proxy listens on a fixed port that the +// previous test just closed, and a pooled keep-alive socket to it would be stale. +function post(port: number, token: string, body: string): Promise { + return new Promise((resolve, reject) => { + const req = httpRequest({ host: "127.0.0.1", port, path: "/v1/chat/completions", method: "POST", agent: false, headers: { authorization: `Bearer ${token}`, "x-grok-client-version": "1.0.34", "content-type": "application/json" } }, (response) => { response.resume(); response.on("end", () => resolve(response.statusCode ?? 0)); }); + req.on("error", reject); req.end(body); + }); +} + +test("the session-title sink is refused before capability, guard, credential, or upstream use", async () => { + let calls = 0, accessed = 0, guarded = 0; + const proxy = await startGrokBrokerProxy({ accessToken: async () => { accessed++; return "provider-token"; }, markRejected: async () => undefined }, async () => { calls++; return { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from("{}") }; }); + try { + const token = proxy.capabilities.issue("agent", "turn", 60_000, 1); proxy.registerIsolationGuard("turn", async () => { guarded++; }); + const title = JSON.stringify({ model: "disabled", max_tokens: 100, temperature: 0, stream: true, messages: [{ role: "user", content: "prompt-derived" }], tool_choice: { type: "function", function: { name: "session_title" } }, tools: [{ type: "function", function: { name: "session_title" } }] }); + assert.equal(await post(proxy.port, GROK_SESSION_TITLE_SINK_KEY, title), 503); + assert.deepEqual({ calls, accessed, guarded }, { calls: 0, accessed: 0, guarded: 0 }); + // The turn capability (budget 1 request) is untouched and still serves the real request. + assert.equal(await post(proxy.port, token, leanBody()), 200); + assert.equal(calls, 1); + } finally { await proxy.close(); } +}); + +test("the isolation guard is awaited before the first upstream call, and a failing guard makes no upstream call", async () => { + const order: string[] = []; let upstreamCalls = 0; let fail = true; + const proxy = await startGrokBrokerProxy({ accessToken: async () => { order.push("credential"); return "provider-token"; }, markRejected: async () => undefined }, async () => { upstreamCalls++; order.push("upstream"); return { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from("{}") }; }); + try { + const token = proxy.capabilities.issue("agent", "turn"); + proxy.registerIsolationGuard("turn", async () => { + order.push("guard-start"); await new Promise((resolve) => setTimeout(resolve, 30)); order.push("guard-end"); + if (fail) throw new Error("no enforcement evidence"); + }); + assert.equal(await post(proxy.port, token, leanBody()), 503); + assert.equal(upstreamCalls, 0); + assert.deepEqual(order, ["guard-start", "guard-end"]); + fail = false; order.length = 0; + assert.equal(await post(proxy.port, token, leanBody()), 200); + assert.deepEqual(order, ["guard-start", "guard-end", "credential", "upstream"]); + } finally { await proxy.close(); } +}); diff --git a/src/runtime/grokBrokerProxy.ts b/src/runtime/grokBrokerProxy.ts index 4dee112..023a474 100644 --- a/src/runtime/grokBrokerProxy.ts +++ b/src/runtime/grokBrokerProxy.ts @@ -2,24 +2,26 @@ import { createHash } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; +import { DEFAULT_GROK_BROKER_MODEL_POLICY, parseGrokBrokerModelPolicy, type GrokBrokerModelPolicy } from "./grokBrokerModelPolicy.js"; import { authorizeGrokBrokerProxyRequest } from "./grokBrokerProxyRequest.js"; export type GrokBrokerCredentialAuthority = Readonly<{ accessToken(forceRefresh: boolean): Promise; refreshAfterRejection?(rejectedTokenDigest:string):Promise; markRejected(rejectedTokenDigest?:string): Promise }>; export type GrokBrokerUpstream = (request: ReturnType) => Promise>; body: Uint8Array }>>; -export async function startGrokBrokerProxy(authority: GrokBrokerCredentialAuthority, upstream: GrokBrokerUpstream = defaultUpstream): PromisePromise):void; revokeIsolationGuard(turnId:string):void; close(): Promise }>> { - const capabilities = new EngineBrokerCapabilities(); - const guards=new MapPromise>();const server = createServer((request, response) => { void serve(request, response, authority, upstream, capabilities,guards); }); +/** `policy` is the declared model/effort every forwarded body must carry (closed list; defaults grok-4.6/low). */ +export async function startGrokBrokerProxy(authority: GrokBrokerCredentialAuthority, upstream: GrokBrokerUpstream = defaultUpstream, policy: GrokBrokerModelPolicy = DEFAULT_GROK_BROKER_MODEL_POLICY): PromisePromise):void; revokeIsolationGuard(turnId:string):void; close(): Promise }>> { + const declared = parseGrokBrokerModelPolicy(policy); const capabilities = new EngineBrokerCapabilities(); + const guards=new MapPromise>();const server = createServer((request, response) => { void serve(request, response, authority, upstream, capabilities,guards,declared); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(43_123, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address() as AddressInfo; return { port: address.port, capabilities,registerIsolationGuard(turnId,guard){guards.set(turnId,guard);},revokeIsolationGuard(turnId){guards.delete(turnId);}, close: () => new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error))) }; } -async function serve(request: IncomingMessage, response: ServerResponse, authority: GrokBrokerCredentialAuthority, upstream: GrokBrokerUpstream, capabilities: EngineBrokerCapabilities,guards:MapPromise>): Promise { +async function serve(request: IncomingMessage, response: ServerResponse, authority: GrokBrokerCredentialAuthority, upstream: GrokBrokerUpstream, capabilities: EngineBrokerCapabilities,guards:MapPromise>,policy:GrokBrokerModelPolicy): Promise { try { const body = await readBody(request); const headers = Object.fromEntries(Object.entries(request.headers).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])); const match=headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{40,})$/u),scope=match?capabilities.inspectToken(match[1]!):undefined;if(!scope)throw new Error();const guard=guards.get(scope.turnId);if(!guard)throw new Error();await guard(); - let token = await authority.accessToken(false);const rejectedDigest=createHash("sha256").update(token).digest("hex"); let prepared = authorizeGrokBrokerProxyRequest({ method: request.method ?? "", pathname: new URL(request.url ?? "/", "http://127.0.0.1").pathname, headers, body }, capabilities, token); token = ""; + let token = await authority.accessToken(false);const rejectedDigest=createHash("sha256").update(token).digest("hex"); let prepared = authorizeGrokBrokerProxyRequest({ method: request.method ?? "", pathname: new URL(request.url ?? "/", "http://127.0.0.1").pathname, headers, body }, capabilities, token, policy); token = ""; let result = await upstream(prepared); if (result.status === 401) { token = authority.refreshAfterRejection?await authority.refreshAfterRejection(rejectedDigest):await authority.accessToken(true);const refreshedDigest=createHash("sha256").update(token).digest("hex"); prepared = { ...prepared, headers: { ...prepared.headers, authorization: `Bearer ${token}` } }; token = ""; result = await upstream(prepared);if(result.status===401)await authority.markRejected(refreshedDigest); } response.writeHead(result.status, { "content-type": result.headers["content-type"] ?? "application/json", "cache-control": "no-store" }); response.end(result.body); diff --git a/src/runtime/grokBrokerProxyRequest.test.ts b/src/runtime/grokBrokerProxyRequest.test.ts index 462c140..7cdcdfb 100644 --- a/src/runtime/grokBrokerProxyRequest.test.ts +++ b/src/runtime/grokBrokerProxyRequest.test.ts @@ -3,28 +3,107 @@ import test from "node:test"; import { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; import { authorizeGrokBrokerProxyRequest } from "./grokBrokerProxyRequest.js"; -const body = Buffer.from(JSON.stringify({ stream: true, messages: [] })); - -test("proxy substitutes broker bearer, forwards bounded Grok client version, and rejects arbitrary routes and headers", () => { +const leanTools = ["run_terminal_command", "read_file", "list_dir", "grep", "search_tool", "use_tool"]; +const tool = (name: string) => ({ type: "function", function: { name, parameters: { type: "object" } } }); +const leanBody = (overrides: Record = {}): Buffer => Buffer.from(JSON.stringify({ model: "grok-4.6", reasoning_effort: "low", stream: true, messages: [], tools: leanTools.map(tool), ...overrides })); +const request = (body: Uint8Array, headers: Record = {}) => { const caps = new EngineBrokerCapabilities(); const opaque = caps.issue("a", "t"); - const request = authorizeGrokBrokerProxyRequest({ method: "POST", pathname: "/v1/chat/completions", headers: { authorization: `Bearer ${opaque}`, cookie: "forbidden", "x-grok-client-version": "1.0.25", "x-grok-client-identifier": "attacker" }, body, agentId: "a", turnId: "t" }, caps, "real-bearer"); - assert.equal(request.url, "https://cli-chat-proxy.grok.com/v1/chat/completions"); - assert.equal(request.headers.authorization, "Bearer real-bearer"); - assert.equal(request.headers["x-grok-client-version"], "1.0.25"); - assert.equal(request.headers["x-grok-client-identifier"], "grok-shell"); - assert.equal("cookie" in request.headers, false); - assert.throws(() => authorizeGrokBrokerProxyRequest({ method: "GET", pathname: "/", headers: { authorization: `Bearer ${opaque}`, "x-grok-client-version": "1.0.25" }, body, agentId: "a", turnId: "t" }, caps, "real-bearer"), /rejected/); -}); - -test("proxy fails closed when the worker omits or malforms the Grok client version", () => { - for (const version of [undefined, "", "1", "1.0", "v1.0.25", "1.0.25\nInjected: yes", "1.0.25+build", `1.2.3-${"a".repeat(65)}`] as const) { - const caps = new EngineBrokerCapabilities(); const opaque = caps.issue("a", "t"); - assert.throws(() => authorizeGrokBrokerProxyRequest({ method: "POST", pathname: "/v1/chat/completions", headers: { authorization: `Bearer ${opaque}`, ...(version === undefined ? {} : { "x-grok-client-version": version }) }, body, agentId: "a", turnId: "t" }, caps, "real-bearer"), /rejected/); + return { caps, input: { method: "POST", pathname: "/v1/chat/completions", headers: { authorization: `Bearer ${opaque}`, "x-grok-client-version": "1.0.34", ...headers }, body, agentId: "a", turnId: "t" } }; +}; + +test("proxy substitutes broker bearer, forwards the pinned client version, and rejects arbitrary routes and headers", () => { + const { caps, input } = request(leanBody(), { cookie: "forbidden", "x-grok-client-identifier": "attacker", "x-grok-model-override": "grok-build" }); + const upstream = authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"); + assert.equal(upstream.url, "https://cli-chat-proxy.grok.com/v1/chat/completions"); + assert.equal(upstream.headers.authorization, "Bearer real-bearer"); + assert.equal(upstream.headers["x-grok-client-version"], "1.0.34"); + assert.equal(upstream.headers["x-grok-client-identifier"], "grok-shell"); + assert.equal(upstream.headers["x-grok-model-override"], "grok-4.6"); + assert.equal("cookie" in upstream.headers, false); + assert.throws(() => authorizeGrokBrokerProxyRequest({ ...input, method: "GET", pathname: "/" }, caps, "real-bearer"), /rejected/); +}); + +test("proxy fails closed on any client version other than the pinned Grok CLI", () => { + for (const version of [undefined, "", "1", "1.0", "v1.0.34", "1.0.13", "1.0.25", "1.0.33", "1.0.35", "1.0.34-beta.1", "1.0.34\nInjected: yes", "1.0.34+build"] as const) { + const { caps, input } = request(leanBody(), { "x-grok-client-version": version }); + assert.throws(() => authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"), /rejected/, String(version)); } }); -test("proxy accepts prerelease Grok client versions", () => { - const caps = new EngineBrokerCapabilities(); const opaque = caps.issue("a", "t"); - const request = authorizeGrokBrokerProxyRequest({ method: "POST", pathname: "/v1/chat/completions", headers: { authorization: `Bearer ${opaque}`, "x-grok-client-version": "1.0.25-beta.1" }, body, agentId: "a", turnId: "t" }, caps, "real-bearer"); - assert.equal(request.headers["x-grok-client-version"], "1.0.25-beta.1"); +test("proxy refuses a request carrying Grok's fail-open full tool set before any upstream call", () => { + const full = [...leanTools, "search_replace", "kill_command_or_subagent", "todo_write", "get_command_or_subagent_output", "spawn_subagent", "scheduler_create", "scheduler_delete", "scheduler_list", "monitor", "workflow", "enter_plan_mode", "exit_plan_mode", "write"]; + for (const tools of [full.map(tool), [tool("session_title")], leanTools.slice(1).map(tool), [...leanTools, "use_tool"].map(tool), [...leanTools.slice(1), "run_terminal_cmd"].map(tool), undefined, [], leanTools.map((name) => ({ type: "custom", function: { name } }))]) { + const { caps, input } = request(leanBody({ tools })); + assert.throws(() => authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"), /rejected/); + } +}); + +test("proxy refuses a reasoning effort or model other than the declared policy", () => { + for (const overrides of [{ reasoning_effort: "high" }, { reasoning_effort: undefined }, { reasoning_effort: "xhigh" }, { model: "grok-build" }, { model: "daimon-broker-grok" }]) { + const { caps, input } = request(leanBody(overrides)); + assert.throws(() => authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"), /rejected/, JSON.stringify(overrides)); + } + const { caps, input } = request(leanBody({ model: "grok-build", reasoning_effort: "medium" })); + const upstream = authorizeGrokBrokerProxyRequest(input, caps, "real-bearer", { model: "grok-build", reasoningEffort: "medium" }); + assert.equal(upstream.headers["x-grok-model-override"], "grok-build"); + const other = request(leanBody()); + assert.throws(() => authorizeGrokBrokerProxyRequest(other.input, other.caps, "real-bearer", { model: "grok-3" as never, reasoningEffort: "low" }), /model policy/); +}); + +test("proxy forwards exactly the validated object, so duplicate members cannot smuggle a different tool set upstream", () => { + const full = [...leanTools, "search_replace", "kill_command_or_subagent", "todo_write", "get_command_or_subagent_output", "spawn_subagent", "scheduler_create", "scheduler_delete", "scheduler_list", "monitor", "workflow", "enter_plan_mode", "exit_plan_mode", "write"]; + const lean = leanTools.map(tool); + // First `tools`/`reasoning_effort`/`model` are the fail-open values; JSON.parse keeps the last (lean) ones. + const smuggled = `{"model":"grok-build","reasoning_effort":"high","tools":${JSON.stringify(full.map(tool))},"model":"grok-4.6","reasoning_effort":"low","stream":true,"messages":[],"stream_options":{"include_usage":true},"tools":${JSON.stringify(lean)}}`; + const { caps, input } = request(Buffer.from(smuggled)); + const upstream = authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"); + const canonical = JSON.stringify({ model: "grok-4.6", reasoning_effort: "low", tools: lean, stream: true, messages: [], stream_options: { include_usage: true } }); + assert.equal(Buffer.from(upstream.body).toString("utf8"), canonical); + assert.equal(Buffer.from(upstream.body).toString("utf8").split('"tools"').length, 2); + assert.doesNotMatch(Buffer.from(upstream.body).toString("utf8"), /search_replace|grok-build|"high"/u); +}); + +test("proxy refuses top-level members a lean Grok 1.0.34 worker never sends", () => { + for (const overrides of [{ functions: [{ name: "write" }] }, { n: 2 }, { tool_choice: "required" }, { max_tokens: 100 }, { temperature: 0 }, { stream_options: { include_usage: true, extra: 1 } }, { stream_options: "yes" }]) { + const { caps, input } = request(leanBody(overrides)); + assert.throws(() => authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"), /rejected/, JSON.stringify(overrides)); + } + const { caps, input } = request(leanBody({ stream_options: { include_usage: true } })); + assert.equal(Buffer.from(authorizeGrokBrokerProxyRequest(input, caps, "real-bearer").body).toString("utf8"), Buffer.from(leanBody({ stream_options: { include_usage: true } })).toString("utf8")); +}); + +test("nested duplicate keys in tools and messages are forwarded only as the parsed values", () => { + const tools = leanTools.map((name) => `{"type":"function","function":{"name":${JSON.stringify(name === "read_file" ? "write" : name)},"name":${JSON.stringify(name)},"parameters":{"type":"object"}}}`).join(","); + const raw = `{"model":"grok-4.6","reasoning_effort":"low","stream":true,"messages":[{"role":"system","role":"user","content":"a","content":"b"}],"tools":[${tools}]}`; + const { caps, input } = request(Buffer.from(raw)); + const forwarded = Buffer.from(authorizeGrokBrokerProxyRequest(input, caps, "real-bearer").body).toString("utf8"); + assert.equal(forwarded, JSON.stringify(JSON.parse(raw))); + assert.doesNotMatch(forwarded, /"write"|"system"|"content":"a"/u); + assert.equal(forwarded.split('"role"').length, 2); + // The reverse order puts the forbidden name last, so the parsed (and gated) value is refused. + const hostile = raw.replace('"name":"write","name":"read_file"', '"name":"read_file","name":"write"'); + const second = request(Buffer.from(hostile)); + assert.throws(() => authorizeGrokBrokerProxyRequest(second.input, second.caps, "real-bearer"), /rejected/u); +}); + +test("__proto__ members are refused anywhere in the body", () => { + const lean = JSON.stringify(leanTools.map(tool)); + const base = `"model":"grok-4.6","reasoning_effort":"low","stream":true,"messages":[]`; + for (const raw of [ + `{${base},"tools":${lean},"__proto__":{"tools":[]}}`, + `{${base},"tools":${lean.replace('{"type":"function"', '{"__proto__":{"type":"function"},"type":"function"')}}`, + `{${base},"tools":${lean.replace('"parameters":{"type":"object"}', '"parameters":{"type":"object","__proto__":{"x":1}}')}}`, + `{"model":"grok-4.6","reasoning_effort":"low","stream":true,"messages":[{"role":"user","content":"hi","__proto__":{"role":"system"}}],"tools":${lean}}` + ]) { + const { caps, input } = request(Buffer.from(raw)); + assert.throws(() => authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"), /rejected/u, raw.slice(0, 120)); + } +}); + +test("tool entries carry only the members a lean worker sends", () => { + for (const extra of [{ strict: true }, { function: { name: "read_file", parameters: {}, x: 1 } }]) { + const tools = leanTools.map((name) => name === "read_file" ? { ...tool(name), ...extra } : tool(name)); + const { caps, input } = request(leanBody({ tools })); + assert.throws(() => authorizeGrokBrokerProxyRequest(input, caps, "real-bearer"), /rejected/u, JSON.stringify(extra)); + } }); diff --git a/src/runtime/grokBrokerProxyRequest.ts b/src/runtime/grokBrokerProxyRequest.ts index 15f79d4..79956ac 100644 --- a/src/runtime/grokBrokerProxyRequest.ts +++ b/src/runtime/grokBrokerProxyRequest.ts @@ -1,12 +1,26 @@ +import { GROK_ENGINE_BROKER } from "../contracts/runtimeContractManifest.js"; +import { GROK_WORKER_VISIBLE_TOOLS } from "../contracts/grokWorkerContract.js"; import type { EngineBrokerCapabilities } from "./engineBrokerCapabilities.js"; +import { DEFAULT_GROK_BROKER_MODEL_POLICY, parseGrokBrokerModelPolicy, type GrokBrokerModelPolicy } from "./grokBrokerModelPolicy.js"; const MAX_BODY = 2 * 1024 * 1024; -const MAX_CLIENT_VERSION = 64; -const CLIENT_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/u; export type GrokBrokerProxyInput = Readonly<{ method: string; pathname: string; headers: Readonly>; body: Uint8Array; agentId?: string; turnId?: string }>; export type GrokBrokerUpstreamRequest = Readonly<{ url: "https://cli-chat-proxy.grok.com/v1/chat/completions"; headers: Readonly>; body: Uint8Array }>; -export function authorizeGrokBrokerProxyRequest(input: GrokBrokerProxyInput, capabilities: EngineBrokerCapabilities, bearer: string): GrokBrokerUpstreamRequest { +/** + * Authorizes one worker request and rebuilds it for the provider. + * + * Everything that decides spend is checked here, before a bearer is attached + * and before any upstream call: + * - the client version is exactly the pinned Grok CLI (`GROK_ENGINE_BROKER.grokCliVersion`); + * - the body model and `reasoning_effort` are exactly the declared policy, and + * the model override header follows that declaration instead of a constant; + * - the offered tool names are exactly the lean visible set. Grok 1.0.34 turns + * an unmappable `--tools` entry into its full 19-tool set, and its per-turn + * `session_title` request carries a single forced tool; both are refused. + */ +export function authorizeGrokBrokerProxyRequest(input: GrokBrokerProxyInput, capabilities: EngineBrokerCapabilities, bearer: string, policy: GrokBrokerModelPolicy = DEFAULT_GROK_BROKER_MODEL_POLICY): GrokBrokerUpstreamRequest { + const declared = parseGrokBrokerModelPolicy(policy); if (input.method !== "POST" || input.pathname !== "/v1/chat/completions" || input.body.byteLength < 2 || input.body.byteLength > MAX_BODY) throw new Error("broker proxy request rejected"); const authorization = input.headers.authorization; const match = authorization?.match(/^Bearer ([A-Za-z0-9_-]{40,})$/u); if (match === null || match === undefined) throw new Error("broker proxy request rejected"); @@ -14,7 +28,35 @@ export function authorizeGrokBrokerProxyRequest(input: GrokBrokerProxyInput, cap if (scope === undefined || (input.agentId !== undefined && scope.agentId !== input.agentId) || (input.turnId !== undefined && scope.turnId !== input.turnId)) throw new Error("broker proxy request rejected"); if (!bearer || /[\r\n]/u.test(bearer)) throw new Error("broker credential authority unavailable"); const clientVersion = input.headers["x-grok-client-version"]; - if (clientVersion === undefined || clientVersion.length > MAX_CLIENT_VERSION || !CLIENT_VERSION.test(clientVersion)) throw new Error("broker proxy request rejected"); - try { const parsed = JSON.parse(Buffer.from(input.body).toString("utf8")) as Record; if (parsed.stream !== true || !Array.isArray(parsed.messages)) throw new Error(); } catch { throw new Error("broker proxy request rejected"); } - return { url: "https://cli-chat-proxy.grok.com/v1/chat/completions", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", "x-xai-token-auth": "xai-grok-cli", "x-grok-model-override": "grok-build", "x-grok-client-version": clientVersion, "x-grok-client-identifier": "grok-shell" }, body: input.body }; + if (clientVersion !== GROK_ENGINE_BROKER.grokCliVersion) throw new Error("broker proxy request rejected"); + let parsed: Record; + // `__proto__` is refused at any depth: JSON.parse makes it an ordinary own + // member, but an upstream JavaScript parser may treat it as a prototype. + try { parsed = JSON.parse(Buffer.from(input.body).toString("utf8"), (key, value: unknown) => { if (key === "__proto__") throw new Error(); return value; }) as Record; } catch { throw new Error("broker proxy request rejected"); } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.stream !== true || !Array.isArray(parsed.messages)) throw new Error("broker proxy request rejected"); + if (parsed.model !== declared.model || parsed.reasoning_effort !== declared.reasoningEffort) throw new Error("broker proxy request rejected"); + if (!exactLeanTools(parsed.tools)) throw new Error("broker proxy request rejected"); + if (Object.keys(parsed).some((key) => !LEAN_BODY_MEMBERS.has(key)) || !validStreamOptions(parsed.stream_options)) throw new Error("broker proxy request rejected"); + // Forward what was validated, never the worker's bytes: JSON.parse keeps the + // last of duplicate keys, and an upstream that keeps the first would + // otherwise see a different `tools`/`model`/`reasoning_effort` than the gate. + return { url: "https://cli-chat-proxy.grok.com/v1/chat/completions", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", "x-xai-token-auth": "xai-grok-cli", "x-grok-model-override": declared.model, "x-grok-client-version": clientVersion, "x-grok-client-identifier": "grok-shell" }, body: Buffer.from(JSON.stringify(parsed)) }; +} + +/** Top-level members of a Grok 1.0.34 lean worker chat-completions body (live stub capture). */ +const LEAN_BODY_MEMBERS: ReadonlySet = new Set(["messages", "model", "reasoning_effort", "stream", "stream_options", "tools"]); +/** Members of each lean tool `function` entry (live stub capture). */ +const LEAN_FUNCTION_MEMBERS: ReadonlySet = new Set(["description", "name", "parameters"]); +const validStreamOptions = (value: unknown): boolean => value === undefined + || (value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => key === "include_usage") && typeof (value as { include_usage?: unknown }).include_usage === "boolean"); + +export function exactLeanTools(tools: unknown): boolean { + if (!Array.isArray(tools) || tools.length !== GROK_WORKER_VISIBLE_TOOLS.length) return false; + const names = tools.map((tool) => { + if (tool === null || typeof tool !== "object" || Array.isArray(tool) || (tool as { type?: unknown }).type !== "function" || Object.keys(tool).some((key) => key !== "type" && key !== "function")) return undefined; + const fn = (tool as { function?: unknown }).function; + if (fn === null || typeof fn !== "object" || Array.isArray(fn) || Object.keys(fn).some((key) => !LEAN_FUNCTION_MEMBERS.has(key))) return undefined; + return (fn as { name?: unknown }).name; + }); + return JSON.stringify([...names].sort()) === JSON.stringify(GROK_WORKER_VISIBLE_TOOLS); } diff --git a/src/runtime/grokBrokerWorkerConfig.test.ts b/src/runtime/grokBrokerWorkerConfig.test.ts index 0be5567..091418a 100644 --- a/src/runtime/grokBrokerWorkerConfig.test.ts +++ b/src/runtime/grokBrokerWorkerConfig.test.ts @@ -1,8 +1,67 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import test from "node:test"; -import { renderGrokBrokerWorkerArgs, renderGrokBrokerWorkerConfig } from "./grokBrokerWorkerConfig.js"; -test("worker config uses only named in-memory auth and fixed loopback proxy", () => { - const config = renderGrokBrokerWorkerConfig("/opt/daimon/bin/grok-broker-auth", 43123); - assert.match(config, /auth_provider\.daimon/u); assert.match(config, /args = \["--auth-provider"\]/u);assert.match(config, /127\.0\.0\.1:43123/u);assert.match(config,/127\.0\.0\.1:43124\/mcp/u);assert.match(config,/DAIMON_MCP_CAPABILITY/u); assert.doesNotMatch(config, /access_token|refresh_token|auth\.json/u); - const args = renderGrokBrokerWorkerArgs("/run/worker/prompt", "/workspace"); assert.equal(args.includes("--prompt-file"), true); assert.equal(args.includes("--single"), false); + +import { GROK_ENGINE_BROKER } from "../contracts/runtimeContractManifest.js"; +import { GROK_BROKER_MODELS, GROK_BROKER_REASONING_EFFORTS } from "./grokBrokerModelPolicy.js"; +import { GROK_1_0_34_BUNDLED_SKILLS, grokBrokerWorkerConfigSha256, renderGrokBrokerWorkerArgs, renderGrokBrokerWorkerConfig, renderGrokBrokerWorkerConfigWith } from "./grokBrokerWorkerConfig.js"; + +const section = (config: string, header: string): string => { + const start = config.indexOf(`${header}\n`); + assert.notEqual(start, -1, `missing ${header}`); + const end = config.indexOf("\n[", start + header.length); + return config.slice(start, end === -1 ? undefined : end); +}; + +test("worker config uses only the launcher-set turn capability, the fixed loopback proxy, and the capability-scoped MCP facade", () => { + const config = renderGrokBrokerWorkerConfig(); + assert.match(section(config, "[model.daimon-broker-grok]"), /base_url = "http:\/\/127\.0\.0\.1:43123\/v1"\nenv_key = "DAIMON_PROVIDER_CAPABILITY"\n/u); + // Grok 1.0.34 ignores [auth_provider.*] for custom models; a helper table would silently send no bearer. + assert.doesNotMatch(config, /auth_provider/u); + assert.equal(section(config, "[mcp_servers.daimon]"), '[mcp_servers.daimon]\nurl = "http://127.0.0.1:43124/mcp"\nbearer_token_env_var = "DAIMON_MCP_CAPABILITY"\n'); + assert.doesNotMatch(config, /access_token|refresh_token|auth\.json/u); +}); + +test("worker config disables every bundled 1.0.34 skill, workflows, and the per-turn session title request", () => { + const config = renderGrokBrokerWorkerConfig(); + assert.equal(GROK_1_0_34_BUNDLED_SKILLS.length, 25); + assert.equal(section(config, "[skills]"), `[skills]\ndisabled = [${GROK_1_0_34_BUNDLED_SKILLS.map((name) => JSON.stringify(name)).join(", ")}]\n`); + assert.equal(section(config, "[workflows]"), "[workflows]\nenabled = false\n"); + assert.match(section(config, "[models]"), /\nsession_summary = "daimon-session-title-disabled"\n/u); + assert.equal(section(config, "[model.daimon-session-title-disabled]"), '[model.daimon-session-title-disabled]\nmodel = "disabled"\nbase_url = "http://127.0.0.1:43123/v1"\napi_key = "session-title-disabled"\nmax_retries = 0\nhidden = true\n'); + // The sink carries a static placeholder key only: no env_key, so it can never pick up the turn capability. + assert.doesNotMatch(section(config, "[model.daimon-session-title-disabled]"), /env_key|DAIMON_/u); + for (const toggle of ["title_refresh", "telemetry", "session_recap", "turn_summary", "backend_tools", "ask_user_question"]) assert.match(section(config, "[features]"), new RegExp(`\\n${toggle} = false\\n`, "u")); + assert.match(section(config, "[cli]"), /auto_update = false\nuse_leader = false/u); +}); + +test("the declared model and effort reach the worker's only model as its sole allowed effort", () => { + const config = renderGrokBrokerWorkerConfig({ model: "grok-build", reasoningEffort: "medium" }); + assert.match(section(config, "[model.daimon-broker-grok]"), /\nmodel = "grok-build"\n/u); + assert.match(section(config, "[models]"), /\ndefault = "daimon-broker-grok"\ndefault_reasoning_effort = "medium"\n/u); + assert.equal(section(config, "[[model.daimon-broker-grok.reasoning_efforts]]"), '[[model.daimon-broker-grok.reasoning_efforts]]\nvalue = "medium"\nlabel = "Medium"\ndefault = true\n'); + assert.equal(config.match(/reasoning_efforts\]\]/gu)?.length, 1); + assert.match(renderGrokBrokerWorkerConfig(), /\nmodel = "grok-4\.6"\n[\s\S]*value = "low"/u); + for (const invalid of [{ model: "grok-3" }, { reasoningEffort: "xhigh" }, { model: "grok-4.6", reasoningEffort: "low", extra: true }]) { + assert.throws(() => renderGrokBrokerWorkerConfig(invalid as never), /model policy/u); + } +}); + +test("the manifest pins the sha256 of every renderable worker config", () => { + for (const model of GROK_BROKER_MODELS) { + for (const reasoningEffort of GROK_BROKER_REASONING_EFFORTS) { + const digest = createHash("sha256").update(renderGrokBrokerWorkerConfig({ model, reasoningEffort })).digest("hex"); + assert.equal(grokBrokerWorkerConfigSha256({ model, reasoningEffort }), digest); + assert.equal(GROK_ENGINE_BROKER.worker.configSha256[model][reasoningEffort], digest, `${model}/${reasoningEffort}`); + } + } +}); + +test("the probe-only renderer refuses non-loopback or injected endpoints", () => { + const policy = { model: "grok-4.6", reasoningEffort: "low" } as const; + assert.throws(() => renderGrokBrokerWorkerConfigWith(policy, { proxyPort: 0, mcpUrl: "http://127.0.0.1:1/mcp" }), /invalid/u); + assert.throws(() => renderGrokBrokerWorkerConfigWith(policy, { proxyPort: 1, mcpUrl: "http://example.com/mcp" }), /invalid/u); + assert.throws(() => renderGrokBrokerWorkerConfigWith(policy, { proxyPort: 1, mcpUrl: "http://127.0.0.1:1/mcp\"\n[evil]" }), /invalid/u); + const args = renderGrokBrokerWorkerArgs("/run/worker/prompt", "/workspace"); + assert.equal(args.includes("--prompt-file"), true); assert.equal(args.includes("--single"), false); }); diff --git a/src/runtime/grokBrokerWorkerConfig.ts b/src/runtime/grokBrokerWorkerConfig.ts index 4223490..5a2f88c 100644 --- a/src/runtime/grokBrokerWorkerConfig.ts +++ b/src/runtime/grokBrokerWorkerConfig.ts @@ -1,17 +1,133 @@ +import { createHash } from "node:crypto"; import path from "node:path"; -export function renderGrokBrokerWorkerConfig(helperPath: string, proxyPort: number): string { - if (!path.posix.isAbsolute(helperPath) || /[\r\n"']/u.test(helperPath) || !Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65_535) throw new TypeError("invalid Grok broker worker configuration"); +import { GROK_ENGINE_BROKER } from "../contracts/runtimeContractManifest.js"; +import { DAIMON_GROK_SYSTEM_PROMPT, GROK_WORKER_MAX_TURNS, GROK_WORKER_TOOL_IDS } from "../contracts/grokWorkerContract.js"; +import { parseGrokBrokerModelPolicy, type GrokBrokerModelPolicy } from "./grokBrokerModelPolicy.js"; + +export { DAIMON_GROK_SYSTEM_PROMPT, GROK_WORKER_MAX_TURNS, GROK_WORKER_TOOL_IDS, GROK_WORKER_VISIBLE_TOOLS } from "../contracts/grokWorkerContract.js"; + +/** + * Bundled skills shipped by Grok CLI 1.0.34. Names are version-specific: + * `[skills] disabled` removed all ~2.1k skill tokens in the P0 matrix, and a + * CLI bump must re-derive this list rather than inherit it. + */ +export const GROK_1_0_34_BUNDLED_SKILLS = Object.freeze([ + "build-with-ai", "code-review", "create-skill", "create-workflow", "design", "docx", "execute-plan", + "game-animation-frames", "game-asset-core", "game-character-consistency", "game-tilesets", "game-ui-icons", + "imagine", "implement", "learn", "long-running-background-tasks", "pdf", "pptx", "pr-babysit", + "resume-claude", "resume-codex", "resume-cursor", "review", "skill-design-principles", "statusline" +] as const); + +/** The worker's only model id; the argv selects it and the proxy never sees another. */ +export const GROK_BROKER_WORKER_MODEL_ID = "daimon-broker-grok" as const; + +/** + * Grok 1.0.34 sends a `session_title` model request before every headless + * turn, and no config key or environment variable disables it + * (`features.title_refresh` governs only the later refresh; verified against a + * loopback stub). `[models] session_summary` does select the model it uses, so + * the title goes to a hidden model whose endpoint is the broker's own provider + * proxy with a placeholder key that can never be a turn capability (shorter + * than the 40-character capability alphabet). The proxy refuses it before any + * capability lookup, isolation guard, credential read, or upstream call, and + * Grok falls back to the truncated prompt as the title. The endpoint is always + * listening while a worker runs, so the refusal is bounded by one loopback + * round trip rather than by a connect timeout, and a prompt-derived title is + * never delivered anywhere but Daimon's own proxy. + */ +export const GROK_SESSION_TITLE_SINK_MODEL_ID = "daimon-session-title-disabled" as const; +export const GROK_SESSION_TITLE_SINK_KEY = "session-title-disabled" as const; +const renderSessionTitleSink = (proxyPort: number): readonly string[] => [ + `[model.${GROK_SESSION_TITLE_SINK_MODEL_ID}]`, 'model = "disabled"', `base_url = "http://127.0.0.1:${proxyPort}/v1"`, `api_key = "${GROK_SESSION_TITLE_SINK_KEY}"`, + "max_retries = 0", "hidden = true", "" +]; + +/** + * The turn-scoped proxy capability reaches the worker's only model through + * `env_key`, set by the native launcher. Grok 1.0.34 accepts + * `[auth_provider.]` tables but never runs the helper for a custom model + * (verified against a loopback stub: no helper invocation and no Authorization + * header, with and without `args`, `api_backend`, `model_providers`, or a + * passwd-home config), while `env_key` attaches the bearer on every request. + * The capability is exactly as exposed as `DAIMON_MCP_CAPABILITY`: visible to + * the worker's own tool children, which run network-restricted, and revoked + * when the turn ends. + */ +export const GROK_BROKER_PROVIDER_CAPABILITY_ENV = "DAIMON_PROVIDER_CAPABILITY" as const; + +type WorkerEndpoints = Readonly<{ proxyPort: number; mcpUrl: string }>; +const PRODUCTION_ENDPOINTS: WorkerEndpoints = Object.freeze({ + proxyPort: GROK_ENGINE_BROKER.providerProxy.port, + mcpUrl: `http://${GROK_ENGINE_BROKER.mcpFacade.host}:${GROK_ENGINE_BROKER.mcpFacade.port}${GROK_ENGINE_BROKER.mcpFacade.path}` +}); + +/** + * Sections shared by every Grok worker Daimon configures, broker or direct. + * + * Each toggle is either measured (skills −2.1k tokens, workflows −314, the + * per-turn `session_title` model request) or behavioural hardening that P0 + * showed to be token-neutral and warning-free on 1.0.34. + */ +export const renderGrokLeanBaseConfig = (): string => [ + "[cli]", "auto_update = false", "use_leader = false", "show_tips = false", "", + "[features]", "telemetry = false", "title_refresh = false", "session_recap = false", "turn_summary = false", + "repo_status_in_system_prompt = false", "codebase_indexing = false", "backend_tools = false", "ask_user_question = false", + "image_gen = false", "video_gen = false", "web_fetch = false", "campaigns = false", "managed_config = false", "", + "[managed_mcps]", "enabled = false", "", + "[skills]", `disabled = [${GROK_1_0_34_BUNDLED_SKILLS.map((name) => JSON.stringify(name)).join(", ")}]`, "", + "[workflows]", "enabled = false", "" +].join("\n"); + +/** + * The only source of broker worker `config.toml` bytes. + * + * Effort is declared here, not on the compiled launcher argv: the argv is one + * constant for every registration while effort is declared per deployment, and + * Grok 1.0.34 drops both `--reasoning-effort` and `[models] + * default_reasoning_effort` unless the model advertises effort support. A + * one-entry `reasoning_efforts` table makes the declared effort the model's + * default *and* its closed enum, so it reaches the request body and nothing + * else can be selected; the proxy then re-verifies it on every body. + */ +export function renderGrokBrokerWorkerConfig(policy: Partial = {}): string { + return renderGrokBrokerWorkerConfigWith(parseGrokBrokerModelPolicy(policy), PRODUCTION_ENDPOINTS); +} + +/** Explicit-endpoint variant for the local live probe; production bytes come only from the function above. */ +export function renderGrokBrokerWorkerConfigWith(policy: GrokBrokerModelPolicy, endpoints: WorkerEndpoints): string { + const declared = parseGrokBrokerModelPolicy(policy); + const { proxyPort, mcpUrl } = endpoints; + if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65_535 || !/^http:\/\/127\.0\.0\.1:\d{1,5}\/mcp$/u.test(mcpUrl)) { + throw new TypeError("invalid Grok broker worker configuration"); + } + const label = `${declared.reasoningEffort[0]!.toUpperCase()}${declared.reasoningEffort.slice(1)}`; return [ - "[cli]", "auto_update = false", "use_leader = false", "", - "[features]", "telemetry = false", "", - "[auth_provider.daimon]", `command = ${JSON.stringify(helperPath)}`, 'args = ["--auth-provider"]', "timeout_secs = 5", "token_ttl_secs = 600", "", - "[model.daimon-broker-grok]", 'model = "grok-build"', `base_url = "http://127.0.0.1:${proxyPort}/v1"`, 'auth_provider = "daimon"', "context_window = 131072", "supports_backend_search = false", "", - "[mcp_servers.daimon]", 'url = "http://127.0.0.1:43124/mcp"', 'headers = { Authorization = "Bearer ${DAIMON_MCP_CAPABILITY}" }', "" + renderGrokLeanBaseConfig(), + "[models]", `default = "${GROK_BROKER_WORKER_MODEL_ID}"`, `default_reasoning_effort = "${declared.reasoningEffort}"`, `session_summary = "${GROK_SESSION_TITLE_SINK_MODEL_ID}"`, "", + ...renderSessionTitleSink(proxyPort), + `[model.${GROK_BROKER_WORKER_MODEL_ID}]`, `model = "${declared.model}"`, `base_url = "http://127.0.0.1:${proxyPort}/v1"`, `env_key = "${GROK_BROKER_PROVIDER_CAPABILITY_ENV}"`, + 'api_backend = "chat_completions"', "context_window = 131072", "supports_backend_search = false", "", + `[[model.${GROK_BROKER_WORKER_MODEL_ID}.reasoning_efforts]]`, `value = "${declared.reasoningEffort}"`, `label = "${label}"`, "default = true", "", + "[mcp_servers.daimon]", `url = "${mcpUrl}"`, 'bearer_token_env_var = "DAIMON_MCP_CAPABILITY"', "" ].join("\n"); } +export const grokBrokerWorkerConfigSha256 = (policy: Partial = {}): string => + createHash("sha256").update(renderGrokBrokerWorkerConfig(policy)).digest("hex"); + +/** + * TypeScript mirror of the argv compiled into `native/engineBrokerLauncherCore.inc`. + * `native/launcherArgv.test.ts` parses the C source and fails on any divergence. + */ export const renderGrokBrokerWorkerArgs = (promptFile: string, cwd: string): readonly string[] => { if (!path.posix.isAbsolute(promptFile) || !path.posix.isAbsolute(cwd)) throw new TypeError("invalid Grok broker worker path"); - return ["--sandbox", "daimon-strict", "--always-approve", "--no-subagents", "--prompt-file", promptFile, "--no-memory", "--disable-web-search", "--cwd", cwd, "--output-format", "streaming-messages-json", "--model", "daimon-broker-grok"]; + return [ + "--sandbox", "daimon-strict", "--always-approve", "--no-subagents", "--prompt-file", promptFile, + "--no-memory", "--disable-web-search", "--no-plan", "--verbatim", + "--system-prompt-override", DAIMON_GROK_SYSTEM_PROMPT, + "--tools", GROK_WORKER_TOOL_IDS.join(","), + "--max-turns", String(GROK_WORKER_MAX_TURNS), + "--cwd", cwd, "--output-format", "streaming-messages-json", "--model", GROK_BROKER_WORKER_MODEL_ID + ]; }; diff --git a/src/runtime/grokEngineBroker.ts b/src/runtime/grokEngineBroker.ts index 6ae6eaf..98a8384 100644 --- a/src/runtime/grokEngineBroker.ts +++ b/src/runtime/grokEngineBroker.ts @@ -7,7 +7,9 @@ import { EngineBrokerTurnRegistry } from "./engineBrokerTurnRegistry.js"; import { startGrokBrokerProxy } from "./grokBrokerProxy.js"; import { startEngineBrokerMcpFacade } from "./engineBrokerMcpFacade.js"; import { acquireGrokBrokerRealmLease } from "./grokBrokerRealmLease.js"; -import { GrokWorkerAttestationFailure,prepareGrokWorkerAttestation,verifyGrokWorkerAttestation } from "./grokWorkerAttestation.js"; +import { grokBrokerWorkerConfigSha256 } from "./grokBrokerWorkerConfig.js"; +import { parseGrokBrokerModelPolicy, type GrokBrokerModelPolicy } from "./grokBrokerModelPolicy.js"; +import { createGrokWorkerIsolationGuard,GrokWorkerAttestationFailure,prepareGrokWorkerAttestation } from "./grokWorkerAttestation.js"; export type GrokEngineBrokerRegistration = Readonly<{ agentId:string;slot:number;workerUid:number;workspace:string;profilePath:string;eventsPath:string;profileSha256:string }>; export type GrokEngineBroker = Awaited>; @@ -32,18 +34,20 @@ export async function finishBrokerTurnWithUsage(turns: EngineBrokerTurnRegistry, if (usage === undefined) return; await recordTurnUsage(usageLedgerPath, { agent: agentId, wake: wakeId, engine: "grok", usage }); } -export async function startGrokEngineBroker(options: Readonly<{ grokCommand: string; nativeClient: string; credentialHome: string; turnStore: string; registrations: readonly GrokEngineBrokerRegistration[]; usageLedgerPath?: string }>) { +export async function startGrokEngineBroker(options: Readonly<{ grokCommand: string; nativeClient: string; credentialHome: string; turnStore: string; registrations: readonly GrokEngineBrokerRegistration[]; usageLedgerPath?: string; modelPolicy?: GrokBrokerModelPolicy }>) { + // One declared model/effort drives both the worker config bytes the broker attests and the bodies the proxy forwards. + const modelPolicy = parseGrokBrokerModelPolicy(options.modelPolicy ?? {}); const configSha256 = grokBrokerWorkerConfigSha256(modelPolicy); const usageLedgerPath = options.usageLedgerPath ?? TURN_USAGE_LEDGER.filePath; const registrations = new Map(options.registrations.map((entry) => [entry.agentId, entry])); if (registrations.size !== options.registrations.length) throw new Error("engine broker registration conflict"); - const lease=await acquireGrokBrokerRealmLease(options.credentialHome);const authority = new DurableGrokBrokerCredentialAuthority(options.grokCommand, options.credentialHome);try{await authority.initialize();}catch(error){await lease.close();throw error;} let proxy:Awaited>;try{proxy=await startGrokBrokerProxy(authority);}catch(error){await lease.close();throw error;}let mcp:Awaited>|undefined;try{mcp=await startEngineBrokerMcpFacade();for(const registration of registrations.values())await prepareGrokWorkerAttestation({...registration,brokerGid:2100});}catch(error){if(mcp)await mcp.close().catch(()=>undefined);await proxy.close();await lease.close();throw error;}if(!mcp)throw new Error("engine broker unavailable");const turns = new EngineBrokerTurnRegistry(options.turnStore); const active = new Map; resolve: () => void }>(); let closed = false; + const lease=await acquireGrokBrokerRealmLease(options.credentialHome);const authority = new DurableGrokBrokerCredentialAuthority(options.grokCommand, options.credentialHome);try{await authority.initialize();}catch(error){await lease.close();throw error;} let proxy:Awaited>;try{proxy=await startGrokBrokerProxy(authority,undefined,modelPolicy);}catch(error){await lease.close();throw error;}let mcp:Awaited>|undefined;try{mcp=await startEngineBrokerMcpFacade();for(const registration of registrations.values())await prepareGrokWorkerAttestation({...registration,brokerGid:2100,configSha256});}catch(error){if(mcp)await mcp.close().catch(()=>undefined);await proxy.close();await lease.close();throw error;}if(!mcp)throw new Error("engine broker unavailable");const turns = new EngineBrokerTurnRegistry(options.turnStore); const active = new Map; resolve: () => void }>(); let closed = false; return { async turn(agentId: string, wakeId: string, prompt: string, mcpEndpoint: string, signal?: AbortSignal): Promise> { if (closed) throw new Error("engine broker unavailable"); const registration = registrations.get(agentId); if (registration === undefined) throw new Error("engine broker unavailable"); const turnId = createHash("sha256").update(`${agentId}\0${wakeId}`).digest("hex"); const request = { version: "noopolis.daimon.engine-broker.v1", kind: "start_turn", requestId: randomUUID(), turnId, agentId, wakeId, prompt,mcpEndpoint } as const; const begun = await turns.begin(request); if (begun !== "start") { if (begun.replay.kind === "completed") return {text:begun.replay.text,workerPid:begun.replay.workerPid,workerUid:begun.replay.workerUid,workerStartTime:begun.replay.workerStartTime};const code=begun.replay.code==="auth_stale"||begun.replay.code==="cancelled"?begun.replay.code:"engine_failed";throw new EngineBrokerTurnFailure(code,begun.replay.diagnostic as NativeBrokerDiagnostic|undefined); } - const isolation=await prepareGrokWorkerAttestation({...registration,brokerGid:2100});proxy.registerIsolationGuard(turnId,()=>verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation));const providerCapability = proxy.capabilities.issue(agentId, turnId);const mcpCapability=mcp.register(agentId,turnId,mcpEndpoint); const controller = new AbortController();const onAbort=()=>controller.abort();signal?.addEventListener("abort",onAbort,{once:true});if(signal?.aborted)controller.abort(); let resolve!:()=>void;const done=new Promise((value)=>{resolve=value;});active.set(turnId,{controller,done,resolve}); + const attestation={...registration,brokerGid:2100,configSha256};const isolation=await prepareGrokWorkerAttestation(attestation);const isolationGuard=createGrokWorkerIsolationGuard(attestation,isolation);proxy.registerIsolationGuard(turnId,isolationGuard);const providerCapability = proxy.capabilities.issue(agentId, turnId);const mcpCapability=mcp.register(agentId,turnId,mcpEndpoint); const controller = new AbortController();const onAbort=()=>controller.abort();signal?.addEventListener("abort",onAbort,{once:true});if(signal?.aborted)controller.abort(); let resolve!:()=>void;const done=new Promise((value)=>{resolve=value;});active.set(turnId,{controller,done,resolve}); let nativeDiagnostic:NativeBrokerDiagnostic|undefined,attested=false; - try { const result = await runNativeBrokerTurn(options.nativeClient, { slot: registration.slot, requestId: request.requestId, turnId, agentId, wakeId, prompt, providerCapability,mcpCapability }, controller.signal);nativeDiagnostic=result.diagnostic;if(result.workerUid!==registration.workerUid)throw new Error();await verifyGrokWorkerAttestation({...registration,brokerGid:2100},isolation);attested=true; const decoded = decodeGrokHeadlessTurn(result.text);const text = decoded.text;const completed={ version: request.version, kind: "completed", requestId: request.requestId, turnId, text, workerPid: result.workerPid, workerUid: result.workerUid, workerStartTime: result.startTicks.toString() } as const; await finishBrokerTurnWithUsage(turns,request,completed,usageLedgerPath,decoded.usage,agentId,wakeId); return {text,workerPid:result.workerPid,workerUid:result.workerUid,workerStartTime:result.startTicks.toString()}; } + try { const result = await runNativeBrokerTurn(options.nativeClient, { slot: registration.slot, requestId: request.requestId, turnId, agentId, wakeId, prompt, providerCapability,mcpCapability }, controller.signal);nativeDiagnostic=result.diagnostic;if(result.workerUid!==registration.workerUid)throw new Error();await isolationGuard();attested=true; const decoded = decodeGrokHeadlessTurn(result.text);const text = decoded.text;const completed={ version: request.version, kind: "completed", requestId: request.requestId, turnId, text, workerPid: result.workerPid, workerUid: result.workerUid, workerStartTime: result.startTicks.toString() } as const; await finishBrokerTurnWithUsage(turns,request,completed,usageLedgerPath,decoded.usage,agentId,wakeId); return {text,workerPid:result.workerPid,workerUid:result.workerUid,workerStartTime:result.startTicks.toString()}; } catch(error) { const code=authority.isStale()?"auth_stale":controller.signal.aborted?"cancelled":"engine_failed";const diagnostic=error instanceof NativeBrokerTurnFailure?error.diagnostic:nativeDiagnostic&&!attested?{...nativeDiagnostic,status:"worker_failed" as const,stage:"attestation" as const,failureClass:error instanceof GrokWorkerAttestationFailure?error.failureClass:"profile_invalid" as const,profileApplied:false}:undefined;await turns.finish(request, { version: request.version, kind: "failed", requestId: request.requestId, turnId, code,...(diagnostic?{diagnostic}:{}) }); throw new EngineBrokerTurnFailure(code,diagnostic); } finally { signal?.removeEventListener("abort",onAbort);active.get(turnId)?.resolve(); active.delete(turnId); proxy.revokeIsolationGuard(turnId);proxy.capabilities.revoke(turnId);mcp.revoke(turnId); } }, diff --git a/src/runtime/grokWorkerAttestation.test.ts b/src/runtime/grokWorkerAttestation.test.ts index 7dc230d..92920e3 100644 --- a/src/runtime/grokWorkerAttestation.test.ts +++ b/src/runtime/grokWorkerAttestation.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { appendFile, chmod, mkdtemp, rm, stat, symlink, truncate, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, truncate, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -102,7 +102,7 @@ const eventsFile = async (dir: string, initial = ""): Promise => { }; const watermark = async (file: string, denyPaths: readonly string[] = []): Promise => { const info = await stat(file); - return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), mtimeMs: Number(info.mtimeMs), denyPaths }; + return { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), denyPaths }; }; const verify = (eventsPath: string, before: GrokWorkerAttestationSnapshot, brokerGid = self.gid): Promise => verifyGrokWorkerAttestation({ eventsPath, workerUid: self.uid, brokerGid, workspace }, before); @@ -220,8 +220,9 @@ test("refuses a sandbox profile that is not root-owned, and one reached through const text = `[profiles.daimon-strict]\nextends = "strict"\nrestrict_network = true\ndeny = []\n`; await writeFile(profile, text); await chmod(profile, 0o444); - const events = await eventsFile(dir); - const input = { profilePath: profile, eventsPath: events, profileSha256: sha256(text), workerUid: self.uid, brokerGid: self.gid }; + await mkdir(path.join(dir, "sessions")); + const events = await eventsFile(path.join(dir, "sessions")); + const input = { profilePath: profile, eventsPath: events, profileSha256: sha256(text), workerUid: self.uid, brokerGid: self.gid, configSha256: "0".repeat(64) }; // Owned by the test user rather than root: `secureOpen` must refuse it even // though its bytes hash correctly. await assert.rejects(prepareGrokWorkerAttestation(input), /attestation unavailable/u); @@ -244,3 +245,28 @@ test("holds the kernel's reported deny_paths to exactly what the pinned profile } assert.throws(() => parseGrokWorkerProfileApplied(bytes(["/a"]), workspace, []), /attestation unavailable/u); }); + +test("reads sandbox events only from the Grok 1.0.34 sessions log", async (t) => { + // Mutation-critical: 1.0.34 writes nothing to `$GROK_HOME/sandbox-events.jsonl`, + // so attesting that path would fail every turn as "not enforced" — or worse, + // accept a stale file. Restoring the old relation must turn this red. + const dir = await workspaceDir(); + t.after(() => rm(dir, { recursive: true, force: true })); + const input = { profilePath: path.join(dir, "sandbox.toml"), profileSha256: "0".repeat(64), workerUid: self.uid, brokerGid: self.gid, configSha256: "0".repeat(64) }; + await assert.rejects(prepareGrokWorkerAttestation({ ...input, eventsPath: path.join(dir, "sandbox-events.jsonl") }), /sessions\/sandbox-events\.jsonl/u); + await assert.rejects(prepareGrokWorkerAttestation({ ...input, eventsPath: path.join(dir, "sessions", "sandbox-events.jsonl") }), /attestation unavailable/u); +}); + +test("attests real Grok 1.0.34 events: ProfileApplied with a non-empty deny list, then the denial it caused", async (t) => { + const fixture = await readFile(new URL("./fixtures/grok-1.0.34-sandbox-events.jsonl", import.meta.url)); + const liveWorkspace = "/var/lib/spawnfile/instance/workspace/agents/a1"; + assert.doesNotThrow(() => parseGrokWorkerProfileApplied(fixture, liveWorkspace, ["/run/paideia"])); + assert.throws(() => parseGrokWorkerProfileApplied(fixture, liveWorkspace, []), /attestation unavailable/u); + assert.throws(() => parseGrokWorkerProfileApplied(fixture, liveWorkspace, ["/run/paideia", "/run/training"]), /attestation unavailable/u); + const dir = await workspaceDir(); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = await eventsFile(dir); + const before = await watermark(file, ["/run/paideia"]); + await appendFile(file, fixture); + await verifyGrokWorkerAttestation({ eventsPath: file, workerUid: self.uid, brokerGid: self.gid, workspace: liveWorkspace }, before); +}); diff --git a/src/runtime/grokWorkerAttestation.ts b/src/runtime/grokWorkerAttestation.ts index ce450f3..adfa3f5 100644 --- a/src/runtime/grokWorkerAttestation.ts +++ b/src/runtime/grokWorkerAttestation.ts @@ -1,6 +1,10 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { lstat,open } from "node:fs/promises"; +import path from "node:path"; + +import { verifyGrokWorkerHome } from "./grokWorkerHomeAttestation.js"; +import { grokWorkerEventsPathFor } from "./grokWorkerSandboxProfile.js"; /** * The per-turn freshness watermark taken before the worker is launched: the @@ -8,22 +12,20 @@ import { lstat,open } from "node:fs/promises"; * `size` is pre-turn history and is never read back — a `ProfileApplied` down * there is a replay, not evidence about this turn. */ -export type GrokWorkerAttestationSnapshot=Readonly<{dev:number;ino:number;size:number;mtimeMs:number;denyPaths:readonly string[]}>; +export type GrokWorkerAttestationSnapshot=Readonly<{dev:number;ino:number;size:number;denyPaths:readonly string[]}>; type Snapshot=GrokWorkerAttestationSnapshot; export class GrokWorkerAttestationFailure extends Error { constructor(readonly failureClass:"profile_missing"|"profile_invalid"){super("Grok worker isolation attestation unavailable");} } /** * Reads the `deny` list out of a worker sandbox profile, but only after the * bytes match `profileSha256` exactly. * - * There is no minimum length. The floor used to be 3 (subscription realm, - * bootstrap credential, peer roots), then 1, and an empty list is now the - * *expected* shape: Grok 1.0.13 re-execs itself inside bubblewrap whenever - * `deny` is non-empty and then opens each deny-path placeholder — which it - * created at mode 000 — from a capability-stripped process, gets EACCES, and - * refuses to start ("possible __GROK_INSIDE_BWRAP spoof") without ever - * emitting a `ProfileApplied` event. Spawnfile therefore renders `deny = []` - * and confines the worker with unix permissions plus builtin-`strict` - * Landlock instead (`containerDaimonBrokerRender.ts`). + * There is no minimum length, and a populated list is supported. Grok 1.0.13 + * refused to start on any non-empty `deny` (its mode-000 placeholders failed + * an EACCES "__GROK_INSIDE_BWRAP spoof" check), so deployments rendered + * `deny = []`. Grok 1.0.34 runs every profile inside bubblewrap and enforces a + * non-empty list, and since its strict base reads all of `/run`, `/var` and + * `/tmp`, the deny list is what keeps evaluator and host-bind paths away from + * the worker. `grokWorkerSandboxProfile.ts` renders these bytes. * * The integrity guarantee is the hash pin, not the length. `profileSha256` * comes from `/etc/daimon-engine-broker/service.json`, which the root @@ -52,25 +54,87 @@ export function parseGrokWorkerSandboxProfile(bytes:Uint8Array,profileSha256:str if(!Array.isArray(parsed)||parsed.some((entry)=>typeof entry!=="string")||new Set(parsed).size!==parsed.length)throw new Error("Grok worker isolation attestation unavailable"); return [...parsed as string[]].sort(); } -export async function prepareGrokWorkerAttestation(input:Readonly<{profilePath:string;eventsPath:string;profileSha256:string;workerUid:number;brokerGid:number}>):Promise{ - const profile=await secureOpen(input.profilePath,0,0,0o444,65_536);let bytes:Buffer|undefined;let denyPaths:readonly string[]=[];try{bytes=await profile.readFile();denyPaths=parseGrokWorkerSandboxProfile(bytes,input.profileSha256);}catch{throw new Error("Grok worker isolation attestation unavailable");}finally{bytes?.fill(0);await profile.close();} - const events=await secureOpen(input.eventsPath,input.workerUid,input.brokerGid,0o640,16*1024*1024);try{const stat=await events.stat();return{dev:Number(stat.dev),ino:Number(stat.ino),size:Number(stat.size),mtimeMs:Number(stat.mtimeMs),denyPaths};}finally{await events.close();} +/** + * Pre-launch half of the per-turn attestation. Besides the pinned profile and + * the events watermark it requires the 1.0.34 layout: events under + * `$GROK_HOME/sessions/` (the root `sandbox-events.jsonl` stays empty on + * 1.0.34), and a root-owned read-only worker home whose `config.toml` hashes to + * the declared renderer output (`grokWorkerHomeAttestation.ts`). + */ +export async function prepareGrokWorkerAttestation(input:Readonly<{profilePath:string;eventsPath:string;profileSha256:string;workerUid:number;brokerGid:number;configSha256:string}>,profileOwner:Readonly<{uid:number;gid:number}>={uid:0,gid:0}):Promise{ + if(input.eventsPath!==grokWorkerEventsPathFor(input.profilePath))throw new Error("Grok worker sandbox events must be read from $GROK_HOME/sessions/sandbox-events.jsonl"); + const profile=await secureOpen(input.profilePath,profileOwner.uid,profileOwner.gid,0o444,65_536);let bytes:Buffer|undefined;let denyPaths:readonly string[]=[];try{bytes=await profile.readFile();denyPaths=parseGrokWorkerSandboxProfile(bytes,input.profileSha256);}catch{throw new Error("Grok worker isolation attestation unavailable");}finally{bytes?.fill(0);await profile.close();} + await verifyGrokWorkerHome(path.dirname(input.profilePath),input.configSha256); + const events=await secureOpen(input.eventsPath,input.workerUid,input.brokerGid,0o640,16*1024*1024);try{const stat=await events.stat();return{dev:Number(stat.dev),ino:Number(stat.ino),size:Number(stat.size),denyPaths};}finally{await events.close();} +} +/** + * The accepted `ProfileApplied` line of one turn, as an absolute byte range of + * the events file plus its digest. + */ +export type GrokWorkerAttestationLock={accepted?:Readonly<{offset:number;length:number;digest:string}>;refused?:GrokWorkerAttestationFailure["failureClass"]}; + +/** + * The per-turn isolation guard: every model request of a turn, and the + * post-turn check, go through the same lock. + * + * Why the first verification is trustworthy: the proxy awaits this guard before + * *every* upstream request, including the first, and the only worker-uid + * process that exists before request 1 is the Grok the launcher started under + * the pinned profile — tool children are created only after a model response. + * So the event accepted at request 1 was written before any tool child could + * write to the (worker-owned) events file. Later requests must find that exact + * line, byte for byte, at the same offset: a `ProfileApplied` appended later — + * which a tool child could forge — is never a substitute, and once a turn has + * been refused it stays refused. + */ +export function createGrokWorkerIsolationGuard(input:Parameters[0],before:Snapshot):()=>Promise{ + const lock:GrokWorkerAttestationLock={}; + return async()=>{ + if(lock.refused!==undefined)throw new GrokWorkerAttestationFailure(lock.refused); + try{await verifyGrokWorkerAttestation(input,before,lock);} + catch(error){const failure=error instanceof GrokWorkerAttestationFailure?error:new GrokWorkerAttestationFailure("profile_invalid");lock.refused=failure.failureClass;throw failure;} + }; } -export async function verifyGrokWorkerAttestation(input:Readonly<{eventsPath:string;workerUid:number;brokerGid:number;workspace:string}>,before:Snapshot):Promise{ - let handle:Awaited>;try{handle=await secureOpen(input.eventsPath,input.workerUid,input.brokerGid,0o640,16*1024*1024);}catch{throw new GrokWorkerAttestationFailure("profile_invalid");}let bytes:Buffer|undefined;try{const stat=await handle.stat();if(Number(stat.dev)!==before.dev||Number(stat.ino)!==before.ino)throw new GrokWorkerAttestationFailure("profile_invalid");if(Number(stat.size)<=before.size)throw new GrokWorkerAttestationFailure("profile_missing");bytes=Buffer.alloc(Number(stat.size)-before.size);const read=await handle.read(bytes,0,bytes.length,before.size);if(read.bytesRead!==bytes.length)throw new GrokWorkerAttestationFailure("profile_invalid");const after=await handle.stat();if(Number(after.size)!==Number(stat.size)||Number(after.mtimeMs)!==Number(stat.mtimeMs))throw new GrokWorkerAttestationFailure("profile_invalid");parseGrokWorkerProfileApplied(bytes,input.workspace,before.denyPaths);}catch(error){if(error instanceof GrokWorkerAttestationFailure)throw error;throw new GrokWorkerAttestationFailure("profile_invalid");}finally{bytes?.fill(0);await handle.close();} +export async function verifyGrokWorkerAttestation(input:Readonly<{eventsPath:string;workerUid:number;brokerGid:number;workspace:string}>,before:Snapshot,lock?:GrokWorkerAttestationLock):Promise{ + let handle:Awaited>;try{handle=await secureOpen(input.eventsPath,input.workerUid,input.brokerGid,0o640,16*1024*1024);}catch{throw new GrokWorkerAttestationFailure("profile_invalid");} + let bytes:Buffer|undefined; + try{ + const stat=await handle.stat(); + if(Number(stat.dev)!==before.dev||Number(stat.ino)!==before.ino)throw new GrokWorkerAttestationFailure("profile_invalid"); + if(Number(stat.size)<=before.size)throw new GrokWorkerAttestationFailure("profile_missing"); + const accepted=lock?.accepted; + const start=accepted===undefined?before.size:before.size+accepted.offset; + const length=accepted===undefined?Number(stat.size)-before.size:accepted.length; + if(start+length>Number(stat.size))throw new GrokWorkerAttestationFailure("profile_invalid"); + bytes=Buffer.alloc(length); + const read=await handle.read(bytes,0,bytes.length,start); + if(read.bytesRead!==bytes.length)throw new GrokWorkerAttestationFailure("profile_invalid"); + // The file must not change while it is read: a concurrent writer could + // otherwise show this check bytes that no single state of the file held. + const after=await handle.stat(); + if(Number(after.size)!==Number(stat.size)||Number(after.mtimeMs)!==Number(stat.mtimeMs))throw new GrokWorkerAttestationFailure("profile_invalid"); + if(accepted!==undefined){ + if(createHash("sha256").update(bytes).digest("hex")!==accepted.digest)throw new GrokWorkerAttestationFailure("profile_invalid"); + return; + } + const event=locateGrokWorkerProfileApplied(bytes,input.workspace,before.denyPaths); + if(lock!==undefined)lock.accepted={offset:event.offset,length:event.length,digest:createHash("sha256").update(bytes.subarray(event.offset,event.offset+event.length)).digest("hex")}; + }catch(error){if(error instanceof GrokWorkerAttestationFailure)throw error;throw new GrokWorkerAttestationFailure("profile_invalid");}finally{bytes?.fill(0);await handle.close();} } /** * Requires one fully-conforming `ProfileApplied` event *somewhere* in the * fresh region — not as its last line. * - * `sandbox-events.jsonl` is not a profile log. Grok 1.0.13 writes its whole + * `sandbox-events.jsonl` is not a profile log. Grok (1.0.13 and 1.0.34) writes its whole * sandbox event vocabulary there — verified by reading the shipped binary: * `ProfileApplied, ApplyFailed, FsViolation, NetViolation, BypassGranted, * BypassDenied` (one contiguous enum blob beside the record fields * `timestamp, event_type, read_only_paths, deny_paths, operation, target, * command, tool_call_id`, emitted from `xai_grok_sandbox::logging`), and its * own embedded documentation says so outright: "Sandbox events (profile - * applied, violations) are logged to `~/.grok/sandbox-events.jsonl`". + * applied, violations) are logged to `~/.grok/sandbox-events.jsonl`" (1.0.34 + * moved the file to `~/.grok/sessions/`; a 1.0.34 turn logs `ProfileApplied` + * followed by an `FsViolation` for every denied read). * * Requiring `ProfileApplied` to be the *last* line therefore failed on the * first denied access of any turn: the violation Grok logged next became the @@ -88,15 +152,19 @@ export async function verifyGrokWorkerAttestation(input:Readonly<{eventsPath:str * guard exists for: a Grok that came up without kernel enforcement, which * emits no conforming `ProfileApplied` at all. */ -export function parseGrokWorkerProfileApplied(bytes:Uint8Array,workspace:string,denyPaths:readonly string[]=[]):void{ +export function parseGrokWorkerProfileApplied(bytes:Uint8Array,workspace:string,denyPaths:readonly string[]=[]):void{locateGrokWorkerProfileApplied(bytes,workspace,denyPaths);} +/** Byte range (within `bytes`) of the first fully-conforming `ProfileApplied` line. */ +export function locateGrokWorkerProfileApplied(bytes:Uint8Array,workspace:string,denyPaths:readonly string[]=[]):Readonly<{offset:number;length:number}>{ const expected=JSON.stringify([...denyPaths].sort()); - for(const line of Buffer.from(bytes).toString("utf8").split("\n")){ + const buffer=Buffer.from(bytes.buffer,bytes.byteOffset,bytes.byteLength); + for(let offset=0;offset; try{const parsed=JSON.parse(line) as unknown;if(parsed===null||typeof parsed!=="object"||Array.isArray(parsed))continue;event=parsed as Record;}catch{continue;} if(event.event_type!=="ProfileApplied")continue; const observed=Array.isArray(event.deny_paths)?event.deny_paths.filter((entry):entry is string=>typeof entry==="string").sort():[]; - if(event.profile==="daimon-strict"&&event.enforced===true&&event.restrict_network===true&&event.platform==="linux/landlock"&&event.workspace===workspace&&JSON.stringify(observed)===expected)return; + if(event.profile==="daimon-strict"&&event.enforced===true&&event.restrict_network===true&&event.platform==="linux/landlock"&&event.workspace===workspace&&JSON.stringify(observed)===expected)return{offset:lineOffset,length:end-lineOffset}; } throw new Error("Grok worker isolation attestation unavailable"); } diff --git a/src/runtime/grokWorkerAttestationChecks.test.ts b/src/runtime/grokWorkerAttestationChecks.test.ts new file mode 100644 index 0000000..694391c --- /dev/null +++ b/src/runtime/grokWorkerAttestationChecks.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { appendFile, chmod, link, mkdir, mkdtemp, open, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { mock } from "node:test"; + +import { + GrokWorkerAttestationFailure, + prepareGrokWorkerAttestation, + verifyGrokWorkerAttestation, + type GrokWorkerAttestationSnapshot +} from "./grokWorkerAttestation.js"; + +const workspace = "/var/lib/daimon-workers/2200/workspace"; +const self = { uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0 }; +const applied = (overrides: Record = {}): string => + `${JSON.stringify({ event_type: "ProfileApplied", profile: "daimon-strict", enforced: true, restrict_network: true, platform: "linux/landlock", workspace, deny_paths: [], ...overrides })}\n`; +const violation = `${JSON.stringify({ event_type: "FsViolation", profile: "daimon-strict", operation: "read", target: "/run/paideia/context.json" })}\n`; + +const fixture = async (t: { after(fn: () => Promise): void }, initial = "") => { + const dir = await mkdtemp(path.join(os.tmpdir(), "daimon-guard-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, "sandbox-events.jsonl"); + await writeFile(file, initial); + await chmod(file, 0o640); + const info = await stat(file); + const before: GrokWorkerAttestationSnapshot = { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), denyPaths: [] }; + const input = { eventsPath: file, workerUid: self.uid, brokerGid: self.gid, workspace }; + return { dir, file, before, input }; +}; +const refusal = async (run: Promise): Promise => { + try { await run; } catch (error) { + assert.ok(error instanceof GrokWorkerAttestationFailure, `expected a GrokWorkerAttestationFailure, got ${String(error)}`); + return error.failureClass; + } + throw new Error("expected the attestation to be refused"); +}; + +test("refuses an events file with a second hard link", async (t) => { + const { dir, file, before, input } = await fixture(t); + await appendFile(file, applied()); + await verifyGrokWorkerAttestation(input, before); + await link(file, path.join(dir, "second-name.jsonl")); + assert.equal(await refusal(verifyGrokWorkerAttestation(input, before)), "profile_invalid"); +}); + +test("refuses events that change while they are being read", async (t) => { + const { file, before, input } = await fixture(t); + await appendFile(file, applied()); + const probe = await open(file, "r"); + const prototype = Object.getPrototypeOf(probe) as { read: (...args: unknown[]) => Promise }; + await probe.close(); + const original = prototype.read; + const reading = mock.method(prototype, "read", async function (this: unknown, ...args: unknown[]) { + const result = await original.apply(this, args); + await appendFile(file, violation); + return result; + }); + try { assert.equal(await refusal(verifyGrokWorkerAttestation(input, before)), "profile_invalid"); } finally { reading.mock.restore(); } + assert.ok(reading.mock.callCount() >= 1); +}); + +test("prepare refuses a worker home that fails attestation even when profile and events are valid", async (t) => { + // Run as a non-root owner so the profile and events legs pass; the home leg + // (root-owned, read-only config) cannot, and must be what refuses. + const home = await mkdtemp(path.join(os.tmpdir(), "daimon-guard-home-")); + t.after(() => rm(home, { recursive: true, force: true })); + const profile = path.join(home, "sandbox.toml"); + const text = '[profiles.daimon-strict]\nextends = "strict"\nrestrict_network = true\ndeny = []\n'; + await writeFile(profile, text); await chmod(profile, 0o444); + await mkdir(path.join(home, "sessions")); + const events = path.join(home, "sessions", "sandbox-events.jsonl"); + await writeFile(events, ""); await chmod(events, 0o640); + const input = { profilePath: profile, eventsPath: events, profileSha256: createHash("sha256").update(text).digest("hex"), workerUid: self.uid, brokerGid: self.gid, configSha256: "0".repeat(64) }; + await assert.rejects(prepareGrokWorkerAttestation(input, { uid: self.uid, gid: Number((await stat(profile)).gid) }), /attestation unavailable/u); +}); diff --git a/src/runtime/grokWorkerHomeAttestation.test.ts b/src/runtime/grokWorkerHomeAttestation.test.ts new file mode 100644 index 0000000..22f591e --- /dev/null +++ b/src/runtime/grokWorkerHomeAttestation.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { mock } from "node:test"; + +import { grokBrokerWorkerConfigSha256, renderGrokBrokerWorkerConfig } from "./grokBrokerWorkerConfig.js"; +import { assertGrokWorkerConfigBytes, assertGrokWorkerHomeEntries, verifyGrokWorkerHome } from "./grokWorkerHomeAttestation.js"; + +type Kind = "dir" | "file" | "link"; +const entry = (kind: Kind, mode: number, uid = 0, nlink = 1) => ({ + uid, mode: (kind === "dir" ? 0o040000 : kind === "file" ? 0o100000 : 0o120000) | mode, nlink, + isFile: () => kind === "file", isDirectory: () => kind === "dir", isSymbolicLink: () => kind === "link" +}); +const files = ["config.toml", "managed_config.toml", "requirements.toml", "sandbox.toml", "trusted_folders.toml"]; +const layout = (overrides: Record | undefined> = {}) => ({ + ".": entry("dir", 0o1771), sessions: entry("dir", 0o1771), ...Object.fromEntries(files.map((name) => [name, entry("file", 0o444)])), ...overrides +}); + +test("accepts the root-owned sticky worker home with root-owned read-only config and trust files", () => { + assert.doesNotThrow(() => assertGrokWorkerHomeEntries(layout())); + assert.doesNotThrow(() => assertGrokWorkerHomeEntries(layout({ ".": entry("dir", 0o711), sessions: entry("dir", 0o750) }))); +}); + +test("refuses a worker home where the worker could change its config or trust state", () => { + const refusals: Record> = { + "worker-owned home": layout({ ".": entry("dir", 0o1771, 2200) }), + "group-writable home without sticky bit": layout({ ".": entry("dir", 0o771) }), + "world-writable home": layout({ ".": entry("dir", 0o1777) }), + "worker-owned sessions": layout({ sessions: entry("dir", 0o700, 2200) }), + "group-writable sessions without sticky bit": layout({ sessions: entry("dir", 0o770) }) + }; + for (const name of files) { + refusals[`${name} missing`] = layout({ [name]: undefined }); + refusals[`${name} worker-owned`] = layout({ [name]: entry("file", 0o444, 2200) }); + refusals[`${name} owner-writable`] = layout({ [name]: entry("file", 0o644) }); + refusals[`${name} group-writable`] = layout({ [name]: entry("file", 0o464) }); + refusals[`${name} symlink`] = layout({ [name]: entry("link", 0o444) }); + refusals[`${name} hard-linked`] = layout({ [name]: entry("file", 0o444, 0, 2) }); + } + for (const [label, entries] of Object.entries(refusals)) assert.throws(() => assertGrokWorkerHomeEntries(entries), /attestation unavailable/u, label); +}); + +test("refuses a real home that is not root-owned even when every file exists", async (t) => { + const home = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-home-")); + t.after(() => rm(home, { recursive: true, force: true })); + await mkdir(path.join(home, "sessions")); + for (const name of files) await writeFile(path.join(home, name), "", { mode: 0o444 }); + await assert.rejects(verifyGrokWorkerHome(home, "0".repeat(64)), /attestation unavailable/u); +}); + +test("accepts only the renderer's exact config bytes for the declared model policy", () => { + const declared = { model: "grok-4.6", reasoningEffort: "low" } as const; + const bytes = Buffer.from(renderGrokBrokerWorkerConfig(declared)); + assert.doesNotThrow(() => assertGrokWorkerConfigBytes(bytes, grokBrokerWorkerConfigSha256(declared))); + for (const tampered of [ + renderGrokBrokerWorkerConfig({ model: "grok-4.6", reasoningEffort: "high" }), + renderGrokBrokerWorkerConfig(declared).replace(/\[skills\]\ndisabled = \[[^\]]*\]\n/u, ""), + renderGrokBrokerWorkerConfig(declared).replace('session_summary = "daimon-session-title-disabled"', 'session_summary = "grok-4.6"'), + `${renderGrokBrokerWorkerConfig(declared)}\n[mcp_servers.extra]\nurl = "http://127.0.0.1:1/mcp"\n` + ]) assert.throws(() => assertGrokWorkerConfigBytes(Buffer.from(tampered), grokBrokerWorkerConfigSha256(declared)), /attestation unavailable/u); +}); + +const ownHome = async (t: { after(fn: () => Promise): void }) => { + const home = await mkdtemp(path.join(os.tmpdir(), "daimon-grok-home-owned-")); + t.after(async () => { await chmod(home, 0o700); await rm(home, { recursive: true, force: true }); }); + await mkdir(path.join(home, "sessions")); + const declared = { model: "grok-4.6", reasoningEffort: "low" } as const; + for (const name of files) await writeFile(path.join(home, name), name === "config.toml" ? renderGrokBrokerWorkerConfig(declared) : "", { mode: 0o444 }); + await chmod(path.join(home, "sessions"), 0o1771); await chmod(home, 0o1771); + return { home, sha: grokBrokerWorkerConfigSha256(declared), uid: process.getuid?.() ?? 0 }; +}; + +test("attests a correctly laid out home whose config is the declared renderer output", async (t) => { + const { home, sha, uid } = await ownHome(t); + await verifyGrokWorkerHome(home, sha, uid); + await assert.rejects(verifyGrokWorkerHome(home, grokBrokerWorkerConfigSha256({ model: "grok-4.6", reasoningEffort: "high" }), uid), /attestation unavailable/u); +}); + +test("refuses a config.toml whose opened inode is not the one lstat saw", async (t) => { + const { home, sha, uid } = await ownHome(t); + const probe = await open(path.join(home, "config.toml"), "r"); + const prototype = Object.getPrototypeOf(probe) as { stat: (...args: unknown[]) => Promise<{ ino: number }> }; + await probe.close(); + const original = prototype.stat; + const swapped = mock.method(prototype, "stat", async function (this: unknown, ...args: unknown[]) { + const real = await original.apply(this, args); + return Object.assign(Object.create(Object.getPrototypeOf(real)), real, { ino: Number(real.ino) + 1 }); + }); + try { await assert.rejects(verifyGrokWorkerHome(home, sha, uid), /attestation unavailable/u); } finally { swapped.mock.restore(); } + await verifyGrokWorkerHome(home, sha, uid); +}); diff --git a/src/runtime/grokWorkerHomeAttestation.ts b/src/runtime/grokWorkerHomeAttestation.ts new file mode 100644 index 0000000..47b15ba --- /dev/null +++ b/src/runtime/grokWorkerHomeAttestation.ts @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { lstat, open } from "node:fs/promises"; +import path from "node:path"; + +import { GROK_ENGINE_BROKER } from "../contracts/runtimeContractManifest.js"; + +type Entry = Pick & Partial> & Readonly<{ isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }>; +const HOME = GROK_ENGINE_BROKER.worker.home; + +/** + * The worker must not be able to change what Grok reads before its next turn. + * + * Grok writes its own state (sessions, hooks, locks, docs) into `$GROK_HOME`, + * so the directory is worker-group writable — but root-owned and sticky, so a + * worker can neither rename nor unlink a root-owned file in it. Every file + * that decides a turn's behaviour is root-owned with no write bit: `config.toml` + * (model, effort, MCP, skills), `sandbox.toml`, `trusted_folders.toml` (trust + * would re-enable cwd `AGENTS.md` and project skills), and the managed and + * requirements layers that can override user config. A missing file fails + * too: the worker could create it. + * + * Pure so every refusal is testable without root. + */ +export function assertGrokWorkerHomeEntries(entries: Readonly>, rootUid: number = HOME.directory.uid): void { + const directory = (entry: Entry | undefined): boolean => + entry !== undefined && entry.isDirectory() && !entry.isSymbolicLink() && entry.uid === rootUid + && (Number(entry.mode) & 0o002) === 0 && ((Number(entry.mode) & 0o020) === 0 || (Number(entry.mode) & 0o1000) !== 0); + if (!directory(entries["."]) || !directory(entries[HOME.sessionsDirectory.relativePath])) throw unavailable(); + for (const name of HOME.readOnlyFiles.names) { + const entry = entries[name]; + if (entry === undefined || !entry.isFile() || entry.isSymbolicLink() || entry.uid !== rootUid || entry.nlink !== 1 || (Number(entry.mode) & 0o7222) !== 0) throw unavailable(); + } +} + +/** The worker's `config.toml` must be exactly the renderer's bytes for the declared policy. */ +export function assertGrokWorkerConfigBytes(bytes: Uint8Array, configSha256: string): void { + if (!/^[0-9a-f]{64}$/u.test(configSha256) || createHash("sha256").update(bytes).digest("hex") !== configSha256) throw unavailable(); +} + +/** + * Attests the worker home layout and that `config.toml` is exactly the + * renderer's bytes for the declared model policy (`configSha256`). + */ +export async function verifyGrokWorkerHome(grokHome: string, configSha256: string, rootUid: number = HOME.directory.uid): Promise { + const entries: Record = {}; + for (const name of [".", HOME.sessionsDirectory.relativePath, ...HOME.readOnlyFiles.names]) { + try { entries[name] = await lstat(path.join(grokHome, name)); } catch { entries[name] = undefined; } + } + assertGrokWorkerHomeEntries(entries, rootUid); + let handle: Awaited> | undefined; + try { + handle = await open(path.join(grokHome, "config.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const opened = await handle.stat(); + const before = entries["config.toml"]!; + // Same inode as the lstat above, as in `secureOpen`: a rename between the two cannot swap in other bytes. + if (!opened.isFile() || opened.size > 65_536 || opened.dev !== before.dev || opened.ino !== before.ino || opened.uid !== before.uid || opened.mode !== before.mode || opened.nlink !== 1) throw unavailable(); + assertGrokWorkerConfigBytes(await handle.readFile(), configSha256); + } catch { throw unavailable(); } finally { await handle?.close().catch(() => undefined); } +} + +const unavailable = (): Error => new Error("Grok worker isolation attestation unavailable"); diff --git a/src/runtime/grokWorkerIsolationGuard.test.ts b/src/runtime/grokWorkerIsolationGuard.test.ts new file mode 100644 index 0000000..cae9d81 --- /dev/null +++ b/src/runtime/grokWorkerIsolationGuard.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { appendFile, chmod, mkdtemp, open, readFile, rm, stat, truncate, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createGrokWorkerIsolationGuard, + GrokWorkerAttestationFailure, + verifyGrokWorkerAttestation, + type GrokWorkerAttestationSnapshot +} from "./grokWorkerAttestation.js"; + +const workspace = "/var/lib/daimon-workers/2200/workspace"; +const self = { uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0 }; +const applied = (overrides: Record = {}): string => + `${JSON.stringify({ event_type: "ProfileApplied", profile: "daimon-strict", enforced: true, restrict_network: true, platform: "linux/landlock", workspace, deny_paths: [], ...overrides })}\n`; +const violation = `${JSON.stringify({ event_type: "FsViolation", profile: "daimon-strict", operation: "read", target: "/run/paideia/context.json" })}\n`; + +const fixture = async (t: { after(fn: () => Promise): void }, initial = "") => { + const dir = await mkdtemp(path.join(os.tmpdir(), "daimon-guard-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, "sandbox-events.jsonl"); + await writeFile(file, initial); + await chmod(file, 0o640); + const info = await stat(file); + const before: GrokWorkerAttestationSnapshot = { dev: Number(info.dev), ino: Number(info.ino), size: Number(info.size), denyPaths: [] }; + const input = { eventsPath: file, workerUid: self.uid, brokerGid: self.gid, workspace }; + return { dir, file, before, input }; +}; +const refusal = async (run: Promise): Promise => { + try { await run; } catch (error) { + assert.ok(error instanceof GrokWorkerAttestationFailure, `expected a GrokWorkerAttestationFailure, got ${String(error)}`); + return error.failureClass; + } + throw new Error("expected the attestation to be refused"); +}; + +test("a turn refused at its first request stays refused after a conforming ProfileApplied is appended", async (t) => { + // Before request 1 only the launcher-started Grok can have written events; a + // line appended after it can come from a tool child and must never repair the turn. + const { file, before, input } = await fixture(t); + const guard = createGrokWorkerIsolationGuard(input, before); + await appendFile(file, applied({ enforced: false })); + assert.equal(await refusal(guard()), "profile_invalid"); + await appendFile(file, applied()); + assert.equal(await refusal(guard()), "profile_invalid"); + // Without the lock the same bytes would be accepted, so the refusal above is the lock's doing. + await verifyGrokWorkerAttestation(input, before); +}); + +test("later requests need the accepted event at the same offset, not any newly appended one", async (t) => { + const { file, before, input } = await fixture(t, "{\"event_type\":\"stale\"}\n"); + const guard = createGrokWorkerIsolationGuard(input, before); + await appendFile(file, `${violation}${applied()}`); + await guard(); + await appendFile(file, `${violation}${applied()}`); + await guard(); + + // Rewrite the accepted line in place (same length, different bytes) and append a fresh conforming one. + const bytes = await readFile(file, "utf8"); + const acceptedAt = bytes.indexOf(applied()); + const handle = await open(file, "r+"); + try { await handle.write(Buffer.from(applied({ workspace: workspace.replace("workspace", "workspacX") })), 0, undefined, acceptedAt); } finally { await handle.close(); } + await appendFile(file, applied()); + assert.equal(await refusal(guard()), "profile_invalid"); +}); + +test("a truncated accepted event region is refused on the next request", async (t) => { + const { file, before, input } = await fixture(t); + const guard = createGrokWorkerIsolationGuard(input, before); + await appendFile(file, applied()); + await guard(); + await truncate(file, 10); + await appendFile(file, `\n${applied()}`); + assert.equal(await refusal(guard()), "profile_invalid"); +}); diff --git a/src/runtime/grokWorkerSandboxProfile.test.ts b/src/runtime/grokWorkerSandboxProfile.test.ts new file mode 100644 index 0000000..7c24264 --- /dev/null +++ b/src/runtime/grokWorkerSandboxProfile.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; + +import { parseGrokWorkerSandboxProfile } from "./grokWorkerAttestation.js"; +import { grokWorkerEventsPathFor, grokWorkerSandboxProfileSha256, renderGrokWorkerSandboxProfile } from "./grokWorkerSandboxProfile.js"; + +test("renders a non-empty deny list deterministically and round-trips through the attestation parser", () => { + const profile = renderGrokWorkerSandboxProfile(["/run/training/inputs", "/run/paideia", "/run/paideia"]); + assert.equal(profile, '[profiles.daimon-strict]\nextends = "strict"\nrestrict_network = true\ndeny = ["/run/paideia", "/run/training/inputs"]\n'); + assert.equal(renderGrokWorkerSandboxProfile(["/run/paideia", "/run/training/inputs"]), profile); + const digest = createHash("sha256").update(profile).digest("hex"); + assert.equal(grokWorkerSandboxProfileSha256(["/run/training/inputs", "/run/paideia"]), digest); + assert.deepEqual(parseGrokWorkerSandboxProfile(Buffer.from(profile), digest), ["/run/paideia", "/run/training/inputs"]); + assert.equal(renderGrokWorkerSandboxProfile(), '[profiles.daimon-strict]\nextends = "strict"\nrestrict_network = true\ndeny = []\n'); +}); + +test("refuses deny paths that are relative, non-canonical, root, globbed, or TOML-breaking", () => { + for (const entry of ["run/paideia", "/run/../etc", "/run/paideia/", "/", "/run/*", '/run/"x', "/run/a\nb", "/run/a\\b", ""]) { + assert.throws(() => renderGrokWorkerSandboxProfile([entry]), /deny path/u, JSON.stringify(entry)); + } +}); + +test("derives the Grok 1.0.34 sandbox events log from the profile location", () => { + assert.equal(grokWorkerEventsPathFor("/var/lib/daimon-workers/2200/.grok/sandbox.toml"), "/var/lib/daimon-workers/2200/.grok/sessions/sandbox-events.jsonl"); +}); diff --git a/src/runtime/grokWorkerSandboxProfile.ts b/src/runtime/grokWorkerSandboxProfile.ts new file mode 100644 index 0000000..b6ff126 --- /dev/null +++ b/src/runtime/grokWorkerSandboxProfile.ts @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +export const GROK_WORKER_SANDBOX_PROFILE = "daimon-strict" as const; +export const GROK_WORKER_SANDBOX_EVENTS_RELATIVE_PATH = "sessions/sandbox-events.jsonl" as const; + +/** + * The only source of `daimon-strict` sandbox profile bytes. + * + * Grok 1.0.34 runs every Landlock profile inside bubblewrap, and there a + * non-empty `deny` list works: each entry is bind-masked for both the shell + * tool and in-process `read_file` (P0: `/run/paideia` denied, controls intact). + * The strict base still reads all of `/run`, `/var`, `/tmp` and `/etc`, so the + * deny list — not Landlock's allowlist — is what keeps evaluator and host-bind + * paths away from the worker. Which paths to deny is a registration input + * supplied by the deployment; Daimon only renders, pins, and attests them. + * + * Entries are sorted and deduplicated so equal sets render equal bytes (and the + * same `profileSha256`). A path that is not absolute and canonical, or that + * carries a character TOML or Grok would reinterpret, is refused. + */ +export function renderGrokWorkerSandboxProfile(denyPaths: readonly string[] = []): string { + const denied = [...new Set(denyPaths)].sort(); + for (const entry of denied) { + if (!path.posix.isAbsolute(entry) || path.posix.normalize(entry) !== entry || entry === "/" || entry.endsWith("/") || /["\\\u0000-\u001f\u007f*?[\]]/u.test(entry)) { + throw new TypeError("invalid Grok worker sandbox deny path"); + } + } + return [ + `[profiles.${GROK_WORKER_SANDBOX_PROFILE}]`, + 'extends = "strict"', + "restrict_network = true", + `deny = [${denied.map((entry) => JSON.stringify(entry)).join(", ")}]`, + "" + ].join("\n"); +} + +export const grokWorkerSandboxProfileSha256 = (denyPaths: readonly string[] = []): string => + createHash("sha256").update(renderGrokWorkerSandboxProfile(denyPaths)).digest("hex"); + +/** `$GROK_HOME` is the profile's directory; 1.0.34 logs sandbox events under `sessions/`. */ +export const grokWorkerEventsPathFor = (profilePath: string): string => + path.posix.join(path.posix.dirname(profilePath), GROK_WORKER_SANDBOX_EVENTS_RELATIVE_PATH); diff --git a/src/runtime/native/AGENTS.md b/src/runtime/native/AGENTS.md index f8e468b..3e7f8ae 100644 --- a/src/runtime/native/AGENTS.md +++ b/src/runtime/native/AGENTS.md @@ -11,3 +11,28 @@ forbidden. Prompt and scoped capability bytes cross as inherited sealed file descriptors, not protocol strings. Workers must start in a private process group with no-new-privileges, no capabilities, no core dump, and a parent-death signal. Unsupported platforms fail closed. + +The worker argv is one compiled constant: the lean Grok 1.0.34 flags, the +`DBL_GROK_SYSTEM_PROMPT` operating contract, the `--tools` allowlist and the +`--max-turns` backstop live in `engineBrokerLauncher.h`, mirrored byte-for-byte +by `src/contracts/grokWorkerContract.ts` and checked by `launcherArgv.test.ts`. +Model and reasoning effort are per-deployment and belong to the worker +`config.toml`, never to the argv. + +The launcher sets exactly two turn-scoped capabilities in the worker +environment: `DAIMON_MCP_CAPABILITY` and `DAIMON_PROVIDER_CAPABILITY` (Grok +1.0.34 ignores `[auth_provider.*]` helpers for custom models, so the worker +config reads the proxy capability through `env_key`). `--auth-provider` mode +remains for callers of the older contract. + +Received descriptors carry `MSG_CMSG_CLOEXEC` and can already occupy fds 3-5, +so `launch()` lifts prompt, capability, output, executable and status fds above +16 before `dup2`-ing them into place; a `dup2` onto itself keeps close-on-exec +and the prompt used to vanish at exec. `fixtureWorker.c` reads +`/proc/self/fd/3` so the integration suite fails if that regresses. + +The executable is re-hashed on every spawn (~58 ms for the 136 MB Grok binary). +Holding one verified descriptor and `execveat`-ing it would not make a replaced +binary unrunnable: Grok 1.0.34 re-executes itself inside bubblewrap by path +(`/usr/local/bin/grok`), so the image path's root ownership, not the launcher +descriptor, is what protects the sandboxed process. diff --git a/src/runtime/native/artifacts/daimon-engine-broker-arm64 b/src/runtime/native/artifacts/daimon-engine-broker-arm64 index f028a3c..daeaf6e 100755 Binary files a/src/runtime/native/artifacts/daimon-engine-broker-arm64 and b/src/runtime/native/artifacts/daimon-engine-broker-arm64 differ diff --git a/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json b/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json index 85dafe3..6eb9ffd 100644 --- a/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json +++ b/src/runtime/native/artifacts/daimon-engine-broker-arm64.provenance.json @@ -1 +1 @@ -{"version":"daimon.engine-broker-native-build.v1","architecture":"arm64","target":"linux/arm64","builder_image":"gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15","compiler":"gcc-14","source_sha256":"sha256:bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58","binary_sha256":"sha256:ad44e02c38e6a3207ac4a3d5fd98b6d2e55341ce42dfd2f07204bbe54a7a653d","install_path":"/opt/daimon/bin/daimon-engine-broker"} +{"version":"daimon.engine-broker-native-build.v1","architecture":"arm64","target":"linux/arm64","builder_image":"gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15","compiler":"gcc-14","source_sha256":"sha256:36f60689f0a8af0e3108f5f53d78ed52b7d4b6f934c75b6184606dfa82bc741e","binary_sha256":"sha256:c93216cc6fa4ca50dc404fe41e68da9150a869b14f46eb42484ae77c3aa400a9","install_path":"/opt/daimon/bin/daimon-engine-broker"} diff --git a/src/runtime/native/artifacts/daimon-engine-broker-x64 b/src/runtime/native/artifacts/daimon-engine-broker-x64 index 3b0b5d1..fce563c 100755 Binary files a/src/runtime/native/artifacts/daimon-engine-broker-x64 and b/src/runtime/native/artifacts/daimon-engine-broker-x64 differ diff --git a/src/runtime/native/artifacts/daimon-engine-broker-x64.provenance.json b/src/runtime/native/artifacts/daimon-engine-broker-x64.provenance.json index 5514ae9..f2bcf17 100644 --- a/src/runtime/native/artifacts/daimon-engine-broker-x64.provenance.json +++ b/src/runtime/native/artifacts/daimon-engine-broker-x64.provenance.json @@ -1 +1 @@ -{"version":"daimon.engine-broker-native-build.v1","architecture":"x64","target":"linux/amd64","builder_image":"gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15","compiler":"gcc-14","source_sha256":"sha256:bdcab1e12dcc531ed8e56f890263ca23a9ee7bac468191dd598e143df4ff8c58","binary_sha256":"sha256:e3fe2738fc8a979861085b4003bf2d5d7c284874897cb6ec2e2e2383211768bd","install_path":"/opt/daimon/bin/daimon-engine-broker"} +{"version":"daimon.engine-broker-native-build.v1","architecture":"x64","target":"linux/amd64","builder_image":"gcc:14-bookworm@sha256:5e927c284bf55a7dc796262e311a0703344f62f41f5621eb56843111b1d37e15","compiler":"gcc-14","source_sha256":"sha256:36f60689f0a8af0e3108f5f53d78ed52b7d4b6f934c75b6184606dfa82bc741e","binary_sha256":"sha256:36dc76b134eb59cf5a6720b6f94228eb279108e20ea3343fa6efd9ffcb60a4d3","install_path":"/opt/daimon/bin/daimon-engine-broker"} diff --git a/src/runtime/native/artifactsManifest.test.ts b/src/runtime/native/artifactsManifest.test.ts new file mode 100644 index 0000000..259bcdc --- /dev/null +++ b/src/runtime/native/artifactsManifest.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { GROK_ENGINE_BROKER } from "../../contracts/runtimeContractManifest.js"; + +const sources = ["engineBrokerLauncher.c", "engineBrokerLauncher.h", "engineBrokerLauncherCore.inc", "engineBrokerLauncherServer.inc", "engineBrokerLauncherModes.inc", "engineBrokerLauncherMain.inc"]; +const read = (name: string): Buffer => readFileSync(new URL(`./${name}`, import.meta.url)); + +test("the manifest pins the committed native artifacts, their provenance, and the current launcher source", () => { + const source = createHash("sha256"); + for (const name of sources) source.update(read(name)); + assert.equal(GROK_ENGINE_BROKER.artifacts.sourceSha256, source.digest("hex"), "launcher source changed without rebuilding the native artifacts"); + for (const [architecture, pinned] of [["x64", GROK_ENGINE_BROKER.artifacts.x64Sha256], ["arm64", GROK_ENGINE_BROKER.artifacts.arm64Sha256]] as const) { + const binary = read(`artifacts/daimon-engine-broker-${architecture}`); + const provenance = JSON.parse(read(`artifacts/daimon-engine-broker-${architecture}.provenance.json`).toString("utf8")) as Record; + const digest = createHash("sha256").update(binary).digest("hex"); + assert.equal(digest, pinned, architecture); + assert.equal(provenance.binary_sha256, `sha256:${digest}`, architecture); + assert.equal(provenance.source_sha256, `sha256:${GROK_ENGINE_BROKER.artifacts.sourceSha256}`, architecture); + assert.equal(provenance.install_path, GROK_ENGINE_BROKER.nativeExecutablePath, architecture); + } +}); diff --git a/src/runtime/native/engineBrokerLauncher.h b/src/runtime/native/engineBrokerLauncher.h index 76e7ce9..d3a9512 100644 --- a/src/runtime/native/engineBrokerLauncher.h +++ b/src/runtime/native/engineBrokerLauncher.h @@ -21,6 +21,12 @@ #ifndef DBL_EXECUTABLE #define DBL_EXECUTABLE "/usr/local/bin/grok" #endif +/* Lean Grok worker contract; mirrored byte-for-byte by src/contracts/grokWorkerContract.ts + and checked by launcherArgv.test.ts. */ +#define DBL_GROK_SYSTEM_PROMPT \ + "You are a headless Daimon agent; no human is present. Your identity, instructions and wake event are in the user prompt. Daimon tools are MCP tools on server daimon: call a known one directly with use_tool (tool_name daimon__moltnet_read, daimon__moltnet_send, daimon__memory_search, daimon__memory_register, or another daimon__ name you were given); use search_tool only for a name you do not know. If a tool result says output was saved to a file, read that path with read_file. If a tool fails, do not retry it in a loop: stop and report the failure. Your final answer is a private note to the runtime: one line, or empty." +#define DBL_GROK_TOOLS "run_terminal_cmd,read_file,grep,list_dir,search_tool,use_tool" +#define DBL_GROK_MAX_TURNS "48" struct dbl_request { uint32_t version, slot; diff --git a/src/runtime/native/engineBrokerLauncherCore.inc b/src/runtime/native/engineBrokerLauncherCore.inc index 8329892..cb2b9ec 100644 --- a/src/runtime/native/engineBrokerLauncherCore.inc +++ b/src/runtime/native/engineBrokerLauncherCore.inc @@ -257,14 +257,19 @@ static __attribute__((noreturn)) void launch_fail(int status_fd, static pid_t launch(const struct dbl_registration *r, int executable, int prompt, int capability, int output, uint32_t *failure, uint64_t *observed_start_ticks) { - unsigned char mcp[DBL_MAX_TOKEN + 1] = {0}; + unsigned char provider[DBL_MAX_TOKEN + 1] = {0}, mcp[DBL_MAX_TOKEN + 1] = {0}; int status_pipe[2]; - if (capability_bundle(capability, NULL, mcp) || pipe2(status_pipe, O_CLOEXEC)) + if (capability_bundle(capability, provider, mcp) || + pipe2(status_pipe, O_CLOEXEC)) { + erase(provider, sizeof(provider)); + erase(mcp, sizeof(mcp)); return -1; + } pid_t p = fork(); if (p < 0) { close(status_pipe[0]); close(status_pipe[1]); + erase(provider, sizeof(provider)); erase(mcp, sizeof(mcp)); return -1; } @@ -275,6 +280,7 @@ static pid_t launch(const struct dbl_registration *r, int executable, sizeof(*observed_start_ticks)) || !*observed_start_ticks) { close(status_pipe[0]); + erase(provider, sizeof(provider)); erase(mcp, sizeof(mcp)); kill(p, SIGKILL); waitpid(p, NULL, 0); @@ -282,6 +288,7 @@ static pid_t launch(const struct dbl_registration *r, int executable, } ssize_t got = read(status_pipe[0], &code, sizeof(code)); close(status_pipe[0]); + erase(provider, sizeof(provider)); erase(mcp, sizeof(mcp)); if (got == 0) return p; @@ -306,12 +313,30 @@ static pid_t launch(const struct dbl_registration *r, int executable, if (prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0) || prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) launch_fail(status_pipe[1], 3); - if (dup2(prompt, 3) < 0 || dup2(capability, 4) < 0 || - dup2(output, STDOUT_FILENO) < 0 || dup2(output, STDERR_FILENO) < 0) + /* Received fds carry MSG_CMSG_CLOEXEC and may already sit on 3-5: dup2 onto + itself keeps close-on-exec (the prompt vanished at exec) and an + overlapping order clobbers a source. Lift all of them above the targets + first so every dup2 below changes the fd number and clears CLOEXEC. */ + int status_fd = fcntl(status_pipe[1], F_DUPFD_CLOEXEC, 16); + if (status_fd < 0) launch_fail(status_pipe[1], 4); - if (executable != 5 && dup2(executable, 5) < 0) - launch_fail(status_pipe[1], 5); - close_other_fds(status_pipe[1]); + int null_input = open("/dev/null", O_RDONLY | O_CLOEXEC); + int high_prompt = fcntl(prompt, F_DUPFD_CLOEXEC, 16), + high_capability = fcntl(capability, F_DUPFD_CLOEXEC, 16), + high_output = fcntl(output, F_DUPFD_CLOEXEC, 16), + high_executable = fcntl(executable, F_DUPFD_CLOEXEC, 16); + if (null_input < 0 || high_prompt < 0 || high_capability < 0 || + high_output < 0 || high_executable < 0 || + dup2(null_input, STDIN_FILENO) < 0 || dup2(high_prompt, 3) < 0 || + dup2(high_capability, 4) < 0 || dup2(high_output, STDOUT_FILENO) < 0 || + dup2(high_output, STDERR_FILENO) < 0) + launch_fail(status_fd, 4); + /* The worker needs fd 5 only to be executed: close-on-exec keeps the Grok + image descriptor out of the worker and every tool child (execveat with + AT_EMPTY_PATH still works for an ELF; only a #! script would lose it). */ + if (dup2(high_executable, 5) < 0 || fcntl(5, F_SETFD, FD_CLOEXEC) < 0) + launch_fail(status_fd, 5); + close_other_fds(status_fd); char *const argv[] = {"grok", "--sandbox", "daimon-strict", @@ -321,6 +346,14 @@ static pid_t launch(const struct dbl_registration *r, int executable, "/proc/self/fd/3", "--no-memory", "--disable-web-search", + "--no-plan", + "--verbatim", + "--system-prompt-override", + DBL_GROK_SYSTEM_PROMPT, + "--tools", + DBL_GROK_TOOLS, + "--max-turns", + DBL_GROK_MAX_TURNS, "--cwd", (char *)r->workspace, "--output-format", @@ -328,21 +361,30 @@ static pid_t launch(const struct dbl_registration *r, int executable, "--model", "daimon-broker-grok", NULL}; - char home[300], grok[300], mcp_env[DBL_MAX_TOKEN + 24]; + char home[300], grok[300], mcp_env[DBL_MAX_TOKEN + 24], + provider_env[DBL_MAX_TOKEN + 32]; snprintf(home, sizeof(home), "HOME=%s", r->home); snprintf(grok, sizeof(grok), "GROK_HOME=%s/.grok", r->home); snprintf(mcp_env, sizeof(mcp_env), "DAIMON_MCP_CAPABILITY=%s", mcp); + /* Grok 1.0.34 never runs `[auth_provider.*]` helpers for a custom model, so + the worker's `[model.daimon-broker-grok] env_key` reads the turn-scoped + proxy capability from here. It is as exposed as the MCP capability. */ + snprintf(provider_env, sizeof(provider_env), "DAIMON_PROVIDER_CAPABILITY=%s", + provider); + erase(provider, sizeof(provider)); erase(mcp, sizeof(mcp)); char *const envp[] = {home, grok, mcp_env, + provider_env, "DAIMON_CAPABILITY_FD=4", "PATH=/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8", "TZ=UTC", NULL}; syscall(SYS_execveat, 5, "", argv, envp, AT_EMPTY_PATH); + erase(provider_env, sizeof(provider_env)); erase(mcp_env, sizeof(mcp_env)); - launch_fail(status_pipe[1], 6); + launch_fail(status_fd, 6); } diff --git a/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc b/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc index 8458226..ee189d9 100644 --- a/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc +++ b/src/runtime/native/engineBrokerLauncherIntegrationLauncher.inc @@ -110,8 +110,22 @@ static void org_cases(void) { char *out = calloc(1, r.output_length + 1); check(read(s, out, r.output_length) == (ssize_t)r.output_length && strstr(out, "uid=2200") && strstr(out, "--always-approve") && + strstr(out, " prompt=prompt fds=0,1,2,3,4 stdin=/dev/null\n") && !strstr(out, "EVIL"), "fixed worker boundary"); + check(strstr(out, "=--verbatim\n") && strstr(out, "=--no-plan\n") && + strstr(out, "=--no-subagents\n") && strstr(out, "=--no-memory\n") && + strstr(out, "=--disable-web-search\n") && + strstr(out, "=--tools\n") && strstr(out, "=" DBL_GROK_TOOLS "\n") && + strstr(out, "=--max-turns\n") && + strstr(out, "=" DBL_GROK_MAX_TURNS "\n") && + strstr(out, "=--system-prompt-override\n") && + strstr(out, "=" DBL_GROK_SYSTEM_PROMPT "\n") && + strstr(out, "=--prompt-file\n") && + strstr(out, "=/proc/self/fd/3\n") && + !strstr(out, "--reasoning-effort") && + strstr(out, " argc=23 "), + "lean worker argv"); free(out); close(s); q = request(); diff --git a/src/runtime/native/engineBrokerLauncherIntegrationMain.inc b/src/runtime/native/engineBrokerLauncherIntegrationMain.inc index 0b816dd..1191e2d 100644 --- a/src/runtime/native/engineBrokerLauncherIntegrationMain.inc +++ b/src/runtime/native/engineBrokerLauncherIntegrationMain.inc @@ -1,3 +1,52 @@ +/* A worker that fails before or at exec must not run anything: the launcher + child exits instead. The registered "executable" is a #! script; fd 5 is + close-on-exec, so execveat(AT_EMPTY_PATH) cannot run it (a script needs + /dev/fd/5 after exec) and the turn fails at the exec stage. */ +static void exec_failure_case(struct dbl_registration r) { + const char *marker = "/tmp/exec-failure-script-ran"; + unlink(marker); + check(rename("/usr/local/bin/grok", "/usr/local/bin/grok.fixture") == 0, + "exec failure fixture move"); + int script = open("/usr/local/bin/grok", O_CREAT | O_EXCL | O_WRONLY, 0755); + const char body[] = "#!/bin/sh\ntouch /tmp/exec-failure-script-ran\n"; + check(script >= 0 && write(script, body, sizeof(body) - 1) == + (ssize_t)(sizeof(body) - 1), + "exec failure script"); + close(script); + digest("/usr/local/bin/grok", r.executable_sha256); + int f = open(DBL_REGISTRY, O_TRUNC | O_WRONLY, 0600); + check(f >= 0 && write(f, &r, sizeof(r)) == sizeof(r), "exec failure registry"); + close(f); + pid_t child = fork(); + if (!child) { + setgid(DBL_BROKER_UID); + setuid(DBL_BROKER_UID); + int s = connect_socket(), p = sealed("prompt"), + c = sealed_bundle("provider.Exec-1", "mcp.Exec-2"); + struct dbl_request q = request(); + struct dbl_result result; + send_request(s, &q, p, c); + _exit(read_all(s, &result, sizeof(result)) && + result.status == DBL_STATUS_PRELAUNCH_FAILED && + result.stage == DBL_STAGE_EXEC && + result.failure_class == DBL_FAILURE_EXEC && + result.worker_pid == 0 + ? 0 + : 1); + } + int status; + waitpid(child, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "exec failure result"); + sleep(1); + check(access(marker, F_OK) != 0, "exec failure ran nothing"); + unlink("/usr/local/bin/grok"); + check(rename("/usr/local/bin/grok.fixture", "/usr/local/bin/grok") == 0, + "exec failure fixture restore"); + digest("/usr/local/bin/grok", r.executable_sha256); + f = open(DBL_REGISTRY, O_TRUNC | O_WRONLY, 0600); + check(f >= 0 && write(f, &r, sizeof(r)) == sizeof(r), "registry restore"); + close(f); +} int main(void) { setbuf(stdout, NULL); alarm(40); @@ -43,8 +92,13 @@ int main(void) { auth_adversarial_cases(); puts("native-stage auth complete"); pid_t broker = fork(); - if (!broker) + if (!broker) { + /* A real launcher stdin, so a worker inheriting it is visible. */ + int launcher_stdin = open("/etc/hostname", O_RDONLY); + if (launcher_stdin < 0 || dup2(launcher_stdin, STDIN_FILENO) < 0) + _exit(1); execl("/opt/daimon/bin/daimon-engine-broker", "daimon-engine-broker", NULL); + } check(broker > 0, "broker fork"); wait_launcher_ready(broker); root_peer_rejects(); @@ -56,6 +110,9 @@ int main(void) { } int status; waitpid(org, &status, 0); + check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "org cases"); + exec_failure_case(r); + puts("native-stage exec failure complete"); kill(broker, SIGKILL); waitpid(broker, 0, 0); check(WIFEXITED(status) && WEXITSTATUS(status) == 0, "org cases"); diff --git a/src/runtime/native/fixtureWorker.c b/src/runtime/native/fixtureWorker.c index 6baaad7..b26a4ae 100644 --- a/src/runtime/native/fixtureWorker.c +++ b/src/runtime/native/fixtureWorker.c @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "engineBrokerLauncher.h" -int main(int argc,char**argv){unsigned char bundle[8200]={0};ssize_t size=pread(4,bundle,sizeof(bundle),0);if(size<6)return 21;size_t offset=0;unsigned provider_length=bundle[0]|(bundle[1]<<8);offset=2;if(offset+provider_length+2>(size_t)size)return 21;char provider[4097]={0};memcpy(provider,bundle+offset,provider_length);offset+=provider_length;unsigned mcp_length=bundle[offset]|(bundle[offset+1]<<8);offset+=2;if(offset+mcp_length!=(size_t)size)return 21;char mcp[4097]={0};memcpy(mcp,bundle+offset,mcp_length);memset(bundle,0,sizeof(bundle));int exact=!strcmp(provider,"exact-output"),overflow=!strcmp(provider,"overflow-output");if(!strcmp(provider,"sleep"))sleep(30);if(!getenv("DAIMON_MCP_CAPABILITY")||strcmp(getenv("DAIMON_MCP_CAPABILITY"),mcp)||strstr(getenv("DAIMON_MCP_CAPABILITY"),provider))return 24;int output[2];if(pipe(output))return 22;pid_t child=fork();if(!child){dup2(output[1],1);close(output[0]);execl("/opt/daimon/bin/daimon-engine-broker","daimon-engine-broker","--auth-provider",NULL);_exit(127);}close(output[1]);char auth[8192]={0};ssize_t got=read(output[0],auth,sizeof(auth)-1);close(output[0]);int status;waitpid(child,&status,0);char expected[8192];snprintf(expected,sizeof(expected),"{\"access_token\":\"%s\",\"expires_in\":600}\n",provider);memset(provider,0,sizeof(provider));memset(mcp,0,sizeof(mcp));if(!WIFEXITED(status)||WEXITSTATUS(status)||got<1||strcmp(auth,expected))return 23;memset(auth,0,sizeof(auth));memset(expected,0,sizeof(expected));if(exact||overflow){size_t count=DBL_MAX_OUTPUT+(size_t)overflow;for(size_t i=0;i(size_t)size)return 21;char provider[4097]={0};memcpy(provider,bundle+offset,provider_length);offset+=provider_length;unsigned mcp_length=bundle[offset]|(bundle[offset+1]<<8);offset+=2;if(offset+mcp_length!=(size_t)size)return 21;char mcp[4097]={0};memcpy(mcp,bundle+offset,mcp_length);memset(bundle,0,sizeof(bundle));int exact=!strcmp(provider,"exact-output"),overflow=!strcmp(provider,"overflow-output");if(!strcmp(provider,"sleep"))sleep(30);if(!getenv("DAIMON_MCP_CAPABILITY")||strcmp(getenv("DAIMON_MCP_CAPABILITY"),mcp)||strstr(getenv("DAIMON_MCP_CAPABILITY"),provider))return 24;if(!getenv("DAIMON_PROVIDER_CAPABILITY")||strcmp(getenv("DAIMON_PROVIDER_CAPABILITY"),provider))return 28;int output[2];if(pipe(output))return 22;pid_t child=fork();if(!child){dup2(output[1],1);close(output[0]);execl("/opt/daimon/bin/daimon-engine-broker","daimon-engine-broker","--auth-provider",NULL);_exit(127);}close(output[1]);char auth[8192]={0};ssize_t got=read(output[0],auth,sizeof(auth)-1);close(output[0]);int status;waitpid(child,&status,0);char expected[8192];snprintf(expected,sizeof(expected),"{\"access_token\":\"%s\",\"expires_in\":600}\n",provider);memset(provider,0,sizeof(provider));memset(mcp,0,sizeof(mcp));if(!WIFEXITED(status)||WEXITSTATUS(status)||got<1||strcmp(auth,expected))return 23;memset(auth,0,sizeof(auth));memset(expected,0,sizeof(expected));if(exact||overflow){size_t count=DBL_MAX_OUTPUT+(size_t)overflow;for(size_t i=0;id_name[0]=='.')continue;int fd_number=atoi(fd_entry->d_name);if(fd_number==dirfd(fd_dir))continue;if(fd_number<0||fd_number>=64)return 30;fd_seen[fd_number]=1;}closedir(fd_dir);for(int fd_number=0;fd_number<64;fd_number++)if(fd_seen[fd_number])fds_used+=(size_t)snprintf(fds+fds_used,sizeof(fds)-fds_used,"%s%d",fds_used?",":"",fd_number);char stdin_target[64]={0};if(readlink("/proc/self/fd/0",stdin_target,sizeof(stdin_target)-1)<0)return 31;printf("uid=%ld argc=%d home=%s auth=ok mcp=ok prompt=%s fds=%s stdin=%s\n",(long)getuid(),argc,getenv("HOME"),prompt_bytes,fds,stdin_target);for(int i=0;i readFileSync(new URL(`./${name}`, import.meta.url), "utf8"); +const unquote = (literal: string): string => { + assert.match(literal, /^"[^"\\]*"$/u, "launcher literals must not need escaping"); + return literal.slice(1, -1); +}; + +/** `#define NAME "value"` (optionally continued onto the next line) from the launcher header. */ +const defines = (): ReadonlyMap => { + const header = read("engineBrokerLauncher.h").replace(/\\\n\s*/gu, ""); + return new Map([...header.matchAll(/^#define (DBL_GROK_[A-Z_]+)\s+("[^"\n]*")$/gmu)].map((match) => [match[1]!, unquote(match[2]!)])); +}; + +/** The compiled worker argv, token by token, exactly as `launch()` passes it to `execveat`. */ +const compiledArgv = (): readonly string[] => { + const source = read("engineBrokerLauncherCore.inc"); + const block = source.match(/char \*const argv\[\] = \{([\s\S]*?)NULL\};/u); + assert.ok(block, "launcher argv array not found"); + const values = defines(); + return block[1]!.split(",").map((token) => token.trim()).filter(Boolean).map((token) => { + if (token.startsWith('"')) return unquote(token); + if (token === "(char *)r->workspace") return "/registered/workspace"; + const value = values.get(token); + assert.ok(value !== undefined, `unexpected launcher argv token ${token}`); + return value; + }); +}; + +test("the native launcher compiles exactly the lean Grok worker argv Daimon renders", () => { + const argv = compiledArgv(); + assert.equal(argv[0], "grok"); + assert.deepEqual(argv.slice(1), renderGrokBrokerWorkerArgs("/proc/self/fd/3", "/registered/workspace")); + for (const flag of ["--verbatim", "--no-plan", "--no-subagents", "--no-memory", "--disable-web-search", "--always-approve"]) assert.ok(argv.includes(flag), flag); + assert.equal(argv[argv.indexOf("--tools") + 1], GROK_WORKER_TOOL_IDS.join(",")); + assert.equal(argv[argv.indexOf("--max-turns") + 1], String(GROK_WORKER_MAX_TURNS)); + assert.equal(argv.includes("--reasoning-effort"), false, "effort is declared per deployment in config.toml, never compiled"); + assert.equal(argv.includes("--disallowed-tools"), false, "--disallowed-tools is ignored under --tools"); +}); + +test("the compiled system prompt is byte-identical to the contract prompt pinned in the manifest", () => { + const compiled = defines().get("DBL_GROK_SYSTEM_PROMPT"); + assert.equal(compiled, DAIMON_GROK_SYSTEM_PROMPT); + assert.equal(createHash("sha256").update(DAIMON_GROK_SYSTEM_PROMPT).digest("hex"), GROK_ENGINE_BROKER.worker.systemPromptSha256); + assert.match(DAIMON_GROK_SYSTEM_PROMPT, /^[\x20-\x7e]+$/u); + assert.ok(DAIMON_GROK_SYSTEM_PROMPT.length >= 320 && DAIMON_GROK_SYSTEM_PROMPT.length <= 700, "roughly 80-150 tokens"); + for (const tool of ["daimon__moltnet_read", "daimon__moltnet_send", "use_tool", "search_tool", "read_file"]) assert.ok(DAIMON_GROK_SYSTEM_PROMPT.includes(tool), tool); +}); + +test("the launcher exports the turn provider capability under the env_key the worker config reads", () => { + const source = read("engineBrokerLauncherCore.inc"); + assert.match(source, new RegExp(`"${GROK_BROKER_PROVIDER_CAPABILITY_ENV}=%s"`, "u")); + assert.match(source, /char \*const envp\[\] = \{home,\s*grok,\s*mcp_env,\s*provider_env,/u); + assert.match(renderGrokBrokerWorkerConfig(), new RegExp(`\\nenv_key = "${GROK_BROKER_PROVIDER_CAPABILITY_ENV}"\\n`, "u")); +});