fix(attestation): harden writes and fail-closed config parsing

This commit is contained in:
David Abutbul
2026-04-16 15:02:41 +03:00
parent ced2464594
commit 69368e9778
4 changed files with 99 additions and 18 deletions
@@ -113,14 +113,8 @@ export function resolveHermesScopedOutputPath(outputPath, hermesHome = detectHer
}
}
try {
if (fs.lstatSync(resolvedOutput).isSymbolicLink()) {
throw new Error(`output path must not be a symlink: ${resolvedOutput}`);
}
} catch (error) {
if (error?.code !== "ENOENT") {
throw error;
}
if (fs.existsSync(resolvedOutput) && fs.lstatSync(resolvedOutput).isSymbolicLink()) {
throw new Error(`output path must not be a symlink: ${resolvedOutput}`);
}
return resolvedOutput;
@@ -200,10 +194,14 @@ function readEnvBool(name, fallback = false) {
if (typeof raw !== "string") {
return fallback;
}
const norm = raw.trim().toLowerCase();
if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
return fallback;
return bool(raw, fallback);
}
function configBool(value, envFallback = false) {
if (value === undefined || value === null) {
return envFallback;
}
return bool(value, false);
}
function normalizePath(input, hermesHome) {
@@ -237,14 +235,14 @@ export function buildAttestation({
const config = configState.config || {};
const gateways = {
telegram: bool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
matrix: bool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
discord: bool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
telegram: configBool(config?.gateways?.telegram?.enabled, readEnvBool("HERMES_GATEWAY_TELEGRAM_ENABLED", false)),
matrix: configBool(config?.gateways?.matrix?.enabled, readEnvBool("HERMES_GATEWAY_MATRIX_ENABLED", false)),
discord: configBool(config?.gateways?.discord?.enabled, readEnvBool("HERMES_GATEWAY_DISCORD_ENABLED", false)),
};
const riskyToggles = {
allow_unsigned_mode: bool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
bypass_verification: bool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
allow_unsigned_mode: configBool(config?.security?.allow_unsigned_mode, readEnvBool("HERMES_ALLOW_UNSIGNED_MODE", false)),
bypass_verification: configBool(config?.security?.bypass_verification, readEnvBool("HERMES_BYPASS_VERIFICATION", false)),
};
const feedStatus = String(
@@ -88,6 +88,49 @@ function parseArgs(argv) {
return args;
}
function isSymlinkPath(filePath) {
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch (error) {
if (error?.code === "ENOENT") {
return false;
}
throw error;
}
}
function writeAtomically(outPath, body) {
const dir = path.dirname(outPath);
const base = path.basename(outPath);
const tempPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
let fd = null;
try {
fd = fs.openSync(tempPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
fs.writeFileSync(fd, body, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = null;
if (isSymlinkPath(outPath)) {
throw new Error(`output path must not be a symlink: ${outPath}`);
}
fs.renameSync(tempPath, outPath);
} finally {
if (fd !== null) {
try {
fs.closeSync(fd);
} catch {
// best-effort cleanup
}
}
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function run() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
@@ -113,7 +156,7 @@ function run() {
const outPath = resolveHermesScopedOutputPath(args.output);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const body = stableStringify(attestation, args.compact ? 0 : 2);
fs.writeFileSync(outPath, `${body}\n`, "utf8");
writeAtomically(outPath, `${body}\n`);
if (args.writeSha256) {
const shaPath = `${outPath}.sha256`;
@@ -76,6 +76,21 @@ await withTempDir(async (tempDir) => {
]);
assert.equal(verifyTrustedBaseline.status, 0, `trusted baseline should verify: ${verifyTrustedBaseline.stderr}`);
const hardLinkPath = path.join(attestationsDir, "current-hardlink.json");
const oldContent = "old-attestation-body\n";
await fs.writeFile(outputPath, oldContent, "utf8");
await fs.link(outputPath, hardLinkPath);
const atomicRewrite = runNode(generatorScript, ["--output", outputPath, "--generated-at", generatedAt], {
HERMES_HOME: hermesHome,
});
assert.equal(atomicRewrite.status, 0, `atomic rewrite failed: ${atomicRewrite.stderr}`);
const rewrittenContent = await fs.readFile(outputPath, "utf8");
const hardLinkedContent = await fs.readFile(hardLinkPath, "utf8");
assert.notEqual(rewrittenContent, hardLinkedContent, "output rewrite should atomically replace file entry");
assert.equal(hardLinkedContent, oldContent, "hard link should preserve previous file body after atomic replace");
const invalidCurrent = JSON.parse(attestationRaw);
delete invalidCurrent.platform;
await fs.writeFile(outputPath, JSON.stringify(invalidCurrent, null, 2), "utf8");
@@ -244,6 +244,31 @@ async function testBooleanConfigCoercionDoesNotEnableFalseStrings() {
assert.equal(attestation.posture.runtime.gateways.telegram, true);
},
);
await withPatchedEnv(
{
HERMES_HOME: hermesHome,
HERMES_GATEWAY_TELEGRAM_ENABLED: "true",
HERMES_ALLOW_UNSIGNED_MODE: "true",
},
async () => {
await fs.writeFile(
path.join(hermesHome, "config.json"),
JSON.stringify({
gateways: {
telegram: { enabled: "maybe" },
},
security: {
allow_unsigned_mode: { bad: true },
},
}),
"utf8",
);
const attestation = buildAttestation({ generatedAt: "2026-04-15T18:00:00.000Z" });
assert.equal(attestation.posture.runtime.gateways.telegram, false);
assert.equal(attestation.posture.runtime.risky_toggles.allow_unsigned_mode, false);
},
);
});
}