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(