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 <noreply@anthropic.com>
This commit is contained in:
Edgar Arakelyan
2026-10-19 08:55:26 -07:00
parent ede425e9d5
commit 23b80c24a6
4 changed files with 44 additions and 6 deletions
+2 -1
View File
@@ -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/<name>/` 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_<name>/` 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,
@@ -30,7 +30,8 @@ previous increment.
nothing is ever overwritten.
- Write `README.md`, `envs/<name>/Dockerfile`, `envs/<name>/env.toml`,
`envs/<name>/manifest.json` and `envs/<name>/evidence.json` as byte-exact
copies of the inputs, `artifacts/check_<name>/verify.py` and
copies of the inputs (mode `0600`, so captured content stays readable by its
owner only whatever the umask), `artifacts/check_<name>/verify.py` and
`tasks/smoke.json`, where `<name>` 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
@@ -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);
}
},
);
});
@@ -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<WrittenBundleFile> => {
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(