refactor(agents): validate workspace attestation rows structurally, not by filename allowlist (#113686)

* refactor(agents): validate attestation hashes structurally

* fix(agents): reject Windows reserved device names in attestation filenames

* refactor(agents): close attestation filename set with an ASCII markdown charset
This commit is contained in:
Peter Steinberger
2026-07-25 08:32:50 -07:00
committed by GitHub
parent 1a11f5394e
commit 14da8a2523
3 changed files with 73 additions and 12 deletions
+49
View File
@@ -49,6 +49,17 @@ function deleteState(targetDir: string): void {
deleteWorkspaceState(prepareWorkspaceStateDeletion(targetDir));
}
function insertPersistedAttestationHash(filename: string, sha256: string): void {
const identity = resolveWorkspaceStateIdentity(workspaceDir());
const db = openOpenClawStateDatabase().db;
db.prepare(
"INSERT INTO workspace_attestations (workspace_key, attested_at_ms, updated_at_ms) VALUES (?, 1, 1)",
).run(identity.workspaceKey);
db.prepare(
"INSERT INTO workspace_generated_bootstrap_hashes (workspace_key, filename, sha256) VALUES (?, ?, ?)",
).run(identity.workspaceKey, filename, sha256);
}
describe("workspace state store", () => {
it("round-trips setup and attestation state after a database restart", () => {
const dir = workspaceDir();
@@ -81,6 +92,44 @@ describe("workspace state store", () => {
]);
});
it.each(["HEARTBEAT.md", "RETIRED.md"])(
"reads a persisted hash for a retired or unknown bootstrap filename: %s",
(filename) => {
insertPersistedAttestationHash(filename, "a".repeat(64));
expect([
...readWorkspaceStateSnapshot(workspaceDir()).attestation!.generatedHashes.entries(),
]).toStrictEqual([[filename, "a".repeat(64)]]);
},
);
it.each([
"../AGENTS.md",
"nested\\AGENTS.md",
"C:outside.md",
"NUL.md",
"com1.md",
"CON.md",
"COM¹.md",
"CONIN$.md",
"CONOUT$.md",
".hidden.md",
])("rejects an unsafe persisted attestation filename: %s", (filename) => {
insertPersistedAttestationHash(filename, "a".repeat(64));
expect(() => readWorkspaceStateSnapshot(workspaceDir())).toThrow(
"workspace attestation hash row is invalid",
);
});
it("rejects a malformed persisted attestation hash", () => {
insertPersistedAttestationHash("AGENTS.md", "a".repeat(63));
expect(() => readWorkspaceStateSnapshot(workspaceDir())).toThrow(
"workspace attestation hash row is invalid",
);
});
it("never regresses persisted setup milestones", () => {
const dir = workspaceDir();
mergeWorkspaceSetupState(dir, { bootstrapSeededAt: "2026-07-16T01:00:00.000Z" }, 1_000);
+22 -10
View File
@@ -18,15 +18,25 @@ import { resolveUserPath } from "../utils.js";
export const WORKSPACE_SETUP_STATE_VERSION = 1 as const;
export const WORKSPACE_ATTESTATION_RECENT_MS = 24 * 60 * 60 * 1000;
export const WORKSPACE_LEGACY_STATE_MIGRATION_KIND = "legacy-workspace-setup-files";
export const WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES: ReadonlySet<string> = new Set([
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
]);
const MAX_WORKSPACE_ATTESTATION_FILENAME_LENGTH = 255;
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/u;
// Attested names are joined onto the workspace dir and read back, so keep the
// accepted set closed rather than denying unsafe forms one at a time: a plain
// ASCII markdown basename excludes separators, traversal, colons, NUL, and the
// Win32 superscript/`CONIN$` device aliases in one rule.
const SAFE_ATTESTATION_BASENAME = /^[A-Za-z0-9._-]+\.md$/u;
// Win32 keeps these stems special even with an extension, so `NUL.md` names a
// device rather than a workspace file; the charset above cannot catch them.
const WINDOWS_RESERVED_DEVICE_STEMS = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])$/iu;
export function isSafeWorkspaceAttestationFilename(filename: string): boolean {
return (
filename.length <= MAX_WORKSPACE_ATTESTATION_FILENAME_LENGTH &&
SAFE_ATTESTATION_BASENAME.test(filename) &&
!filename.startsWith(".") &&
!WINDOWS_RESERVED_DEVICE_STEMS.test(filename.split(".")[0] ?? "")
);
}
function isCanonicalIsoTimestamp(value: string): boolean {
const timestamp = new Date(value);
@@ -322,8 +332,10 @@ function readSnapshotFromDatabase(params: {
.orderBy("filename", "asc"),
).rows;
for (const row of hashRows) {
// Validate names structurally rather than against today's bootstrap set:
// retiring a seeded file must not make an existing attestation unreadable.
if (
!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(row.filename) ||
!isSafeWorkspaceAttestationFilename(row.filename) ||
!SHA256_HEX_PATTERN.test(row.sha256)
) {
throw new Error("workspace attestation hash row is invalid");
@@ -466,7 +478,7 @@ export function replaceWorkspaceAttestation(params: {
assertCanonicalIntegerTimestamp(params.nowMs, "attestation update");
}
for (const [filename, sha256] of params.generatedHashes) {
if (!WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(filename) || !SHA256_HEX_PATTERN.test(sha256)) {
if (!isSafeWorkspaceAttestationFilename(filename) || !SHA256_HEX_PATTERN.test(sha256)) {
throw new Error("workspace attestation hash is invalid");
}
}
@@ -2,9 +2,9 @@
import { createHash } from "node:crypto";
import { LEGACY_WORKSPACE_ATTESTATION_HEADER } from "../agents/workspace-legacy-state.js";
import {
WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES,
WORKSPACE_LEGACY_STATE_MIGRATION_KIND,
WORKSPACE_SETUP_STATE_VERSION,
isSafeWorkspaceAttestationFilename,
registerWorkspaceStateAliasesInTransaction,
} from "../agents/workspace-state-store.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
@@ -129,7 +129,7 @@ function parseAttestation(snapshot: SourceSnapshot): ParsedSource {
const generatedHashes = new Map<string, string>();
for (const line of lines.slice(2)) {
const match = /^generated:([^:]+):([a-f0-9]{64})$/.exec(line);
if (!match?.[1] || !match[2] || !WORKSPACE_ATTESTED_BOOTSTRAP_FILENAMES.has(match[1])) {
if (!match?.[1] || !match[2] || !isSafeWorkspaceAttestationFilename(match[1])) {
throw new Error("legacy workspace attestation has an invalid generated hash");
}
if (generatedHashes.has(match[1])) {