From 23b80c24a6d9f742a728ce5963430434d16c211b Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 19 Oct 2026 08:55:26 -0700 Subject: [PATCH] fix(environment): write exported capture copies readable by their owner only The bundle's manifest.json and evidence.json are copies of captured content, which can include values an application returned such as session tokens. They are now written with mode 0600 regardless of the caller's umask. The Dockerfile already copies them with --chown for the runtime user, so the served environment can still read them. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 3 ++- .../environment-export/spec.md | 3 ++- .../environment/environmentExport.test.ts | 21 ++++++++++++++++- .../environment/environmentExport.ts | 23 ++++++++++++++++--- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index a15ebdff59..e5d46a0173 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -377,7 +377,8 @@ rea environment-export manifest.json --evidence capture.json ./grafana-example - The folder holds a `README.md` with the bundle's provenance, an env folder `envs//` with a `Dockerfile` that installs this exact `rea-agents` -version and serves the copied `manifest.json` and `evidence.json` with +version and serves the copied `manifest.json` and `evidence.json` (written +readable by their owner only, since they hold captured content) with `environment-serve`, a Python verifier under `artifacts/check_/` and the smoke task `tasks/smoke.json`. `--package-tarball` copies a packed `rea-agents` tarball into the env folder and installs it instead of the registry package, diff --git a/docs/specs/agent-environments/environment-export/spec.md b/docs/specs/agent-environments/environment-export/spec.md index 09c79b7115..90a7feb5f7 100644 --- a/docs/specs/agent-environments/environment-export/spec.md +++ b/docs/specs/agent-environments/environment-export/spec.md @@ -30,7 +30,8 @@ previous increment. nothing is ever overwritten. - Write `README.md`, `envs//Dockerfile`, `envs//env.toml`, `envs//manifest.json` and `envs//evidence.json` as byte-exact - copies of the inputs, `artifacts/check_/verify.py` and + copies of the inputs (mode `0600`, so captured content stays readable by its + owner only whatever the umask), `artifacts/check_/verify.py` and `tasks/smoke.json`, where `` defaults to the manifest's kebab-case name and `--name` must be a kebab-case label of at most 64 characters. - The Dockerfile reads `FROM node:24.18.0-bookworm-slim`, sets diff --git a/src/application/environment/environmentExport.test.ts b/src/application/environment/environmentExport.test.ts index eb792603f0..deba925f8f 100644 --- a/src/application/environment/environmentExport.test.ts +++ b/src/application/environment/environmentExport.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import * as v8 from "node:v8"; @@ -251,3 +251,22 @@ describe("exportEnvironmentBundle", () => { expect(await listFiles(directory)).toHaveLength(7); }); }); + +describe("exportEnvironmentBundle file modes", () => { + it.skipIf(process.platform === "win32")( + "writes the captured manifest and Evidence copies readable by their owner only", + async () => { + const { input, directory } = await prepare( + "rea-environment-export-mode-", + ); + const exported = await exportEnvironmentBundle({ ...input, directory }); + if (!exported.ok) throw exported.error; + for (const copy of ["manifest.json", "evidence.json"]) { + const { mode } = await stat( + join(directory, "envs/grafana-example", copy), + ); + expect(mode & 0o777).toBe(0o600); + } + }, + ); +}); diff --git a/src/application/environment/environmentExport.ts b/src/application/environment/environmentExport.ts index 0a080c1ffb..8252de9f95 100644 --- a/src/application/environment/environmentExport.ts +++ b/src/application/environment/environmentExport.ts @@ -265,15 +265,22 @@ export const readEnvironmentExportInputs = async ( return ok({ manifest: manifest.value, evidence: evidence.value }); }; +/** Captured content (the manifest and Evidence copies) stays readable by its owner only, whatever the umask. */ +const CAPTURED_FILE_MODE = 0o600; + const writeNew = async ( root: string, relative: string, content: string | Uint8Array, + mode?: number, ): Promise => { const path = join(root, relative); try { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, content, { flag: "wx" }); + await writeFile(path, content, { + flag: "wx", + ...(mode === undefined ? {} : { mode }), + }); } catch (cause: unknown) { rethrowUnlessDenied(cause, path); } @@ -309,10 +316,20 @@ const writeBundle = async ( for (const file of plan.files) written.push(await writeNew(input.directory, file.path, file.content)); written.push( - await writeNew(input.directory, plan.copies.manifest, input.manifest.bytes), + await writeNew( + input.directory, + plan.copies.manifest, + input.manifest.bytes, + CAPTURED_FILE_MODE, + ), ); written.push( - await writeNew(input.directory, plan.copies.evidence, input.evidence.bytes), + await writeNew( + input.directory, + plan.copies.evidence, + input.evidence.bytes, + CAPTURED_FILE_MODE, + ), ); if (plan.copies.tarball !== null && plan.packageSource.kind === "tarball") written.push(