fix(cso): Rails verify replaces read-only copied config instead of writing into it

This commit is contained in:
garrytan committed 2026-10-19 17:00:24 +00:00
1 parent abae830649
commit 2a3b8ccedd
3 files changed
+49 -9

No files matched your search

+2 -1
View File
@@ -2,9 +2,10 @@
## [Unreleased]
**/cso's retained Git history no longer brings back credential files the snapshot leaves out, and a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`.** The helper keeps `.env`, `master.key`, keys and agent configuration out of the readable tree, but `history.txt` captured `git log -p` in full, so a committed `.env` came back through its diff whenever the value wasn't credential-shaped enough for the redactor. Paid qualification run 39186328569 caught this when the private corpus's planted `.env` value showed up in the judge's helper state.
**/cso can verify Rails repairs in a read-only checkout, its retained Git history no longer brings back credential files the snapshot leaves out, and a repair whose external assertions fail is now reported as `ASSERTION_FAILED` instead of a misleading `INCOMPATIBLE_INPUT`.** The helper keeps `.env`, `master.key`, keys and agent configuration out of the readable tree, but `history.txt` captured `git log -p` in full, so a committed `.env` came back through its diff whenever the value wasn't credential-shaped enough for the redactor. Paid qualification run 39186328569 caught this when the private corpus's planted `.env` value showed up in the judge's helper state.
### Fixed
- **Rails `verify` on read-only sources:** the helper writes its own test `config/database.yml` (plus an initializer and `storage.yml`) into the execution copy. The copy keeps the source's file modes, so in a read-only checkout (the eval producer seals every source file at 0444, and Nix or Bazel trees are the same) the write failed with `EACCES`. That surfaced as the generic `INVALID_SCHEMA` "unexpected or unsafe input", and no Rails repair could reach `runtime_tested`. Each helper file now replaces the copied file instead of opening it for writing, so it also never writes through a copied symlink.
- **`history.txt`:** every diff section that touches a path the snapshot excludes (`exclusion()`, the same rule the readable tree uses) is replaced with `diff withheld: <path> (<reason>)`. This covers either side of a rename and any diff header that can't be parsed. Commit preambles and other files' diffs stay.
- **`verify` with an authenticated witness:** a receipt or observation mismatch is still `INCOMPATIBLE_INPUT`. Receipts that bind correctly but record failed external assertions now raise `ASSERTION_FAILED`, with the phase and the booted, legitimate and security outcome. Nothing new is certified either way. In run 39186328569, three repairs that rejected the attack with a different status than the oracle expected surfaced as `INCOMPATIBLE_INPUT`.
+20 -7
View File
@@ -1106,6 +1106,25 @@ export async function superviseRegistrySocket(options: {
* Requires qualified runtime images to contain the fixed, compiled
* /opt/cso/preparation@1.0.0 helper contract.
*/
/**
* Write the helper's own files (Rails' test database.yml and friends) into an execution copy. The copy keeps the
* source's modes, and a sealed or read-only checkout makes those files 0444, so each copied file is replaced, never
* opened for writing (or written through).
*/
export function writeOfflineTransformations(
source: string,
transformations: OfflinePreparationRequest['transformations'],
): void {
for (const transformation of transformations) {
if (sha256(transformation.content) !== transformation.sha256)
fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed');
const file = contained(source, transformation.path);
secureDirectory(resolve(file, '..'));
fs.rmSync(file, { force: true });
fs.writeFileSync(file, transformation.content, { mode: 0o600, flag: 'wx' });
}
}
export class DockerPreparationSandboxRunner implements PreparationSandboxRunner {
readonly qualification;
private readonly runtime;
@@ -1514,13 +1533,7 @@ export class DockerPreparationSandboxRunner implements PreparationSandboxRunner
errorOnExist: false,
preserveTimestamps: false,
});
for (const transformation of request.transformations) {
if (sha256(transformation.content) !== transformation.sha256)
fail('INCOMPATIBLE_INPUT', 'Offline transformation content hash changed');
const file = contained(source, transformation.path);
secureDirectory(resolve(file, '..'));
fs.writeFileSync(file, transformation.content, { mode: 0o600 });
}
writeOfflineTransformations(source, request.transformations);
for (const item of request.metadata) {
if (sha256(item.content) !== item.sha256)
fail('INCOMPATIBLE_INPUT', `Offline metadata hash changed: ${item.path}`);
+27 -1
View File
@@ -3,8 +3,9 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createPreparedExport, recordNpmArchiveEntry } from '../lib/cso/preparation-container';
import { materializePreparedExport } from '../lib/cso/preparation-docker';
import { materializePreparedExport, writeOfflineTransformations } from '../lib/cso/preparation-docker';
import { heldApplicationReady, validateSingleContainerProcessOutput } from '../lib/cso/docker';
const roots: string[] = [];
@@ -68,3 +69,28 @@ describe('CSO preparation container archive limits', () => {
expect(heldApplicationReady('PID COMMAND\n1 sh\n')).toBe(false);
});
});
describe('offline transformations in an execution copy', () => {
test.skipIf(process.platform === 'win32')('replace a read-only copied file under a read-only directory instead of writing through it', () => {
const source = temporary(), config = path.join(source, 'config'), database = path.join(config, 'database.yml'), outside = path.join(temporary(), 'outside.yml');
fs.mkdirSync(config);
fs.writeFileSync(database, 'production: sealed\n');
fs.chmodSync(database, 0o444);
fs.chmodSync(config, 0o555);
fs.writeFileSync(outside, 'untouched\n');
const content = 'test:\n adapter: sqlite3\n', sha256 = createHash('sha256').update(content).digest('hex');
writeOfflineTransformations(source, [
{ path: 'config/database.yml', content, sha256, reason: 'helper test configuration' },
{ path: 'config/initializers/zzzz_cso_test.rb', content, sha256, reason: 'helper test configuration' },
]);
expect(fs.readFileSync(database, 'utf8')).toBe(content);
expect(fs.statSync(database).mode & 0o777).toBe(0o600);
expect(fs.readFileSync(path.join(config, 'initializers', 'zzzz_cso_test.rb'), 'utf8')).toBe(content);
expect(() => writeOfflineTransformations(source, [{ path: 'config/database.yml', content, sha256: 'f'.repeat(64), reason: 'tampered' }])).toThrow('Offline transformation content hash changed');
fs.rmSync(database);
fs.symlinkSync(outside, database);
writeOfflineTransformations(source, [{ path: 'config/database.yml', content, sha256, reason: 'helper test configuration' }]);
expect(fs.lstatSync(database).isSymbolicLink()).toBe(false);
expect(fs.readFileSync(outside, 'utf8')).toBe('untouched\n');
});
});