From e3400f04dff2c16ae10206b95c221252f653e706 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 13 Sep 2026 21:59:58 +0200 Subject: [PATCH 1/2] fix: preserve effective codex sandbox denials --- README.md | 16 +++ src/pi/AGENTS.md | 11 ++ src/pi/CLAUDE.md | 1 + src/pi/cliEngineSpawn.ts | 11 +- src/pi/codexFilesystemRules.test.ts | 68 ++++++++++++ src/pi/codexFilesystemRules.ts | 29 ++++++ src/runtime/codexSandbox.linux.test.ts | 116 +++++++++++++++++++++ src/runtime/codexSandboxProjection.test.ts | 40 +++++++ src/runtime/codexSandboxProjection.ts | 50 +++++++++ src/runtime/index.ts | 2 + 10 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/pi/AGENTS.md create mode 120000 src/pi/CLAUDE.md create mode 100644 src/pi/codexFilesystemRules.test.ts create mode 100644 src/pi/codexFilesystemRules.ts create mode 100644 src/runtime/codexSandbox.linux.test.ts create mode 100644 src/runtime/codexSandboxProjection.test.ts create mode 100644 src/runtime/codexSandboxProjection.ts diff --git a/README.md b/README.md index 60cf74b..ce35a4e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,22 @@ import { PiHarnessAdapter } from "@noopolis/daimon/pi"; ## Organization-runtime contract +For a strict Codex agent in an already prepared runtime, the public +`resolveOrganizationCodexSandboxProjection(config, agentId, { acceptanceStorePath })` +API returns `noopolis.daimon.codex-sandbox-projection.v1`: canonical workspace/home +paths, the resolved executable, and `sandboxArgs` containing the exact permission +profile rendered for production. Append only `--` and a mechanical command when +checking whether that policy can execute in the caller's container. + +The resolver opens and verifies caller-owned path identities and executes the +Codex version probe. It does not import/read authentication, create an agent, +accept a wake or invoke a model. The acceptance-store path must be the same one +given to the control host. This API projects command permissions, not the complete +cognition invocation: Codex's `sandbox` subcommand lacks the strict configuration +flags accepted by `exec`. A caller must therefore use a fresh empty `HOME` and +`CODEX_HOME` and a restricted environment for its mechanical probe. Do not infer +successful tool use or completed agent work from this projection alone. + `@noopolis/daimon/runtime` exports a standard JSON Schema for structural validation plus the strict, side-effect-free semantic `validateOrganizationRuntimeConfig` / `parseOrganizationRuntimeConfig` API and diff --git a/src/pi/AGENTS.md b/src/pi/AGENTS.md new file mode 100644 index 0000000..eb26811 --- /dev/null +++ b/src/pi/AGENTS.md @@ -0,0 +1,11 @@ +# Pi runtime implementation + +This directory owns the Pi harness and native CLI adaptation. Keep public +runtime contracts independent of Pi types. Files stay below 400 lines and +tests live beside their implementations. + +Codex permission rendering must preserve the effective path permissions while +removing redundant nested denies that cannot be mounted by its Linux sandbox. +An intervening readable path or workspace root makes a deeper deny necessary. +Verify changes with generated production arguments and real local sandbox +commands; a model call is neither required nor permitted for this check. diff --git a/src/pi/CLAUDE.md b/src/pi/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/pi/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/pi/cliEngineSpawn.ts b/src/pi/cliEngineSpawn.ts index 861cc58..3b4ef99 100644 --- a/src/pi/cliEngineSpawn.ts +++ b/src/pi/cliEngineSpawn.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { trackCliChild } from "./cliProcess.js"; import { cliChildEnvironment } from "./cliEnvironment.js"; +import { codexFilesystemRules } from "./codexFilesystemRules.js"; import type { CliEngineOptions, CliSessionInput } from "./cliSession.js"; export const GROK_STRICT_SANDBOX_PROFILE = "strict"; @@ -49,7 +50,7 @@ export const renderCodexArgs = ( "--strict-config", "-c", "web_search=\"disabled\"", "-c", `default_permissions=${JSON.stringify(profileName)}`, - "-c", renderCodexPermissionProfile(profileName, options.codexSandboxProtectedPaths ?? [], options.codexSandboxReadablePaths ?? []), + "-c", renderCodexPermissionProfile(profileName, options.codexSandboxProtectedPaths ?? [], options.codexSandboxReadablePaths ?? [], cwd), "-c", "approval_policy=\"never\"", "-c", `mcp_servers={daimon={url=\"${endpoint}\",enabled=true,default_tools_approval_mode=\"approve\"}}` ]; @@ -66,13 +67,13 @@ export const renderCodexArgs = ( export const renderCodexPermissionProfile = ( profileName: string, protectedPaths: readonly string[], - readablePaths: readonly string[] = [] + readablePaths: readonly string[] = [], + workspacePath?: string ): string => { const filesystem: Record> = { - ":workspace_roots": { ".": "write" } + ":workspace_roots": { ".": "write" }, + ...codexFilesystemRules(protectedPaths, readablePaths, workspacePath) }; - for (const readablePath of readablePaths) filesystem[readablePath] = "read"; - for (const protectedPath of protectedPaths) filesystem[protectedPath] = "deny"; return `permissions=${tomlInline({ [profileName]: { extends: ":workspace", filesystem, diff --git a/src/pi/codexFilesystemRules.test.ts b/src/pi/codexFilesystemRules.test.ts new file mode 100644 index 0000000..dd3dca0 --- /dev/null +++ b/src/pi/codexFilesystemRules.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderCodexArgs } from "./cliEngineSpawn.js"; +import { codexFilesystemRules } from "./codexFilesystemRules.js"; + +test("prunes redundant nested denies independently of declaration order", () => { + for (const denies of [["/run", "/run/control", "/run/control/acceptance"], ["/run/control/acceptance", "/run/control", "/run"]]) { + assert.deepEqual(codexFilesystemRules(denies, []), { "/run": "deny" }); + } + assert.deepEqual(codexFilesystemRules(["/run", "/runner", "/other/control"], []), { + "/run": "deny", "/runner": "deny", "/other/control": "deny" + }); +}); + +test("preserves a deny below an intervening readable exception", () => { + assert.deepEqual(codexFilesystemRules([ + "/vault", "/vault/shared/auth", "/vault/shared/auth/deeper", "/vault/secret" + ], ["/vault/shared"]), { + "/vault/shared": "read", "/vault": "deny", "/vault/shared/auth": "deny" + }); +}); + +test("normalizes aliases before testing ancestry, including a shared path that escapes /run", () => { + assert.deepEqual(codexFilesystemRules(["/run/", "/run//control/", "/run/../var/lib/private/control/", "/var/lib/private/control"], []), { + "/run": "deny", "/var/lib/private/control": "deny" + }); + assert.deepEqual(codexFilesystemRules(["/vault/", "/vault/open//secret/"], ["/vault/unused/../open/"]), { + "/vault/open": "read", "/vault": "deny", "/vault/open/secret": "deny" + }); + assert.deepEqual(codexFilesystemRules(["/vault", "/vault/work/secret"], [], "/vault//work/"), { + "/vault": "deny", "/vault/work/secret": "deny" + }); +}); + +test("same-path deny wins over a read and does not open a descendant", () => { + assert.deepEqual(codexFilesystemRules(["/vault", "/vault/auth"], ["/vault"]), { "/vault": "deny" }); +}); + +test("implicit workspace write is an intervening exception, including in production argv", () => { + const denies = ["/vault", "/vault/workspace/secret", "/vault/workspace/secret/deeper"]; + assert.deepEqual(codexFilesystemRules(denies, [], "/vault/workspace"), { + "/vault": "deny", "/vault/workspace/secret": "deny" + }); + const args = renderCodexArgs({ codexSandbox: { mode: "workspace-write", networkAccess: false, webSearch: "disabled" }, + codexSandboxProtectedPaths: denies }, "/vault/workspace", "http://127.0.0.1:1/mcp"); + const config = args.find(arg => arg.startsWith("permissions="))!; + assert.ok(config.includes('"/vault/workspace/secret"="deny"')); + assert.ok(!config.includes('"/vault/workspace/secret/deeper"')); +}); + +test("root deny subsumes descendants and a same-path workspace deny remains authoritative", () => { + assert.deepEqual(codexFilesystemRules(["/", "/run"], []), { "/": "deny" }); + assert.deepEqual(codexFilesystemRules(["/workspace", "/workspace/secret"], [], "/workspace"), { "/workspace": "deny" }); + assert.deepEqual(codexFilesystemRules([], [], "/workspace"), {}); +}); + +test("pruning preserves the most specific permission across alternating rules", () => { + const deny = ["/v", "/v/a", "/v/a/read/secret", "/v/a/read/secret/deeper", "/v/work/secret"]; + const read = ["/v/a/read", "/v/a/read/secret/open"]; + const before = { "/v/work": "write", ...Object.fromEntries(read.map(p => [p, "read"])), ...Object.fromEntries(deny.map(p => [p, "deny"])) }; + const after = { "/v/work": "write", ...codexFilesystemRules(deny, read, "/v/work") }; + const permission = (rules: Record, target: string): string | undefined => Object.entries(rules) + .filter(([root]) => target === root || target.startsWith(`${root}/`)).sort(([a], [b]) => b.length - a.length)[0]?.[1]; + for (const entry of [...deny, ...read, "/v/work", "/v/work/secret", "/v/ab", "/other"]) { + for (const target of [entry, `${entry}/file`]) assert.equal(permission(after, target), permission(before, target), target); + } +}); diff --git a/src/pi/codexFilesystemRules.ts b/src/pi/codexFilesystemRules.ts new file mode 100644 index 0000000..510ff78 --- /dev/null +++ b/src/pi/codexFilesystemRules.ts @@ -0,0 +1,29 @@ +import path from "node:path"; + +type Permission = "read" | "write" | "deny"; + +/** Codex mounts deny roots read-only; a second deny below one cannot create parents. */ +export function codexFilesystemRules( + protectedPaths: readonly string[], readablePaths: readonly string[], workspacePath?: string +): Record { + const explicit = new Map(); + // Agent roots are canonical, but shared acceptance paths may contain `..` + // or trailing separators. Compare the same absolute locations Codex uses. + for (const readablePath of readablePaths) explicit.set(path.resolve(readablePath), "read"); + for (const protectedPath of protectedPaths) explicit.set(path.resolve(protectedPath), "deny"); + const effective = new Map([ + ...(workspacePath === undefined ? [] : [[path.resolve(workspacePath), "write"] as const]), + ...explicit + ]); + return Object.fromEntries([...explicit].filter(([target, permission]) => { + if (permission !== "deny") return true; + let ancestor = path.dirname(target); + while (ancestor !== target) { + const inherited = effective.get(ancestor); + if (inherited !== undefined) return inherited !== "deny"; + target = ancestor; + ancestor = path.dirname(target); + } + return true; + })); +} diff --git a/src/runtime/codexSandbox.linux.test.ts b/src/runtime/codexSandbox.linux.test.ts new file mode 100644 index 0000000..e9f3afd --- /dev/null +++ b/src/runtime/codexSandbox.linux.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; + +import { renderCodexArgs } from "../pi/cliEngineSpawn.js"; +import { codexSandboxProtectedPaths, codexSandboxReadablePaths } from "./engineDispatcher.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const image = process.env.DAIMON_CODEX_SANDBOX_TEST_IMAGE; +const script = ` +const fs=require('node:fs'),path=require('node:path'),cp=require('node:child_process'),net=require('node:net'); +const input=JSON.parse(process.argv[1]); +for(const p of [input.workspace,input.home,...input.denied.filter(p=>p!=='/proc'&&p!=='/run').map(p=>p.endsWith('/auth.json')?path.dirname(p):p),...input.readable])fs.mkdirSync(p,{recursive:true,mode:448}); +const canaries=input.denied.filter(p=>p!=='/proc').map(p=>p.endsWith('/auth.json')?p:path.join(p,'canary')); +for(const p of canaries)fs.writeFileSync(p,'non-secret-denied-canary',{mode:384}); +for(const p of canaries)if(fs.readFileSync(p,'utf8')!=='non-secret-denied-canary')throw Error('outside-sandbox canary missing'); +for(const p of input.readable)fs.writeFileSync(path.join(p,'canary'),'readable-canary'); +fs.writeFileSync(path.join(input.workspace,'input'),'workspace-read'); +const server=net.createServer(s=>s.destroy()); +server.listen(0,'127.0.0.1',()=>{ +const command=input.workspaceDenied?\`const fs=require('node:fs'); +for(const p of \${JSON.stringify([input.workspace+'/input',input.workspace+'/secret/canary'])}){let denied=false;try{fs.readFileSync(p);}catch{denied=true;}if(!denied)throw Error('workspace deny lost to implicit write');} +let denied=false;try{fs.writeFileSync(\${JSON.stringify(input.workspace+'/output')},'forbidden');}catch{denied=true;}if(!denied)throw Error('denied workspace writable'); +process.stdout.write('EXACT_WORKSPACE_DENIED');\`:\`const fs=require('node:fs'),net=require('node:net'); +if(fs.readFileSync('input','utf8')!=='workspace-read')throw Error('workspace read failed'); +fs.writeFileSync('output','workspace-write'); +for(const p of \${JSON.stringify(canaries)}){let denied=false;try{fs.readFileSync(p);}catch{denied=true;}if(!denied)throw Error('canary became readable: '+p);} +for(const p of \${JSON.stringify(input.readable)})if(fs.readFileSync(p+'/canary','utf8')!=='readable-canary')throw Error('readable exception missing'); +const socket=net.connect({host:'127.0.0.1',port:\${server.address().port}});let done=false; +socket.once('connect',()=>{done=true;socket.destroy();throw Error('network reached outside sandbox');}); +socket.once('error',()=>{if(!done){done=true;fs.writeFileSync('complete','CODEX_SANDBOX_ENFORCED');}}); +socket.setTimeout(1500,()=>{socket.destroy();if(!done){done=true;throw Error('network probe timed out');}});\`; +const env={PATH:process.env.PATH,HOME:input.home,CODEX_HOME:input.home+'/.codex',TMPDIR:'/tmp',LANG:'C.UTF-8'}; +const executable=input.workspaceDenied?['/bin/sh','-c','cd /tmp && exec /usr/local/bin/node -e "$1"','probe',command]:['/usr/local/bin/node','-e',command]; +cp.execFile('codex',['sandbox','-P','daimon-strict','-C',input.workspace,'-c',input.profile,'--',...executable],{env,timeout:15000,maxBuffer:65536},(error,stdout,stderr)=>{ +server.close(); +if(error){process.stderr.write(stderr);process.exitCode=1;return;} +if(input.workspaceDenied){if(stdout!=='EXACT_WORKSPACE_DENIED'||fs.existsSync(path.join(input.workspace,'output')))throw Error('workspace deny not enforced: '+JSON.stringify({stdout,stderr,outputExists:fs.existsSync(path.join(input.workspace,'output'))}));process.stdout.write('CODEX_SANDBOX_ENFORCED');return;} +if(fs.readFileSync(path.join(input.workspace,'complete'),'utf8')!=='CODEX_SANDBOX_ENFORCED'||fs.readFileSync(path.join(input.workspace,'output'),'utf8')!=='workspace-write')throw Error('missing sandbox side effect'); +process.stdout.write('CODEX_SANDBOX_ENFORCED'); +});});`; + +async function runSandbox(input: { workspace: string; home: string; denied: readonly string[]; readable: readonly string[]; profile: string; workspaceDenied?: boolean }): Promise { + return (await promisify(execFile)("docker", [ + "run", "--rm", "--network", "none", "--read-only", "--user", "501:20", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined", + ...["/tmp", "/run", "/var/lib/daimon", "/var/lib/spawnfile"].flatMap(target => ["--tmpfs", `${target}:rw,uid=501,gid=20,mode=0700`]), + "--entrypoint", "/usr/local/bin/node", image!, "-e", script, JSON.stringify(input) + ], { timeout: 25000, maxBuffer: 131072 })).stdout; +} + +test("actual Codex sandbox executes the production policy with overlapping control denies", { skip: !image, timeout: 60000 }, async () => { + const configFile = process.env.DAIMON_CODEX_SANDBOX_TEST_CONFIG; + const agent: OrganizationRuntimeAgentConfig = configFile + ? (JSON.parse(await readFile(configFile, "utf8")) as { agents: OrganizationRuntimeAgentConfig[] }).agents[0]! + : { id: "agent:current", name: "Current", instructions: "Unused in this mechanical probe.", + workspacePath: "/var/lib/daimon/workspace", runtimeHomePath: "/var/lib/daimon/home", + engine: { kind: "codex", codexSandbox: { mode: "workspace-write", networkAccess: false, webSearch: "disabled" } } }; + const control = "/run/paideia/control"; + const denied = codexSandboxProtectedPaths(agent.id, agent, path.join(agent.runtimeHomePath, ".codex"), [agent], [control]); + const readable = codexSandboxReadablePaths(agent); + const profile = renderCodexArgs({ codexSandbox: agent.engine.codexSandbox, + codexSandboxProtectedPaths: denied, codexSandboxReadablePaths: readable }, agent.workspacePath, "http://127.0.0.1:1/mcp") + .find(arg => arg.startsWith("permissions="))!; + assert.ok(denied.includes("/run") && denied.includes(control)); + assert.ok(!profile.includes(`"${control}"="deny"`)); + const run = (permissionConfig: string): Promise => runSandbox({ workspace: agent.workspacePath, + home: agent.runtimeHomePath, denied, readable, profile: permissionConfig }); + assert.equal(await run(profile), "CODEX_SANDBOX_ENFORCED"); + // Restore the exact redundant deny emitted before the fix. The real sandbox + // must reproduce the setup failure instead of merely executing an empty shell. + await assert.rejects(run(profile.replace('"/run"="deny"', `"/run"="deny","${control}"="deny"`)), /Can't mkdir parents.*Read-only file system/u); +}); + +test("a denied workspace fails readiness when Codex exits zero without executing the command", { skip: !image, timeout: 30000 }, async () => { + const workspace = "/var/lib/daimon/workspace", home = "/var/lib/daimon/home"; + const denied = [workspace, `${workspace}/secret`, "/run", "/proc", `${home}/.codex/auth.json`]; + const profile = renderCodexArgs({ codexSandbox: { mode: "workspace-write", networkAccess: false, webSearch: "disabled" }, + codexSandboxProtectedPaths: denied }, workspace, "http://127.0.0.1:1/mcp").find(arg => arg.startsWith("permissions="))!; + assert.ok(!profile.includes(`"${workspace}/secret"="deny"`)); + // Codex 0.142.3 executes neither Node nor the shell wrapper in this geometry. + // Exit zero is not readiness: the missing sentinel must reject it. + await assert.rejects(runSandbox({ workspace, home, denied, readable: [], profile, workspaceDenied: true }), + /workspace deny not enforced: \{"stdout":"","stderr":"","outputExists":false\}/u); + await assert.rejects(runSandbox({ workspace, home, denied, readable: [], workspaceDenied: true, + profile: profile.replace(`,"${workspace}"="deny"`, "") }), /workspace deny lost to implicit write/u); +}); + +test("a shared protected path containing .. cannot inherit the wrong ancestor deny", { skip: !image, timeout: 30000 }, async () => { + const workspace = "/var/lib/daimon/workspace", home = "/var/lib/daimon/home"; + const denied = ["/run", "/run/../var/lib/daimon/control/", "/proc", `${home}/.codex/auth.json`]; + const profile = renderCodexArgs({ codexSandbox: { mode: "workspace-write", networkAccess: false, webSearch: "disabled" }, + codexSandboxProtectedPaths: denied }, workspace, "http://127.0.0.1:1/mcp").find(arg => arg.startsWith("permissions="))!; + assert.ok(profile.includes('"/var/lib/daimon/control"="deny"')); + assert.equal(await runSandbox({ workspace, home, denied, readable: [], profile }), "CODEX_SANDBOX_ENFORCED"); + await assert.rejects(runSandbox({ workspace, home, denied, readable: [], + profile: profile.replace(',"/var/lib/daimon/control"="deny"', "") }), /canary became readable/u); +}); + +test("unsupported nested exception geometry fails closed instead of dropping its protected descendant", { skip: !image, timeout: 30000 }, async () => { + const workspace = "/var/lib/daimon/workspace", home = "/var/lib/daimon/home", vault = "/var/lib/daimon/vault"; + const denied = ["/run", "/proc", `${home}/.codex/auth.json`, `${home}/.daimon-inbound`, vault, `${vault}/open/secret`]; + const readable = [`${vault}/open`]; + const profile = renderCodexArgs({ codexSandbox: { mode: "workspace-write", networkAccess: false, webSearch: "disabled" }, + codexSandboxProtectedPaths: denied, codexSandboxReadablePaths: readable }, workspace, "http://127.0.0.1:1/mcp") + .find(arg => arg.startsWith("permissions="))!; + assert.ok(profile.includes(`"${vault}/open/secret"="deny"`)); + // Codex 0.142.3 cannot mount this nonredundant geometry. Keep the deny and + // require the actual-profile readiness probe to refuse it before cognition. + await assert.rejects(runSandbox({ workspace, home, denied, readable, profile }), /Can't mkdir parents.*Read-only file system/u); + await assert.rejects(runSandbox({ workspace, home, denied, readable, + profile: profile.replace(`,"${vault}/open/secret"="deny"`, "") }), /canary became readable/u); +}); diff --git a/src/runtime/codexSandboxProjection.test.ts b/src/runtime/codexSandboxProjection.test.ts new file mode 100644 index 0000000..b3ba256 --- /dev/null +++ b/src/runtime/codexSandboxProjection.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { resolveOrganizationCodexSandboxProjection } from "./codexSandboxProjection.js"; +import { renderCodexArgs } from "../pi/cliEngineSpawn.js"; +import { codexSandboxProtectedPaths, codexSandboxReadablePaths } from "./engineDispatcher.js"; + +test("public projection uses actual canonical policy and never reads auth or runs cognition", async () => { + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "daimon-projection-"))); + const priorPath = process.env.PATH; + const agent = { id: "agent:writer", name: "Writer", instructions: "Unused", workspacePath: path.join(root, "workspace"), + runtimeHomePath: path.join(root, "home"), schedule: { kind: "disabled" }, engine: { kind: "codex", codexSandbox: { mode: "workspace-write", networkAccess: false, webSearch: "disabled" } } } as const; + const config = { version: "noopolis.daimon.organization-runtime.v2", host: { bindHost: "127.0.0.1", port: 19700, controlTokenEnv: "UNIT_CONTROL_TOKEN" }, agents: [agent] }; + try { + for (const directory of [agent.workspacePath, agent.runtimeHomePath]) await mkdir(directory, { mode: 0o700 }); + const executable = path.join(root, "codex"), log = path.join(root, "calls.jsonl"); + await writeFile(executable, `#!${process.execPath}\nrequire('node:fs').appendFileSync(${JSON.stringify(log)},JSON.stringify(process.argv.slice(2))+'\\n');if(process.argv[2]!=='--version')process.exit(9);process.stdout.write('unit-codex-version');`, { mode: 0o700 }); + process.env.PATH = `${root}${path.delimiter}${priorPath ?? ""}`; + const projection = await resolveOrganizationCodexSandboxProjection(config, agent.id, { acceptanceStorePath: "/run/paideia/control" }); + const expected = renderCodexArgs({ codexSandbox: agent.engine.codexSandbox, + codexSandboxProtectedPaths: codexSandboxProtectedPaths(agent.id, agent, path.join(agent.runtimeHomePath, ".codex"), [agent], ["/run/paideia/control"]), + codexSandboxReadablePaths: codexSandboxReadablePaths(agent) }, agent.workspacePath, undefined).find(arg => arg.startsWith("permissions=")); + assert.equal(projection.permissionConfig, expected); + assert.equal(projection.executablePath, executable); + assert.equal(projection.engineHomePath, path.join(agent.runtimeHomePath, ".codex")); + assert.deepEqual(projection.sandboxArgs, ["sandbox", "-P", "daimon-strict", "-C", agent.workspacePath, "-c", expected]); + assert.ok(!projection.permissionConfig.includes('"/run/paideia/control"="deny"')); + assert.deepEqual((await readFile(log, "utf8")).trim().split("\n").map(line => JSON.parse(line)), [["--version"], ["--version"]]); + await assert.rejects(readFile(path.join(agent.runtimeHomePath, ".codex", "auth.json")), /ENOENT/); + await assert.rejects(resolveOrganizationCodexSandboxProjection(config, "missing", { acceptanceStorePath: "/run/control" }), /known strict/); + await assert.rejects(resolveOrganizationCodexSandboxProjection({ ...config, agents: [{ ...agent, engine: { kind: "grok" } }] }, agent.id, { acceptanceStorePath: "/run/control" }), /known strict/); + await assert.rejects(resolveOrganizationCodexSandboxProjection({ ...config, agents: [{ ...agent, engine: { kind: "codex" } }] }, agent.id, { acceptanceStorePath: "/run/control" }), /known strict/); + await assert.rejects(resolveOrganizationCodexSandboxProjection(config, agent.id, { acceptanceStorePath: "relative" }), /absolute/); + await chmod(executable, 0o600); + process.env.PATH = root; + await assert.rejects(resolveOrganizationCodexSandboxProjection(config, agent.id, { acceptanceStorePath: "/run/control" }), /unavailable/); + } finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/codexSandboxProjection.ts b/src/runtime/codexSandboxProjection.ts new file mode 100644 index 0000000..b8d62b8 --- /dev/null +++ b/src/runtime/codexSandboxProjection.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import { renderCodexArgs } from "../pi/cliEngineSpawn.js"; +import { codexSandboxProtectedPaths, codexSandboxReadablePaths } from "./engineDispatcher.js"; +import { engineHomeName, prepareEngineExecutable } from "./engineReadiness.js"; +import { parseOrganizationRuntimeConfig } from "./organizationRuntime.js"; +import { prepareOrganizationRuntimePaths } from "./physicalReadiness.js"; + +export const CODEX_SANDBOX_PROJECTION_VERSION = "noopolis.daimon.codex-sandbox-projection.v1"; +export type OrganizationCodexSandboxProjection = Readonly<{ + version: typeof CODEX_SANDBOX_PROJECTION_VERSION; + agentId: string; + workspacePath: string; + runtimeHomePath: string; + engineHomePath: string; + executablePath: string; + profileName: string; + permissionConfig: string; + sandboxArgs: readonly string[]; +}>; + +/** Resolves the production command policy without importing auth or accepting a wake. */ +export async function resolveOrganizationCodexSandboxProjection(config: unknown, agentId: string, + options: Readonly<{ acceptanceStorePath: string }>): Promise { + const parsed = parseOrganizationRuntimeConfig(config); + const selected = parsed.agents.find(agent => agent.id === agentId); + if (!selected || selected.engine.kind !== "codex" || selected.engine.codexSandbox === undefined) { + throw new Error("Sandbox projection requires a known strict Codex agent"); + } + if (!path.isAbsolute(options.acceptanceStorePath)) throw new Error("Sandbox projection requires an absolute acceptance store path"); + const authority = await prepareOrganizationRuntimePaths(parsed.agents); + try { + const paths = authority.forAgent(selected); + await paths.verify(); + const agent = { ...selected, workspacePath: paths.workspacePath, runtimeHomePath: paths.runtimeHomePath }; + const engineHomePath = path.join(agent.runtimeHomePath, engineHomeName("codex")); + const executable = await prepareEngineExecutable(agent.id, "codex"); + // These are the same collectors, canonical roots and renderer used by + // startOrganizationRuntimeEngine and its strict production invocation. + const args = renderCodexArgs({ codexSandbox: agent.engine.codexSandbox, + codexSandboxProtectedPaths: codexSandboxProtectedPaths(agent.id, agent, engineHomePath, parsed.agents, [options.acceptanceStorePath]), + codexSandboxReadablePaths: codexSandboxReadablePaths(agent) }, agent.workspacePath, undefined); + const permissionConfig = args.find(arg => arg.startsWith("permissions="))!; + const profileName = JSON.parse(args.find(arg => arg.startsWith("default_permissions="))!.slice("default_permissions=".length)) as string; + await paths.verify(); + await executable.verify(); + return { version: CODEX_SANDBOX_PROJECTION_VERSION, agentId, workspacePath: agent.workspacePath, + runtimeHomePath: agent.runtimeHomePath, engineHomePath, executablePath: executable.executablePath, profileName, permissionConfig, + sandboxArgs: ["sandbox", "-P", profileName, "-C", agent.workspacePath, "-c", permissionConfig] }; + } finally { await authority.close(); } +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 3111163..b72ee90 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -3,6 +3,8 @@ export * from "./contractManifest.js"; export * from "./agySubscriptionRealm.js"; export { createOrganizationRuntimeHost } from "./organizationRuntimeHost.js"; export { createOrganizationRuntimeControlHost } from "./organizationRuntimeControl.js"; +export { CODEX_SANDBOX_PROJECTION_VERSION, resolveOrganizationCodexSandboxProjection, + type OrganizationCodexSandboxProjection } from "./codexSandboxProjection.js"; export { WakeTransitionLockBlockedError } from "./wakeAcceptanceStore.js"; export { OFFLINE_RECONCILIATION_BLOCKED_CODE, From 43fe48cbf2cc3865c9499e0e9562727aebf54587 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Sun, 13 Sep 2026 22:40:27 +0200 Subject: [PATCH 2/2] fix: validate and bound credential reads from file descriptors --- .../portableCredentialBoundary.test.ts | 51 +++++ .../portableCredentialFailures.test.ts | 58 ++++++ .../portableCredentialMaterial.linux.test.ts | 55 ++++++ src/runtime/portableCredentialMaterial.ts | 39 +++- src/runtime/portableCredentialRead.test.ts | 181 ++++++++++++++++++ 5 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 src/runtime/portableCredentialBoundary.test.ts create mode 100644 src/runtime/portableCredentialFailures.test.ts create mode 100644 src/runtime/portableCredentialMaterial.linux.test.ts create mode 100644 src/runtime/portableCredentialRead.test.ts diff --git a/src/runtime/portableCredentialBoundary.test.ts b/src/runtime/portableCredentialBoundary.test.ts new file mode 100644 index 0000000..8d00742 --- /dev/null +++ b/src/runtime/portableCredentialBoundary.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { chmod, link, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { materializePortableCredential } from "./portableCredentialMaterial.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const execute = promisify(execFile); +const reader = fileURLToPath(new URL("./portableCredentialMaterial.ts", import.meta.url)); +const child = `const {materializePortableCredential}=await import(process.argv[1]);const home=process.argv[2];try{await materializePortableCredential({id:'agent:codex',name:'Codex',instructions:'Unused',workspacePath:home+'/workspace',runtimeHomePath:home,engine:{kind:'codex'}},home);process.stdout.write('UNEXPECTED_IMPORT');}catch{process.stdout.write('MATERIALIZATION_REFUSED');}`; + +for (const kind of ["mode", "hardlink", "directory", "empty", "oversize", "symlink"] as const) { + test(`real filesystem rejects unsafe credential ${kind}`, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-credential-boundary-")); + const source = `${root}/.daimon-inbound/codex-auth`; + const agent: OrganizationRuntimeAgentConfig = { id: "agent:codex", name: "Codex", instructions: "Unused", workspacePath: `${root}/workspace`, runtimeHomePath: root, engine: { kind: "codex" } }; + try { + await mkdir(path.dirname(source), { mode: 0o700 }); + if (kind === "directory") await mkdir(source, { mode: 0o600 }); + else if (kind === "symlink") { await writeFile(`${root}/target`, "dummy", { mode: 0o600 }); await symlink(`${root}/target`, source); } + else { + await writeFile(source, kind === "empty" ? "" : kind === "oversize" ? "x".repeat(65537) : "dummy", { mode: 0o600 }); + if (kind === "mode") await chmod(source, 0o644); + if (kind === "hardlink") await link(source, `${root}/second-link`); + } + await assert.rejects(materializePortableCredential(agent, root), /credential materialization failed/u); + await assert.rejects(readFile(`${root}/.codex/auth.json`), { code: "ENOENT" }); + } finally { await rm(root, { recursive: true, force: true }); } + }); +} + +test("a real FIFO is rejected promptly; deleting nonblocking open makes the subprocess hang", { skip: process.platform === "win32", timeout: 15000 }, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "daimon-credential-fifo-")); + try { + await mkdir(`${root}/.daimon-inbound`, { mode: 0o700 }); + await execute("mkfifo", ["-m", "600", `${root}/.daimon-inbound/codex-auth`]); + const run = (modulePath: string) => execute(process.execPath, ["--import", "tsx", "--input-type=module", "-e", child, modulePath, root], { timeout: 3000, killSignal: "SIGKILL" }); + assert.equal((await run(reader)).stdout, "MATERIALIZATION_REFUSED"); + const original = await readFile(reader, "utf8"); + const mutated = original.replace(" | constants.O_NONBLOCK", "").replace('"./contractManifest.js"', JSON.stringify(fileURLToPath(new URL("./contractManifest.ts", import.meta.url)))); + assert.notEqual(mutated, original); + await writeFile(`${root}/package.json`, '{"type":"module"}'); + const mutant = `${root}/mutant.ts`; await writeFile(mutant, mutated); + await assert.rejects(run(mutant), (error: NodeJS.ErrnoException & { killed?: boolean; signal?: string }) => error.killed === true && error.signal === "SIGKILL"); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/portableCredentialFailures.test.ts b/src/runtime/portableCredentialFailures.test.ts new file mode 100644 index 0000000..475e801 --- /dev/null +++ b/src/runtime/portableCredentialFailures.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import test, { mock } from "node:test"; + +import { materializePortableCredential } from "./portableCredentialMaterial.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +test("keeps directory and existing-credential protections and refuses undeclared engines", async () => { + for (const mode of ["engine", "directory-mode", "directory-link", "destination-mode", "destination-directory", "repair-destination"] as const) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "daimon-credential-guards-")); + const config: OrganizationRuntimeAgentConfig = { id: "agent:codex", name: "Codex", instructions: "Unused", workspacePath: `${root}/workspace`, runtimeHomePath: root, engine: { kind: mode === "engine" ? "grok" : "codex" } }; + try { + await fs.mkdir(`${root}/.daimon-inbound`, { mode: 0o700 }); + await fs.writeFile(`${root}/.daimon-inbound/codex-auth`, "dummy", { mode: 0o600 }); + if (mode === "directory-mode") await fs.chmod(`${root}/.daimon-inbound`, 0o755); + if (mode === "directory-link") { await fs.rename(`${root}/.daimon-inbound`, `${root}/source`); await fs.symlink(`${root}/source`, `${root}/.daimon-inbound`); } + if (mode.startsWith("destination") || mode === "repair-destination") await fs.mkdir(`${root}/.codex`, { mode: mode === "repair-destination" ? 0o755 : 0o700 }); + if (mode === "destination-mode") await fs.writeFile(`${root}/.codex/auth.json`, "existing", { mode: 0o644 }); + if (mode === "destination-directory") await fs.mkdir(`${root}/.codex/auth.json`, { mode: 0o600 }); + if (mode === "repair-destination") { + assert.equal(await materializePortableCredential(config, root), "created"); + assert.equal((await fs.stat(`${root}/.codex`)).mode & 0o777, 0o700); + } else await assert.rejects(materializePortableCredential(config, root), /credential material/u); + } finally { await fs.rm(root, { recursive: true, force: true }); } + } +}); + +for (const failure of ["mkdir", "rename", "source-lstat", "existing-lstat", "temporary-close"] as const) test(`does not install credentials after ${failure} failure`, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "daimon-credential-failure-")); + const config: OrganizationRuntimeAgentConfig = { id: "agent:codex", name: "Codex", instructions: "Unused", workspacePath: `${root}/workspace`, runtimeHomePath: root, engine: { kind: "codex" } }; + const source = `${root}/.daimon-inbound/codex-auth`, destination = `${root}/.codex/auth.json`; + const fail = (): never => { throw Object.assign(new Error("private-path-and-contents-must-not-leak"), { code: "EIO" }); }; + try { + await fs.mkdir(path.dirname(source), { mode: 0o700 }); await fs.writeFile(source, "dummy", { mode: 0o600 }); + if (failure === "mkdir") mock.method(fs, "mkdir", fail); + if (failure === "rename") mock.method(fs, "rename", fail); + if (failure.endsWith("lstat")) { + const original = fs.lstat; + mock.method(fs, "lstat", (...args: Parameters) => args[0] === (failure === "source-lstat" ? source : destination) ? fail() : original(...args)); + } + if (failure === "temporary-close") { + const original = fs.open; + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await original(...args); + if (String(args[0]).endsWith(".tmp")) { const close = handle.close.bind(handle); mock.method(handle, "close", async () => { await close(); fail(); }); } + return handle; + }); + } + syncBuiltinESMExports(); + await assert.rejects(materializePortableCredential(config, root), (error: Error) => error.message === "agent agent:codex codex credential materialization failed"); + await assert.rejects(fs.readFile(destination), { code: "ENOENT" }); + const files = await fs.readdir(`${root}/.codex`).catch(() => []); + assert.equal(files.some(file => file.endsWith(".tmp")), false); + } finally { mock.restoreAll(); syncBuiltinESMExports(); await fs.rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/portableCredentialMaterial.linux.test.ts b/src/runtime/portableCredentialMaterial.linux.test.ts new file mode 100644 index 0000000..70d1ed3 --- /dev/null +++ b/src/runtime/portableCredentialMaterial.linux.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const image = process.env.DAIMON_CREDENTIAL_BIND_TEST_IMAGE; +const script = `import fs from 'node:fs/promises'; +const home='/native-home',source=home+'/.daimon-inbound/codex-auth'; +const before=await fs.lstat(source); +const {materializePortableCredential}=await import('/opt/paideia/credential-test/reader.ts'); +const result=await materializePortableCredential({id:'agent:codex',name:'Codex',instructions:'Unused',workspacePath:'/tmp/workspace',runtimeHomePath:home,engine:{kind:'codex'}},home); +if(result!=='created'||await fs.readFile(home+'/.codex/auth.json','utf8')!=='non-secret-dummy-credential')throw Error('dummy credential was not materialized'); +const after=await fs.lstat(source);process.stdout.write(JSON.stringify({result,uid:process.getuid(),beforeUid:before.uid,afterUid:after.uid,descriptorRefreshObserved:before.uid!==after.uid,verified:true}));`; + +test("actual read-only Docker bind materializes a fresh dummy credential without metadata prewarming", { skip: !image, timeout: 30000 }, async t => { + const parent = fileURLToPath(new URL("../../.runtime/", import.meta.url)); + await mkdir(parent, { recursive: true, mode: 0o700 }); + const root = await mkdtemp(path.join(parent, "daimon-dummy-credential-bind-")); + try { + const home = `${root}/home`, code = `${root}/code`, source = `${root}/dummy-credential`; + await mkdir(`${home}/.daimon-inbound`, { recursive: true, mode: 0o700 }); await mkdir(code); + await writeFile(source, "non-secret-dummy-credential", { mode: 0o600 }); + // Execute the edited reader without building dist; its unchanged contract + // constant comes from the image's public runtime export, never private code. + const bytes = (await readFile(fileURLToPath(new URL("./portableCredentialMaterial.ts", import.meta.url)), "utf8")) + .replace('"./contractManifest.js"', '"@noopolis/daimon/runtime"'); + await writeFile(`${code}/reader.ts`, bytes); + const result = await promisify(execFile)("docker", ["run", "--rm", "--network", "none", "--read-only", + "--user", `${process.getuid?.() ?? 501}:${process.getgid?.() ?? 20}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--tmpfs", "/tmp:rw,nosuid,nodev", "--mount", `type=bind,source=${home},target=/native-home`, + "--mount", `type=bind,source=${source},target=/native-home/.daimon-inbound/codex-auth,readonly`, + "--mount", `type=bind,source=${code},target=/opt/paideia/credential-test,readonly`, + "--entrypoint", "/usr/local/bin/node", image!, "--experimental-strip-types", "--input-type=module", "-e", script + ], { timeout: 20000, maxBuffer: 16384 }); + const proof = JSON.parse(result.stdout) as { result: string; uid: number; beforeUid: number; afterUid: number; verified: boolean; descriptorRefreshObserved: boolean }; + assert.equal(proof.result, "created"); assert.equal(proof.verified, true); assert.equal(proof.afterUid, proof.uid); + t.diagnostic(JSON.stringify(proof)); + // Root can read the dummy file via DAC_OVERRIDE; the reader must still + // reject its genuinely different owner rather than relying on open EACCES. + const unsafeOwner = `import fs from 'node:fs/promises'; +const home='/native-home',source=home+'/.daimon-inbound/codex-auth';await fs.mkdir(home+'/.daimon-inbound',{mode:448});await fs.writeFile(source,'dummy',{mode:384});await fs.chown(source,501,20); +const opened=await fs.open(source,'r');if((await opened.stat()).uid===process.getuid())throw Error('owner fixture invalid');await opened.close(); +const {materializePortableCredential}=await import('/opt/paideia/credential-test/reader.ts');let refused=false;try{await materializePortableCredential({id:'agent:codex',name:'Codex',instructions:'Unused',workspacePath:'/tmp/workspace',runtimeHomePath:home,engine:{kind:'codex'}},home);}catch{refused=true;} +if(!refused)throw Error('unsafe owner imported');try{await fs.access(home+'/.codex/auth.json');throw Error('destination exists');}catch(e){if(e.code!=='ENOENT')throw e;}process.stdout.write('UNSAFE_OWNER_REFUSED');`; + const negative = await promisify(execFile)("docker", ["run", "--rm", "--network", "none", "--read-only", "--user", "0:0", + "--cap-drop", "ALL", "--cap-add", "CHOWN", "--cap-add", "DAC_OVERRIDE", "--security-opt", "no-new-privileges", + "--tmpfs", "/tmp:rw,nosuid,nodev", "--tmpfs", "/native-home:rw,mode=0700", + "--mount", `type=bind,source=${code},target=/opt/paideia/credential-test,readonly`, "--entrypoint", "/usr/local/bin/node", + image!, "--experimental-strip-types", "--input-type=module", "-e", unsafeOwner], { timeout: 10000, maxBuffer: 16384 }); + assert.equal(negative.stdout, "UNSAFE_OWNER_REFUSED"); + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/src/runtime/portableCredentialMaterial.ts b/src/runtime/portableCredentialMaterial.ts index 0dc0a71..3d729fd 100644 --- a/src/runtime/portableCredentialMaterial.ts +++ b/src/runtime/portableCredentialMaterial.ts @@ -1,4 +1,4 @@ -import { constants } from "node:fs"; +import { constants, type Stats } from "node:fs"; import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import path from "node:path"; @@ -15,6 +15,7 @@ type FileIdentity = Readonly<{ mtimeMs: number; size: number; uid: number; + nlink: number; }>; /** @@ -72,13 +73,25 @@ async function readCredential( ): Promise<{ bytes: Buffer; identity: FileIdentity }> { let handle: Awaited> | undefined; try { - const before = await assertCredential(filePath, mode, agent); - handle = await open(filePath, constants.O_RDONLY | noFollow()); - const opened = identity(await handle.stat()); - if (!sameIdentity(before, opened)) throw new Error("credential changed during import"); - const bytes = await handle.readFile(); - const after = identity(await handle.stat()); - if (!sameIdentity(before, after) || bytes.length !== before.size) { + // Opening a read-only bind can refresh its metadata. Validate the actual + // descriptor before consulting the refreshed path or reading any bytes. + // Nonblocking open lets us reject a FIFO by type instead of hanging here. + handle = await open(filePath, constants.O_RDONLY | noFollow() | constants.O_NONBLOCK); + const before = assertCredentialEntry(await handle.stat(), mode, agent); + if (!sameIdentity(before, await assertCredential(filePath, mode, agent))) throw new Error("credential changed during import"); + // One extra byte detects growth without allowing a changing file to drive + // an unbounded read/allocation. Partial reads advance within this buffer. + const buffer = Buffer.alloc(before.size + 1); + let length = 0; + while (length < buffer.length) { + const result = await handle.read(buffer, length, buffer.length - length, length); + if (result.bytesRead === 0) break; + length += result.bytesRead; + } + const bytes = buffer.subarray(0, length); + const after = assertCredentialEntry(await handle.stat(), mode, agent); + const afterPath = await assertCredential(filePath, mode, agent); + if (!sameIdentity(before, after) || !sameIdentity(before, afterPath) || bytes.length !== before.size) { throw new Error("credential changed during import"); } return { bytes, identity: before }; @@ -147,6 +160,10 @@ async function assertCredential( if ((error as NodeJS.ErrnoException).code === "ENOENT") throw error; throw unavailable(agent, error); } + return assertCredentialEntry(entry, mode, agent); +} + +function assertCredentialEntry(entry: Stats, mode: number, agent: PortableAgent): FileIdentity { if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || entry.nlink !== 1 || (entry.mode & 0o777) !== mode || entry.size === 0 || entry.size > MAX_CREDENTIAL_BYTES) { @@ -172,6 +189,7 @@ function identity(value: Awaited>): FileIdentity { mtimeMs: number; size: number; uid: number; + nlink: number; }; return { dev: numeric.dev, @@ -179,12 +197,13 @@ function identity(value: Awaited>): FileIdentity { mode: numeric.mode & 0o7777, mtimeMs: numeric.mtimeMs, size: numeric.size, - uid: numeric.uid + uid: numeric.uid, + nlink: numeric.nlink }; } function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode - && left.mtimeMs === right.mtimeMs && left.size === right.size && left.uid === right.uid; + && left.mtimeMs === right.mtimeMs && left.size === right.size && left.uid === right.uid && left.nlink === right.nlink; } function noFollow(): number { return (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0; diff --git a/src/runtime/portableCredentialRead.test.ts b/src/runtime/portableCredentialRead.test.ts new file mode 100644 index 0000000..9245de0 --- /dev/null +++ b/src/runtime/portableCredentialRead.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import { constants, type Stats } from "node:fs"; +import fs from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import test, { mock } from "node:test"; + +import { materializePortableCredential } from "./portableCredentialMaterial.js"; +import type { OrganizationRuntimeAgentConfig } from "./organizationRuntime.js"; + +const altered = (entry: Stats, patch: Partial): Stats => Object.assign(Object.create(Object.getPrototypeOf(entry)), entry, patch); +async function fixture(run: (agent: OrganizationRuntimeAgentConfig, source: string, destination: string) => Promise): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "daimon-credential-descriptor-")); + const agent: OrganizationRuntimeAgentConfig = { id: "agent:codex", name: "Codex", instructions: "Unused.", workspacePath: `${root}/workspace`, runtimeHomePath: `${root}/home`, engine: { kind: "codex" } }; + const source = `${agent.runtimeHomePath}/.daimon-inbound/codex-auth`, destination = `${agent.runtimeHomePath}/.codex/auth.json`; + await fs.mkdir(path.dirname(source), { recursive: true, mode: 0o700 }); + await fs.writeFile(source, "dummy-credential", { mode: 0o600 }); + try { await run(agent, source, destination); } + finally { mock.restoreAll(); syncBuiltinESMExports(); await fs.rm(root, { recursive: true, force: true }); } +} + +test("opens before validation when a bind's pathname metadata refreshes on open", async () => { + await fixture(async (agent, source, destination) => { + const originalOpen = fs.open, originalLstat = fs.lstat; + const order: string[] = []; + let opened = false; + mock.method(fs, "lstat", async (...args: Parameters) => { + const entry = await originalLstat(...args); + if (args[0] !== source) return entry; + order.push("path"); + return opened ? entry : altered(entry as Stats, { uid: (process.getuid?.() ?? 0) + 1 }); + }); + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === source) { + order.push("open"); opened = true; + assert.ok((Number(args[1]) & constants.O_NOFOLLOW) !== 0); + assert.ok((Number(args[1]) & constants.O_NONBLOCK) !== 0); + const stat = handle.stat.bind(handle), read = handle.read.bind(handle); + mock.method(handle, "stat", async () => { order.push("descriptor"); return stat(); }); + mock.method(handle, "read", (buffer: Buffer, offset: number, length: number, position: number) => { order.push("read"); return read(buffer, offset, length, position); }); + } + return handle; + }); + syncBuiltinESMExports(); + assert.equal(await materializePortableCredential(agent, agent.runtimeHomePath), "created"); + assert.deepEqual(order, ["open", "descriptor", "path", "read", "read", "descriptor", "path"]); + assert.equal(await fs.readFile(destination, "utf8"), "dummy-credential"); + }); +}); + +for (const [name, patch] of [ + ["owner", { uid: (process.getuid?.() ?? 0) + 1 }], ["mode", { mode: 0o100644 }], + ["link count", { nlink: 2 }], ["empty", { size: 0 }], ["oversize", { size: 65537 }], + ["directory", { mode: 0o040600 }] +] as const) test(`rejects unsafe descriptor ${name} before reading`, async () => { + await fixture(async (agent, source, destination) => { + const originalOpen = fs.open; + let reads = 0; + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === source) { + const entry = await handle.stat(), read = handle.read.bind(handle); + mock.method(handle, "stat", async () => altered(entry, patch)); + mock.method(handle, "read", (buffer: Buffer, offset: number, length: number, position: number) => { reads++; return read(buffer, offset, length, position); }); + } + return handle; + }); + syncBuiltinESMExports(); + await assert.rejects(materializePortableCredential(agent, agent.runtimeHomePath), /credential materialization failed/u); + assert.equal(reads, 0); + await assert.rejects(fs.stat(destination), { code: "ENOENT" }); + }); +}); + +for (const phase of ["before", "after"] as const) test(`rejects a pathname replaced ${phase} the read`, async () => { + await fixture(async (agent, source, destination) => { + const originalLstat = fs.lstat; + let paths = 0; + mock.method(fs, "lstat", async (...args: Parameters) => { + const entry = await originalLstat(...args); + if (args[0] !== source) return entry; + paths++; + return paths >= (phase === "before" ? 1 : 2) ? altered(entry as Stats, { ino: Number(entry.ino) + 1 }) : entry; + }); + syncBuiltinESMExports(); + await assert.rejects(materializePortableCredential(agent, agent.runtimeHomePath), /credential materialization failed/u); + await assert.rejects(fs.stat(destination), { code: "ENOENT" }); + }); +}); + +test("rejects descriptor metadata and byte-length changes during the read", async () => { + for (const changed of ["mtime", "bytes"] as const) await fixture(async (agent, source, destination) => { + const originalOpen = fs.open; + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === source) { + const entry = await handle.stat(); let stats = 0; + mock.method(handle, "stat", async () => ++stats === 1 || changed === "bytes" ? entry : altered(entry, { mtimeMs: entry.mtimeMs + 1 })); + if (changed === "bytes") mock.method(handle, "read", async (buffer: Buffer) => ({ bytesRead: 0, buffer })); + } + return handle; + }); + syncBuiltinESMExports(); + await assert.rejects(materializePortableCredential(agent, agent.runtimeHomePath), /credential materialization failed/u); + await assert.rejects(fs.stat(destination), { code: "ENOENT" }); + }); +}); + +test("assembles partial descriptor reads within one expected-size-plus-one buffer", async () => { + await fixture(async (agent, source, destination) => { + const originalOpen = fs.open, expected = await fs.readFile(source); + const buffers = new Set(); let calls = 0; + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === source) { + const read = handle.read.bind(handle); + mock.method(handle, "readFile", () => { throw Error("unbounded read forbidden"); }); + mock.method(handle, "read", async (buffer: Buffer, offset: number, length: number, position: number) => { + calls++; buffers.add(buffer); + assert.equal(buffer.length, expected.length + 1); + assert.equal(position, offset); assert.ok(offset + length <= buffer.length); + return read(buffer, offset, Math.min(length, 3), position); + }); + } + return handle; + }); + syncBuiltinESMExports(); + assert.equal(await materializePortableCredential(agent, agent.runtimeHomePath), "created"); + assert.deepEqual(await fs.readFile(destination), expected); + assert.equal(buffers.size, 1); assert.equal(calls, Math.ceil(expected.length / 3) + 1); + }); +}); + +for (const partial of [false, true]) test(`growth after safe metadata remains byte-bounded (${partial ? "partial" : "full"} reads)`, async () => { + await fixture(async (agent, source, destination) => { + const originalOpen = fs.open, expectedSize = (await fs.stat(source)).size; + const buffers = new Set(); let calls = 0, bytes = 0; + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === source) { + const read = handle.read.bind(handle); + mock.method(handle, "readFile", () => { throw Error("unbounded read forbidden"); }); + mock.method(handle, "read", async (buffer: Buffer, offset: number, length: number, position: number) => { + if (calls++ === 0) await fs.truncate(source, 16 * 1024 * 1024); + buffers.add(buffer); assert.equal(buffer.length, expectedSize + 1); + assert.ok(length > 0 && offset + length <= buffer.length); + const result = await read(buffer, offset, partial ? 1 : length, position); bytes += result.bytesRead; return result; + }); + } + return handle; + }); + syncBuiltinESMExports(); + await assert.rejects(materializePortableCredential(agent, agent.runtimeHomePath), /credential materialization failed/u); + assert.equal(bytes, expectedSize + 1); assert.equal(buffers.size, 1); + assert.equal(calls, partial ? expectedSize + 1 : 1); + await assert.rejects(fs.stat(destination), { code: "ENOENT" }); + }); +}); + +for (const size of [0, 5]) test(`rejects truncation to ${size} bytes after safe metadata`, async () => { + await fixture(async (agent, source, destination) => { + const originalOpen = fs.open; let calls = 0; + mock.method(fs, "open", async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (args[0] === source) { + const read = handle.read.bind(handle); + mock.method(handle, "read", async (buffer: Buffer, offset: number, length: number, position: number) => { + if (calls++ === 0) await fs.truncate(source, size); + return read(buffer, offset, length, position); + }); + } + return handle; + }); + syncBuiltinESMExports(); + await assert.rejects(materializePortableCredential(agent, agent.runtimeHomePath), /credential materialization failed/u); + assert.equal(calls, size === 0 ? 1 : 2); + await assert.rejects(fs.stat(destination), { code: "ENOENT" }); + }); +});