diff --git a/skills/hermes-attestation-guardian/lib/attestation.mjs b/skills/hermes-attestation-guardian/lib/attestation.mjs index 63a3da3..b8e4401 100644 --- a/skills/hermes-attestation-guardian/lib/attestation.mjs +++ b/skills/hermes-attestation-guardian/lib/attestation.mjs @@ -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( diff --git a/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs b/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs index 9db2782..931eaa7 100644 --- a/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs +++ b/skills/hermes-attestation-guardian/scripts/generate_attestation.mjs @@ -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`; diff --git a/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs b/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs index 6a5244b..cf74ff3 100644 --- a/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs +++ b/skills/hermes-attestation-guardian/test/attestation_cli.test.mjs @@ -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"); diff --git a/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs b/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs index d2d1e5d..f6005f5 100644 --- a/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs +++ b/skills/hermes-attestation-guardian/test/attestation_schema.test.mjs @@ -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); + }, + ); }); }