From 14da8a252343a7d19c036acede9932c2b2ccdcc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 08:32:50 -0700 Subject: [PATCH] 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 --- src/agents/workspace-state-store.test.ts | 49 +++++++++++++++++++ src/agents/workspace-state-store.ts | 32 ++++++++---- .../state-migrations.workspace-setup-store.ts | 4 +- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/agents/workspace-state-store.test.ts b/src/agents/workspace-state-store.test.ts index 4e4bce73c6ee..49fa059548b2 100644 --- a/src/agents/workspace-state-store.test.ts +++ b/src/agents/workspace-state-store.test.ts @@ -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); diff --git a/src/agents/workspace-state-store.ts b/src/agents/workspace-state-store.ts index c106c4745e2d..17ffa4ee2f0a 100644 --- a/src/agents/workspace-state-store.ts +++ b/src/agents/workspace-state-store.ts @@ -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 = 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"); } } diff --git a/src/infra/state-migrations.workspace-setup-store.ts b/src/infra/state-migrations.workspace-setup-store.ts index ed79aad5953a..24c20cdc8203 100644 --- a/src/infra/state-migrations.workspace-setup-store.ts +++ b/src/infra/state-migrations.workspace-setup-store.ts @@ -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(); 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])) {