diff --git a/loopx/control_plane/agents/supervisor_event_append.ts b/loopx/control_plane/agents/supervisor_event_append.ts index 87da241e37..f4df568e3e 100644 --- a/loopx/control_plane/agents/supervisor_event_append.ts +++ b/loopx/control_plane/agents/supervisor_event_append.ts @@ -2,6 +2,7 @@ import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; function sequence(value: unknown, minimum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { @@ -12,7 +13,7 @@ function sequence(value: unknown, minimum: number): number { function identity(value: unknown) { const row = requireJsonObject(value, "supervisor event identity"); const fingerprint = requireNonEmptyString(row.fingerprint, "event fingerprint"); - if (!/^[a-f0-9]{64}$/.test(fingerprint)) throw new EffectRuntimeRequestError("invalid event fingerprint"); + if (!BARE_SHA256_PATTERN.test(fingerprint)) throw new EffectRuntimeRequestError("invalid event fingerprint"); return {event_id: requireNonEmptyString(row.event_id, "event_id"), fingerprint}; } export function planSupervisorEventAppend(value: unknown): JsonObject { diff --git a/loopx/control_plane/capabilities/external_evidence.ts b/loopx/control_plane/capabilities/external_evidence.ts index cb60b6c5df..4dcf8ed30b 100644 --- a/loopx/control_plane/capabilities/external_evidence.ts +++ b/loopx/control_plane/capabilities/external_evidence.ts @@ -7,6 +7,7 @@ import { requireNonEmptyString, requireStringLiteral, } from "../runtime_decode.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export const EXTERNAL_EVIDENCE_REQUEST_SCHEMA_VERSION = "loopx_external_evidence_request_v0"; @@ -27,7 +28,7 @@ const PROVIDER_KINDS = ["method", "connector"] as const; const RECEIPT_STATUSES = ["succeeded", "failed", "no_evidence"] as const; const EVIDENCE_BASES = ["stated", "observed", "tested", "inferred"] as const; const ADMISSION_DECISIONS = ["admit", "reject"] as const; -const SHA256_RE = /^sha256:[0-9a-f]{64}$/; +const SHA256_RE = ENVELOPED_SHA256_PATTERN; const PROVIDER_ID_RE = /^[a-z][a-z0-9_.:-]{1,95}$/; const SOURCE_REF_RE = /^(https?:\/\/|[a-z][a-z0-9+.-]*:\/\/|urn:)/; diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 25a7a8e82b..f661435365 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -7,6 +7,7 @@ import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts import {acceptanceValidationEffects, type AcceptanceCompletionRequirements} from "../goals/acceptance_contract.ts"; import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_validation_declaration.ts"; import {readTurnSelectionRejection, turnSelectionRejectionState} from "../turn_driver/selection_rejection.ts"; +import { BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; function requireThat(ok: unknown, message: string): asserts ok { if (!ok) throw new EffectRuntimeRequestError(message); @@ -99,7 +100,7 @@ export function delegationTurnPlanDecision(params: JsonObject): JsonObject { }; const transaction = requireJsonObject(plan.transaction, "Turn plan transaction"); requireThat(typeof transaction.turn_key === "string" - && /^sha256:[a-f0-9]{64}$/.test(transaction.turn_key), "Turn plan transaction requires a valid turn_key"); + && ENVELOPED_SHA256_PATTERN.test(transaction.turn_key), "Turn plan transaction requires a valid turn_key"); return { schema_version: "loopx_delegation_turn_plan_decision_v0", state: "planned", @@ -286,7 +287,7 @@ export function delegationInventoryQuery(params: JsonObject): JsonObject { const cursor = params.cursor ?? null; requireThat(Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= 50, "delegation inventory limit must be between 1 and 50"); - requireThat(cursor === null || (typeof cursor === "string" && /^[a-f0-9]{64}$/.test(cursor)), + requireThat(cursor === null || (typeof cursor === "string" && BARE_SHA256_PATTERN.test(cursor)), "invalid delegation inventory cursor"); return {limit, cursor}; } @@ -294,7 +295,7 @@ export function delegationInventoryQuery(params: JsonObject): JsonObject { /** The host supplies a fresh Delegations.read result, never a saved status. */ export function delegationInventoryItem(params: JsonObject): JsonObject { const record = requireJsonObject(params.record, "delegation inventory record"); - requireThat(typeof record.record_id === "string" && /^[a-f0-9]{64}$/.test(record.record_id), + requireThat(typeof record.record_id === "string" && BARE_SHA256_PATTERN.test(record.record_id), "invalid delegation record address"); requireThat(record.operation_id === null || (typeof record.operation_id === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(record.operation_id)), "invalid delegation operation identity"); @@ -323,7 +324,7 @@ export function delegationInventoryItem(params: JsonObject): JsonObject { result.artifacts = observation.artifacts.map(value => { const artifact = requireJsonObject(value, "accepted artifact"); requireThat(text(artifact.ref) && typeof artifact.sha256 === "string" - && /^[a-f0-9]{64}$/.test(artifact.sha256), "invalid accepted artifact reference"); + && BARE_SHA256_PATTERN.test(artifact.sha256), "invalid accepted artifact reference"); return {ref: artifact.ref, sha256: artifact.sha256}; }); } diff --git a/loopx/control_plane/collaboration/return_delivery.ts b/loopx/control_plane/collaboration/return_delivery.ts index 7165579f60..0859c08d6d 100644 --- a/loopx/control_plane/collaboration/return_delivery.ts +++ b/loopx/control_plane/collaboration/return_delivery.ts @@ -5,13 +5,14 @@ import { requireJsonObject, requireNonEmptyString, } from "../runtime_decode.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export const MANAGER_RETURN_DELIVERY_ATTEMPT_SCHEMA = "manager_return_delivery_attempt_v0"; const PROVIDER = /^[a-z][a-z0-9_-]{0,31}$/; const OPAQUE_REF = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/; -const DIGEST = /^sha256:[0-9a-f]{64}$/; +const DIGEST = ENVELOPED_SHA256_PATTERN; const ATTEMPT_KEYS = [ "schema_version", "provider", diff --git a/loopx/control_plane/collaboration/semantic_request.ts b/loopx/control_plane/collaboration/semantic_request.ts index da14fc84e3..d6134f9652 100644 --- a/loopx/control_plane/collaboration/semantic_request.ts +++ b/loopx/control_plane/collaboration/semantic_request.ts @@ -1,6 +1,7 @@ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireJsonObject, requireNonEmptyString } from "../runtime_decode.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; @@ -55,7 +56,7 @@ export function normalizeCollaborationBrief(value: unknown): JsonObject { const ref = workspaceRef(input.ref); const result: JsonObject = { ref, description: text(input.description, "input.description", 1000) }; if (input.sha256 !== undefined) { - if (typeof input.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(input.sha256)) { + if (typeof input.sha256 !== "string" || !BARE_SHA256_PATTERN.test(input.sha256)) { throw new EffectRuntimeRequestError("input.sha256 must be a SHA256 digest"); } result.sha256 = input.sha256; diff --git a/loopx/control_plane/content_digest.ts b/loopx/control_plane/content_digest.ts new file mode 100644 index 0000000000..44505bb672 --- /dev/null +++ b/loopx/control_plane/content_digest.ts @@ -0,0 +1,3 @@ +/** One owner for the two shapes a stored SHA-256 digest takes in a record. */ +export const ENVELOPED_SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; +export const BARE_SHA256_PATTERN = /^[0-9a-f]{64}$/; diff --git a/loopx/control_plane/coordination/authority_archive_read.ts b/loopx/control_plane/coordination/authority_archive_read.ts index 4b39d5065b..09b3a0ada0 100644 --- a/loopx/control_plane/coordination/authority_archive_read.ts +++ b/loopx/control_plane/coordination/authority_archive_read.ts @@ -8,10 +8,11 @@ import type {AuthorityStoreCommittedTransaction} from "./authority_store.ts"; import {AuthorityStoreProtocolError, canonicalAuthorityObject, canonicalAuthorityObjectList, canonicalAuthoritySha256, hasExactAuthorityKeys, requireAuthorityStoreId} from "./authority_store_codec.ts"; import {AuthorityStateReplay, decodeAuthorityStateDelta} from "./authority_state_log.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const AUTHORITY_ARCHIVE_SCHEMA = "loopx_authority_archive_v0"; const MAX_LINE_BYTES = 64 * 1024 * 1024; -const HEX = /^[0-9a-f]{64}$/; +const HEX = BARE_SHA256_PATTERN; export interface ArchiveHeader extends JsonObject { kind: "header"; schema_version: typeof AUTHORITY_ARCHIVE_SCHEMA; diff --git a/loopx/control_plane/coordination/authority_source.ts b/loopx/control_plane/coordination/authority_source.ts index efe7154a4b..2779466d32 100644 --- a/loopx/control_plane/coordination/authority_source.ts +++ b/loopx/control_plane/coordination/authority_source.ts @@ -5,6 +5,7 @@ import {readFile} from "node:fs/promises"; import {isAbsolute} from "node:path"; import type {JsonObject} from "../effect_program.ts"; import {requireJsonObject} from "../runtime_decode.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export type AuthoritySourceCheck = () => Promise; /** Legacy wires and service-owned callers retain their existing fact contract. */ @@ -25,7 +26,7 @@ export function registryAuthoritySourceCheck( } const source = requireJsonObject(input.registry_source, "registry_source"); if (typeof source.path !== "string" || !isAbsolute(source.path) || - typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sha256)) { + typeof source.sha256 !== "string" || !BARE_SHA256_PATTERN.test(source.sha256)) { throw new TypeError("registry_source requires an absolute path and SHA-256 digest"); } // Copy primitive values: later mutation of the decoded request cannot change diff --git a/loopx/control_plane/coordination/local_authority_migration.ts b/loopx/control_plane/coordination/local_authority_migration.ts index 26478b3a2a..2e9d822c1f 100644 --- a/loopx/control_plane/coordination/local_authority_migration.ts +++ b/loopx/control_plane/coordination/local_authority_migration.ts @@ -18,6 +18,7 @@ import {FileAuthorityStore, syncAuthorityDirectory} from "./file_authority_store import {SqliteAuthorityStore} from "./sqlite_authority_store.ts"; import {localAuthorityProviderPaths, openLocalAuthorityStoreHandle, publishLocalAuthoritySelection, requireLocalAuthorityRuntimeRoot} from "./local_authority_provider.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; type Provider = "file" | "sqlite"; interface Source extends JsonObject { @@ -43,7 +44,7 @@ interface Recovery extends JsonObject { target_store_identity: string; archive_sha256: string; } -const HEX = /^[0-9a-f]{64}$/; +const HEX = BARE_SHA256_PATTERN; function provider(value: unknown): Provider { if (value !== "file" && value !== "sqlite") throw new Error("Local migration requires file or sqlite"); return value; diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 5721496eb3..6bfa62f393 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -42,6 +42,7 @@ import { LOCAL_AUTHORITY_SHADOW_TRANSACTION_PROJECTION_SCHEMA, LOCAL_AUTHORITY_SHADOW_TRANSACTION_RECEIPT_SCHEMA, } from "./coordination_state_contract.generated.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export { LOCAL_AUTHORITY_SHADOW_EVIDENCE_SCHEMA, @@ -113,7 +114,7 @@ const READ_REQUEST_FIELDS = new Set([ "scan_limit", ]); const ENTRY_ID_PATTERN = /^local-shadow-tx-[0-9a-f]{64}$/u; -const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const DIGEST_PATTERN = ENVELOPED_SHA256_PATTERN; const MAX_SCAN_LIMIT = 10000; const REVISION_RETRY_ATTEMPTS = 3; diff --git a/loopx/control_plane/coordination/local_authority_shadow_outbox.ts b/loopx/control_plane/coordination/local_authority_shadow_outbox.ts index 66fb1b21d2..d1f704a151 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_outbox.ts +++ b/loopx/control_plane/coordination/local_authority_shadow_outbox.ts @@ -14,6 +14,7 @@ import { LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, } from "./coordination_state_contract.generated.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; /** * Lease-partition side of the local authority shadow outbox. @@ -128,7 +129,7 @@ export function decodeOutboxCursor(value: unknown, partition: string): JsonObjec typeof record.last_entry_id !== "string" || !/^local-shadow-tx-[0-9a-f]{64}$/u.test(record.last_entry_id) || (record.last_partition_digest !== null && - (typeof record.last_partition_digest !== "string" || !/^sha256:[0-9a-f]{64}$/u.test(record.last_partition_digest))) || + (typeof record.last_partition_digest !== "string" || !ENVELOPED_SHA256_PATTERN.test(record.last_partition_digest))) || [record.last_cursor, record.last_provider_revision].some((part) => typeof part !== "string" || part.trim().length === 0)) { throw invalid(); } diff --git a/loopx/control_plane/coordination/reviewed_promotion_plan.ts b/loopx/control_plane/coordination/reviewed_promotion_plan.ts index 26f0748d86..3690acbcd6 100644 --- a/loopx/control_plane/coordination/reviewed_promotion_plan.ts +++ b/loopx/control_plane/coordination/reviewed_promotion_plan.ts @@ -2,6 +2,7 @@ import type { JsonObject } from "../effect_program.ts"; import { requireJsonObject, requireBoolean } from "../runtime_decode.ts"; import { canonicalAuthorityObject, requireAuthorityStoreId } from "./authority_store_codec.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const REVIEWED_PROMOTION_PLAN_SCHEMA = "loopx_reviewed_coordination_promotion_v0"; export const REVIEWED_PROMOTION_OPERATION_SCHEMA = "loopx_reviewed_coordination_promotion_operation_v0"; @@ -10,7 +11,7 @@ export const REVIEWED_PROMOTION_OPERATION_RESULT_SCHEMA = export type ReviewedPromotionAction = "apply" | "recover"; export function promotionPlanDigest(value: unknown): string { - if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) { + if (typeof value !== "string" || !BARE_SHA256_PATTERN.test(value)) { throw new TypeError("reviewed promotion plan digest must be a lowercase SHA-256"); } return value; diff --git a/loopx/control_plane/coordination/runtime_shadow.ts b/loopx/control_plane/coordination/runtime_shadow.ts index 308a7766d6..575ab9e3de 100644 --- a/loopx/control_plane/coordination/runtime_shadow.ts +++ b/loopx/control_plane/coordination/runtime_shadow.ts @@ -19,6 +19,7 @@ import { withShadowMaintenanceLock, ShadowManagementError, requireShadowPrimaryWriteAllowed, } from "./shadow_management.ts"; import * as schemas from "./coordination_state_contract.generated.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export const COORDINATION_RUNTIME_SHADOW_REQUEST_SCHEMA = schemas.COORDINATION_RUNTIME_SHADOW_COMMIT_REQUEST_SCHEMA; export const COORDINATION_RUNTIME_SHADOW_RESULT_SCHEMA = schemas.COORDINATION_RUNTIME_SHADOW_COMMIT_RESULT_SCHEMA; @@ -82,7 +83,7 @@ function sourceSnapshot(request: ShadowRequest): JsonObject { if (!isAbsolute(text(snapshot.state_path, "state_path")) || !isAbsolute(text(snapshot.registered_runtime_root, "registered_runtime_root")) || !isAbsolute(text(snapshot.registered_state_path, "registered_state_path")) || - !/^sha256:[0-9a-f]{64}$/.test(text(snapshot.state_bytes_sha256, "state_bytes_sha256")) || + !ENVELOPED_SHA256_PATTERN.test(text(snapshot.state_bytes_sha256, "state_bytes_sha256")) || !Array.isArray(snapshot.lease_inventory) || !Array.isArray(snapshot.evidence_files) || snapshot.projection_sha256 !== canonicalAuthoritySha256(request.projection)) { throw new ShadowManagementError("source_snapshot_invalid"); diff --git a/loopx/control_plane/coordination/shadow_drain_files.ts b/loopx/control_plane/coordination/shadow_drain_files.ts index 38238237d2..fcdf9bcf57 100644 --- a/loopx/control_plane/coordination/shadow_drain_files.ts +++ b/loopx/control_plane/coordination/shadow_drain_files.ts @@ -15,6 +15,7 @@ import {sha256Digest, readOutboxCursor, outboxPartitionDirectory} from "./local_ import {legacyCoordinationTodoLockPath, taskLeaseLockPath} from "./legacy_writer_lock_paths.ts"; import {readShadowBootstrapSourcePath, type ShadowCaptureBinding} from "./shadow_management.ts"; import {LOCAL_AUTHORITY_SHADOW_OUTBOX_ENTRY_SCHEMA, LOCAL_AUTHORITY_SHADOW_OUTBOX_COMMIT_SCHEMA} from "./coordination_state_contract.generated.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export type DrainPartition = "todos" | "leases"; export interface DrainEntry { @@ -56,7 +57,7 @@ export async function drainInventory(root: string, goal: string, partition: Drai record.goal_id === goal && record.partition === partition && ["python", "typescript"].includes(String(writer.runtime)) && typeof writer.write_class === "string" && writer.write_class.length > 0 && ["markdown_active_state", "state_event_log", "task_lease_record"].includes(String(source.kind)) && - typeof record.source_root_digest === "string" && /^sha256:[0-9a-f]{64}$/.test(record.source_root_digest) && ref !== null && + typeof record.source_root_digest === "string" && ENVELOPED_SHA256_PATTERN.test(record.source_root_digest) && ref !== null && outboxEntryIdentity(goal, partition, seq, ref, record.capture_lineage_id, record.source_root_digest) === id); entry.prepared = true; entry.capture_lineage_id = record.capture_lineage_id; entry.prepared_sha256 = digest; } else { diff --git a/loopx/control_plane/coordination/shadow_entry_delivery.ts b/loopx/control_plane/coordination/shadow_entry_delivery.ts index 4d85bf49b7..d80c0dd8c2 100644 --- a/loopx/control_plane/coordination/shadow_entry_delivery.ts +++ b/loopx/control_plane/coordination/shadow_entry_delivery.ts @@ -1,4 +1,5 @@ import {outboxPartitionProjection} from "./shadow_entry_evidence.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; /** Native delivery of an immutable, witnessed outbox entry. The host selects * evidence, never supplies the projection or decides whether a write committed. */ import {lstat, readFile} from "node:fs/promises"; @@ -40,8 +41,8 @@ function decode(value: unknown): Selection { typeof r.seq === "number" && Number.isSafeInteger(r.seq) && r.seq > 0 && r.seq <= MAX_OUTBOX_SEQUENCE && typeof r.entry_id === "string" && /^local-shadow-tx-[0-9a-f]{64}$/u.test(r.entry_id) && typeof r.capture_lineage_id === "string" && r.capture_lineage_id.trim().length > 0 && - typeof r.prepared_sha256 === "string" && /^sha256:[0-9a-f]{64}$/u.test(r.prepared_sha256) && - (r.committed_sha256 === null || typeof r.committed_sha256 === "string" && /^sha256:[0-9a-f]{64}$/u.test(r.committed_sha256)), + typeof r.prepared_sha256 === "string" && ENVELOPED_SHA256_PATTERN.test(r.prepared_sha256) && + (r.committed_sha256 === null || typeof r.committed_sha256 === "string" && ENVELOPED_SHA256_PATTERN.test(r.committed_sha256)), "shadow_entry_selection_invalid"); return {runtime_root: r.runtime_root, goal_id: r.goal_id, partition: r.partition, seq: r.seq, entry_id: r.entry_id, capture_lineage_id: r.capture_lineage_id, diff --git a/loopx/control_plane/coordination/shadow_management.ts b/loopx/control_plane/coordination/shadow_management.ts index 3eb676b9f9..427f961429 100644 --- a/loopx/control_plane/coordination/shadow_management.ts +++ b/loopx/control_plane/coordination/shadow_management.ts @@ -13,10 +13,11 @@ import { FileAuthorityStore } from "./file_authority_store.ts"; import { SHADOW_MANAGEMENT_STATE_SCHEMA, SHADOW_MANAGEMENT_MANIFEST_SCHEMA, SHADOW_OUTBOX_MANIFEST_SCHEMA, } from "./coordination_state_contract.generated.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export { SHADOW_MANAGEMENT_STATE_SCHEMA, SHADOW_MANAGEMENT_MANIFEST_SCHEMA, SHADOW_OUTBOX_MANIFEST_SCHEMA }; export const SHADOW_CAPTURE_PROFILE = "file_outbox_v1"; -const DIGEST = /^sha256:[0-9a-f]{64}$/; +const DIGEST = ENVELOPED_SHA256_PATTERN; export interface ShadowCaptureBinding extends JsonObject { capture_profile: string; diff --git a/loopx/control_plane/coordination/shadow_registry_source.ts b/loopx/control_plane/coordination/shadow_registry_source.ts index eef375f2bc..4c6eb68aae 100644 --- a/loopx/control_plane/coordination/shadow_registry_source.ts +++ b/loopx/control_plane/coordination/shadow_registry_source.ts @@ -10,6 +10,7 @@ import {canonicalAuthorityBytes, hasExactAuthorityKeys} from "./authority_store_ import {registryAuthoritySourceCheck} from "./authority_source.ts"; import {ShadowManagementError} from "./shadow_management.ts"; import {normalizeRegisteredTodoAgents} from "./todo_agents.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; interface ShadowRegistrySource { path: string; @@ -25,7 +26,7 @@ function registrySource(snapshot: JsonObject): ShadowRegistrySource { } const value = raw as JsonObject; if (typeof value.path !== "string" || !isAbsolute(value.path) || - typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.sha256) || + typeof value.sha256 !== "string" || !BARE_SHA256_PATTERN.test(value.sha256) || !Array.isArray(value.registered_agents) || resolve(value.path) === resolve(String(snapshot.state_path))) { throw new ShadowManagementError("source_registry_witness_invalid"); diff --git a/loopx/control_plane/coordination/source_transfer.ts b/loopx/control_plane/coordination/source_transfer.ts index 9828fb2fcd..4826f48edd 100644 --- a/loopx/control_plane/coordination/source_transfer.ts +++ b/loopx/control_plane/coordination/source_transfer.ts @@ -13,6 +13,7 @@ import { COORDINATION_SOURCE_TRANSFER_REQUEST_SCHEMA as SOURCE_TRANSFER_SCHEMA, COORDINATION_SOURCE_TRANSFER_RESULT_SCHEMA as SOURCE_TRANSFER_RESULT_SCHEMA, } from "./coordination_state_contract.generated.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export {SOURCE_TRANSFER_SCHEMA, SOURCE_TRANSFER_RESULT_SCHEMA}; export const MAX_SOURCE_TRANSFER_BYTES = COORDINATION_STATE_CONTRACT.source_transfer_limits.max_bytes; type Handler = (value: JsonObject) => unknown | Promise; @@ -28,7 +29,7 @@ export function withCoordinationSourceTransfer(method: string, handler: Handler) if (envelope.schema_version !== SOURCE_TRANSFER_SCHEMA) return handler(envelope); ensure(hasExactAuthorityKeys(envelope, ["schema_version", "method", "directory", "request_sha256", "request_bytes"]) && envelope.method === method && typeof envelope.directory === "string" && isAbsolute(envelope.directory) && - typeof envelope.request_sha256 === "string" && /^[a-f0-9]{64}$/u.test(envelope.request_sha256) && + typeof envelope.request_sha256 === "string" && BARE_SHA256_PATTERN.test(envelope.request_sha256) && typeof envelope.request_bytes === "number" && Number.isSafeInteger(envelope.request_bytes) && envelope.request_bytes > 0 && envelope.request_bytes <= MAX_SOURCE_TRANSFER_BYTES, "invalid coordination source transfer envelope or artifact size"); diff --git a/loopx/control_plane/coordination/sqlite_authority_store.ts b/loopx/control_plane/coordination/sqlite_authority_store.ts index 024222d73d..5e069d4e36 100644 --- a/loopx/control_plane/coordination/sqlite_authority_store.ts +++ b/loopx/control_plane/coordination/sqlite_authority_store.ts @@ -24,11 +24,12 @@ import { isAuthorityStateCheckpoint, type AuthorityStateDelta, } from "./authority_state_log.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const SQLITE_AUTHORITY_STORE_SCHEMA = "loopx_sqlite_authority_store_v2"; const IDENTITY = /^sqlite:[0-9a-f]{32}$/; const REVISION = /^sqlite:([0-9a-f]{32}):([1-9]\d*)$/; -const DIGEST = /^[0-9a-f]{64}$/; +const DIGEST = BARE_SHA256_PATTERN; const MAX_SEQUENCE = 9223372036854775807n; const AUDIT_PAGE = 512; const COMMIT_COLUMNS = `CAST(cursor AS TEXT) AS sequence, operation_id, commit_digest, state_digest, diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index 70c3d4d05f..fb53fe13d3 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -57,6 +57,7 @@ import { deriveCoordinationTodoSuccessorProposals, TODO_SUCCESSOR_DERIVATION_REQUEST_SCHEMA, } from "./todo_successor_derivation.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const COORDINATION_TODO_TERMINAL_LIFECYCLE_RESULT_SCHEMA = "loopx_coordination_todo_terminal_lifecycle_result_v0"; @@ -351,7 +352,7 @@ function normalizeTerminalInput( if (raw.review_basis !== undefined) { const basis = canonicalAuthorityObject(raw.review_basis, "terminal review basis"); if (Object.keys(basis).some(key => !["provider_revision", "registry_sha256"].includes(key)) || - typeof basis.registry_sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(basis.registry_sha256)) { + typeof basis.registry_sha256 !== "string" || !BARE_SHA256_PATTERN.test(basis.registry_sha256)) { throw new AuthorityStoreProtocolError("terminal review requires an exact provider revision and registry SHA-256"); } requireAuthorityStoreId(basis.provider_revision, "review provider revision"); @@ -360,7 +361,7 @@ function normalizeTerminalInput( requireAuthorityStoreId(raw.validation_source_provider_revision, "validation source provider revision"); } if (raw.validation_declaration_sha256 != null && - !/^[a-f0-9]{64}$/u.test(raw.validation_declaration_sha256)) { + !BARE_SHA256_PATTERN.test(raw.validation_declaration_sha256)) { throw new AuthorityStoreProtocolError("validation declaration commitment must be a SHA-256 digest"); } if (raw.validation_declaration != null && raw.validation_declaration_sha256 != null && @@ -711,7 +712,7 @@ function acceptedCompletionResult(input: ResolvedCoordinationTodoTerminalLifecyc acceptanceRequire(Object.keys(row).length === fields.length && fields.every(field => Object.hasOwn(row, field)) && row.provider === "local_runtime_v0" && ["application/json", "text/markdown", "text/plain"].includes(String(row.content_type)) && - typeof row.sha256 === "string" && /^[a-f0-9]{64}$/.test(row.sha256) && + typeof row.sha256 === "string" && BARE_SHA256_PATTERN.test(row.sha256) && Number.isSafeInteger(row.size_bytes) && Number(row.size_bytes) > 0 && Number(row.size_bytes) <= 128000, "Completion result must be a bounded local content-addressed object."); return {...row, schema_version: "loopx_completion_result_v0", @@ -1301,7 +1302,7 @@ export async function executeCoordinationTodoTerminalLifecycle( } if (validationRequired && !implicitMonitorNoChange && (update === undefined || authorityTodo.status !== "done")) { - if (typeof validationSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(validationSha256)) { + if (typeof validationSha256 !== "string" || !BARE_SHA256_PATTERN.test(validationSha256)) { return terminalFailure( "completion_validation_identity_missing", "canonical Todo requires validation but omits its declaration digest", diff --git a/loopx/control_plane/coordination/todo_update_intent.ts b/loopx/control_plane/coordination/todo_update_intent.ts index 9d3f5555f3..24025ea38d 100644 --- a/loopx/control_plane/coordination/todo_update_intent.ts +++ b/loopx/control_plane/coordination/todo_update_intent.ts @@ -14,6 +14,7 @@ import { decodeCompletionValidationRevision, type CompletionValidationRevision, } from "../todos/completion_validation_revision.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; const UPDATE_FIELDS = new Set(["text", "note"]); export interface CoordinationTodoUpdateInput { @@ -50,7 +51,7 @@ export function normalizeTodoUpdateInput(raw: CoordinationTodoUpdateInput): Coor if (raw.expected_provider_revision !== undefined) { requireAuthorityStoreId(raw.expected_provider_revision, "expected_provider_revision"); } - if (raw.expected_registry_sha256 !== undefined && !/^[a-f0-9]{64}$/u.test(raw.expected_registry_sha256)) { + if (raw.expected_registry_sha256 !== undefined && !BARE_SHA256_PATTERN.test(raw.expected_registry_sha256)) { throw new AuthorityStoreProtocolError("expected_registry_sha256 must be a SHA-256 digest"); } if (raw.authority_reason != null && typeof raw.authority_reason !== "string") { diff --git a/loopx/control_plane/effect_runtime_snapshot.ts b/loopx/control_plane/effect_runtime_snapshot.ts index 0be26c093e..94f493657b 100644 --- a/loopx/control_plane/effect_runtime_snapshot.ts +++ b/loopx/control_plane/effect_runtime_snapshot.ts @@ -6,6 +6,7 @@ import {lstat, open, type FileHandle} from "node:fs/promises"; import {basename, dirname, isAbsolute} from "node:path"; import {EffectRuntimeRequestError} from "./effect_runtime_errors.ts"; import {requireJsonObject, requireNonEmptyString} from "./runtime_decode.ts"; +import { BARE_SHA256_PATTERN } from "./content_digest.ts"; export const MAX_LOCAL_SNAPSHOT_BYTES = 64 * 1024 * 1024; @@ -39,7 +40,7 @@ export async function readPrivateJsonSnapshot(value: unknown): Promise const path = requireNonEmptyString(ref.path, "snapshot path"); const digest = requireNonEmptyString(ref.sha256, "snapshot sha256"); const size = ref.byte_count; - if (!/^[a-f0-9]{64}$/.test(digest) || typeof size !== "number" || + if (!BARE_SHA256_PATTERN.test(digest) || typeof size !== "number" || !Number.isSafeInteger(size) || size <= 0 || size > MAX_LOCAL_SNAPSHOT_BYTES) { throw new Error("invalid private snapshot reference"); } diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index 409d36b10b..09d6af9b3c 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -13,6 +13,7 @@ import {GOAL_ACCEPTANCE_SCHEMA, acceptanceKeys, acceptanceRequire, acceptanceTas acceptanceCompletionRequirements, goalAcceptanceTodoDigest, goalAcceptanceWorkDigest, normalizeAcceptanceResults, normalizeGoalAcceptanceDocument, projectGoalAcceptance, readGoalAcceptance, type AcceptanceState, type AcceptanceVerification} from "./acceptance_contract.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; const RESULT_SCHEMA = "loopx_goal_acceptance_result_v0"; const RECEIPT_SCHEMA = "loopx_goal_acceptance_operation_v0"; @@ -128,7 +129,7 @@ export async function configureGoalAcceptance(store: AuthorityStore, value: Json export async function commitGoalAcceptanceVerification(store: AuthorityStore, value: JsonObject): Promise { const request = mutationRequest(value, true); acceptanceRequire(Number.isSafeInteger(request.revision) && Number(request.revision) > 0 && - typeof request.contract_digest === "string" && /^[a-f0-9]{64}$/.test(request.contract_digest), "invalid verification contract basis"); + typeof request.contract_digest === "string" && BARE_SHA256_PATTERN.test(request.contract_digest), "invalid verification contract basis"); const todoId = request.todo_id == null ? null : requireAuthorityStoreId(request.todo_id, "verification todo_id"); const results = normalizeAcceptanceResults(request.results); const command = receiptFor(request, "verify"); diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index 7947bb9413..c4e8adc0ae 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -6,6 +6,7 @@ import {AuthorityStoreProtocolError, authorityUnicodeCompare, canonicalAuthority import {indexCoordinationProjectionTodos, validateCoordinationTodoReadModel} from "../coordination/coordination_projection.ts"; import {COMPLETION_VALIDATION_BINDING_RECEIPT_SCHEMA, completionValidationRevisionHistory} from "../todos/completion_validation_revision.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const GOAL_ACCEPTANCE_SCHEMA = "loopx_goal_acceptance_v0"; export interface AcceptanceCriterion extends JsonObject { @@ -304,7 +305,7 @@ export function readGoalAcceptance(head: JsonObject, goalId: string): Acceptance acceptanceKeys(binding, ["todo_id", "todo_semantic_digest", "revision", "criterion_ids", "confirmed_by"]); const declared = document.bindings.find(item => item.todo_id === binding.todo_id); acceptanceRequire(declared && binding.confirmed_by === "owner" && binding.revision === state.revision && - typeof binding.todo_semantic_digest === "string" && /^[a-f0-9]{64}$/.test(binding.todo_semantic_digest) && + typeof binding.todo_semantic_digest === "string" && BARE_SHA256_PATTERN.test(binding.todo_semantic_digest) && canonicalAuthoritySha256(binding.criterion_ids) === canonicalAuthoritySha256(declared.criterion_ids), "invalid owner-confirmed acceptance binding"); return binding as AcceptanceBinding; }); @@ -318,7 +319,7 @@ export function readGoalAcceptance(head: JsonObject, goalId: string): Acceptance acceptanceRequire(operationId === receipt.operation_id, "verification operation id must be trimmed"); acceptanceRequire(Number.isSafeInteger(receipt.contract_revision) && Number(receipt.contract_revision) > 0 && Number(receipt.contract_revision) <= Number(state.revision) && - [receipt.contract_digest, receipt.work_digest].every(value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)), + [receipt.contract_digest, receipt.work_digest].every(value => typeof value === "string" && BARE_SHA256_PATTERN.test(value)), "invalid verification basis"); const todoId = receipt.todo_id === null ? null : id(receipt.todo_id); // Historical receipts can name retired criteria. Current acceptance checks diff --git a/loopx/control_plane/goals/goal_amendment_proposal.ts b/loopx/control_plane/goals/goal_amendment_proposal.ts index d00d0beef7..7b05872e97 100644 --- a/loopx/control_plane/goals/goal_amendment_proposal.ts +++ b/loopx/control_plane/goals/goal_amendment_proposal.ts @@ -12,6 +12,7 @@ import { requireStringArray, requireStringLiteral, } from "../runtime_decode.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; /** * Governed goal amendment proposal admission contract (RFC @@ -126,7 +127,7 @@ const PROPOSAL_ID_PATTERN = /^gap_[a-z0-9_-]{3,64}$/; // (loopx/control_plane/todos/contract.py): replan obligation ids are // "replan-" + 16 lowercase hex chars, e.g. "replan-fe2d75e84da47ac3". const REPLAN_OBLIGATION_ID_PATTERN = /^replan-[a-f0-9]{16}$/; -const SOURCE_BASIS_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +const SOURCE_BASIS_DIGEST_PATTERN = ENVELOPED_SHA256_PATTERN; const AGENT_ID_PATTERN = /^[a-z][a-z0-9_.:@-]{0,79}$/; const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; diff --git a/loopx/control_plane/goals/operator_actions.ts b/loopx/control_plane/goals/operator_actions.ts index c5a82abcdd..9a20276644 100644 --- a/loopx/control_plane/goals/operator_actions.ts +++ b/loopx/control_plane/goals/operator_actions.ts @@ -7,6 +7,7 @@ import { import { projectGoalBindingMatch } from "./goal_instance_identity.ts"; import type { JsonObject } from "../effect_program.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const GOAL_ACTION_PROJECTION_REQUEST_SCHEMA_VERSION = "loopx_goal_action_projection_request_v3"; @@ -15,7 +16,7 @@ export const GOAL_ACTION_CATALOG_SCHEMA_VERSION = export const GOAL_ACTION_SCHEMA_VERSION = "loopx_goal_action_v1"; const OPAQUE_ID = /^[A-Za-z0-9._:-]{1,200}$/; -const SHA256 = /^[a-f0-9]{64}$/; +const SHA256 = BARE_SHA256_PATTERN; function requireOpaqueId(value: unknown, label: string): string { const token = requireNonEmptyString(value, label); diff --git a/loopx/control_plane/goals/shared_goal_alignment.ts b/loopx/control_plane/goals/shared_goal_alignment.ts index 32fd903f90..f0968fe3c4 100644 --- a/loopx/control_plane/goals/shared_goal_alignment.ts +++ b/loopx/control_plane/goals/shared_goal_alignment.ts @@ -10,6 +10,7 @@ import { requireStringArray, requireStringLiteral, } from "../runtime_decode.ts"; +import { BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; /** * Read-only `shared_goal_alignment_v0` projection contract (RFC @@ -68,7 +69,7 @@ const AGENT_ID_PATTERN = /^[a-z][a-z0-9_.:@-]{0,79}$/; // repository Goal-ID contract does not require a "goal-" prefix; registered // goal ids such as "loopx-meta" must decode. const GOAL_ID_PATTERN = /^(?!\.\.?$)[^\s/\\]+$/; -const SOURCE_BASIS_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +const SOURCE_BASIS_DIGEST_PATTERN = ENVELOPED_SHA256_PATTERN; export type RevisionBasis = (typeof REVISION_BASIS_VALUES)[number]; export type BasisSource = (typeof BASIS_SOURCE_VALUES)[number]; @@ -208,7 +209,7 @@ function decodeSourceBasis(value: unknown): SourceBasisFacts { if (raw.todo_basis !== undefined) { const basis = requireJsonObject(raw.todo_basis, "todo_basis"); if (basis.source_authority !== "file_v0" || - typeof basis.records_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(basis.records_sha256)) { + typeof basis.records_sha256 !== "string" || !BARE_SHA256_PATTERN.test(basis.records_sha256)) { throw new EffectRuntimeRequestError("invalid canonical Todo basis"); } todoBasis = {source_authority: basis.source_authority, records_sha256: basis.records_sha256, diff --git a/loopx/control_plane/goals/source_session_lifetime.ts b/loopx/control_plane/goals/source_session_lifetime.ts index a0b18957b2..e2d78e827e 100644 --- a/loopx/control_plane/goals/source_session_lifetime.ts +++ b/loopx/control_plane/goals/source_session_lifetime.ts @@ -5,6 +5,7 @@ import { parseExactGoalRef, type ExactGoalRef, } from "./goal_instance_identity.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export const SOURCE_SESSION_PROFILE_ID = "source_session_v1"; export const SOURCE_SESSION_BINDING_LIMIT = 256; @@ -109,7 +110,7 @@ function requiredString(value: unknown, label: string): string { function requestDigest(value: unknown): string { const digest = requiredString(value, "request_digest"); - if (!/^sha256:[0-9a-f]{64}$/.test(digest)) { + if (!ENVELOPED_SHA256_PATTERN.test(digest)) { throw new EffectRuntimeRequestError("request_digest must be a SHA-256 digest"); } return digest; diff --git a/loopx/control_plane/governed_capability.ts b/loopx/control_plane/governed_capability.ts index 81149a2fef..83f1a50a9e 100644 --- a/loopx/control_plane/governed_capability.ts +++ b/loopx/control_plane/governed_capability.ts @@ -7,6 +7,7 @@ import { requireNonEmptyString as requiredString, requireStringLiteral, } from "./runtime_decode.ts"; +import { ENVELOPED_SHA256_PATTERN } from "./content_digest.ts"; export const EXTERNAL_EFFECT_RECEIPT_SCHEMA_VERSION = "loopx_external_effect_receipt_v0"; @@ -179,7 +180,7 @@ function canonicalDigest(value: unknown): string { function requiredCanonicalDigest(value: unknown, label: string): string { const digest = requiredString(value, label); - if (!/^sha256:[0-9a-f]{64}$/.test(digest)) { + if (!ENVELOPED_SHA256_PATTERN.test(digest)) { throw new EffectRuntimeRequestError(`${label} is invalid`); } return digest; diff --git a/loopx/control_plane/quota/refresh_external_delivery.ts b/loopx/control_plane/quota/refresh_external_delivery.ts index 131e4b354c..4fcdaf4a04 100644 --- a/loopx/control_plane/quota/refresh_external_delivery.ts +++ b/loopx/control_plane/quota/refresh_external_delivery.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import type { JsonObject, SettlementIdentity } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { jsonObject, requireJsonObject } from "../runtime_decode.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const EXTERNAL_DELIVERY_SCHEMA = "refresh_external_delivery_v0"; export const EXTERNAL_DELIVERY_EVENT = "refresh_external_delivery"; @@ -16,7 +17,7 @@ export function decodeExternalDelivery(value: unknown): ExternalDeliveryRequest const input = requireJsonObject(value, "external_delivery"); if (typeof input.suppress !== "boolean" || (input.resume_key !== null && - (typeof input.resume_key !== "string" || !/^[a-f0-9]{64}$/.test(input.resume_key))) || + (typeof input.resume_key !== "string" || !BARE_SHA256_PATTERN.test(input.resume_key))) || (input.suppress && input.resume_key !== null)) { throw new EffectRuntimeRequestError("external_delivery requires suppress and a mutually exclusive resume key"); } @@ -48,7 +49,7 @@ export function refreshExternalDelivery( if (!details || details.schema_version !== EXTERNAL_DELIVERY_SCHEMA || details.settlement_effect_id !== identity.effect_id || typeof event.event_id !== "string" || (details.state !== "paused" && details.state !== "ready") || - typeof details.resume_key !== "string" || !/^[a-f0-9]{64}$/.test(details.resume_key)) { + typeof details.resume_key !== "string" || !BARE_SHA256_PATTERN.test(details.resume_key)) { invalid = true; break; } diff --git a/loopx/control_plane/runtime/usage_statistics_cycles.ts b/loopx/control_plane/runtime/usage_statistics_cycles.ts index 24e3f51c17..f4d5561e5a 100644 --- a/loopx/control_plane/runtime/usage_statistics_cycles.ts +++ b/loopx/control_plane/runtime/usage_statistics_cycles.ts @@ -7,13 +7,14 @@ import { hostCategory } from "./usage_statistics_goal_contract.ts"; import type { GoalObservation } from "./usage_statistics_goal_contract.ts"; import { readCodexTiming } from "./usage_statistics_codex.ts"; import type { CodexCursor } from "./usage_statistics_codex.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export type CycleObservation = { key: string; lane: string; turn: string | null; phase: "start" | "spend"; at: number; host: string; codex?: { path: string; id: string } }; type Cycle = { id: string; start?: number; end?: number; touched: number; exact: boolean; host: string; floor?: number }; type State = { generation: string; cycles: Cycle[]; cursors: Record }; const WEEK = 7 * 86400000; export function validCycle(value: unknown, now: number): value is CycleObservation { - return object(value) && [value.key, value.lane].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) - && (value.turn === null || typeof value.turn === "string" && /^[a-f0-9]{64}$/.test(value.turn)) + return object(value) && [value.key, value.lane].every(v => typeof v === "string" && BARE_SHA256_PATTERN.test(v)) + && (value.turn === null || typeof value.turn === "string" && BARE_SHA256_PATTERN.test(value.turn)) && ["start", "spend"].includes(String(value.phase)) && Number.isSafeInteger(value.at) && Number(value.at) <= now + 1000 && Number(value.at) >= now - 86400000 && typeof value.host === "string"; } diff --git a/loopx/control_plane/runtime/usage_statistics_goal_contract.ts b/loopx/control_plane/runtime/usage_statistics_goal_contract.ts index 9e723e9200..08e4450d34 100644 --- a/loopx/control_plane/runtime/usage_statistics_goal_contract.ts +++ b/loopx/control_plane/runtime/usage_statistics_goal_contract.ts @@ -1,4 +1,5 @@ import { object } from "./usage_statistics_contract.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const GOAL_SCHEMA = "loopx_goal_usage_aggregate_v1"; export const GOAL_DURATIONS = ["lt_1m", "lt_10m", "lt_1h", "lt_6h", "lt_1d", "lt_7d", "lt_30d", "gte_30d"] as const; @@ -37,7 +38,7 @@ export function validGoalAggregate(value: unknown): value is GoalAggregate { export function validGoalObservation(value: unknown, now: number): value is GoalObservation { return object(value) && Object.keys(value).sort().join() === "end,host,key,measurement,start" && (MEASUREMENTS as readonly unknown[]).includes(value.measurement) && (HOSTS as readonly unknown[]).includes(value.host) - && typeof value.key === "string" && /^[a-f0-9]{64}$/.test(value.key) + && typeof value.key === "string" && BARE_SHA256_PATTERN.test(value.key) && Number.isSafeInteger(value.start) && Number.isSafeInteger(value.end) && Number(value.start) > 0 && Number(value.start) <= Number(value.end) && Number(value.end) <= now + 1000 && Number(value.end) >= now - 7 * DAY diff --git a/loopx/control_plane/runtime/usage_statistics_goals.ts b/loopx/control_plane/runtime/usage_statistics_goals.ts index 34f0d916ce..546d0c3a7f 100644 --- a/loopx/control_plane/runtime/usage_statistics_goals.ts +++ b/loopx/control_plane/runtime/usage_statistics_goals.ts @@ -6,6 +6,7 @@ import type { JsonObject } from "../effect_program.ts"; import { object } from "./usage_statistics_contract.ts"; import { GOAL_SCHEMA, HOSTS, MEASUREMENTS, goalDuration, validGoalObservation } from "./usage_statistics_goal_contract.ts"; import type { GoalAggregate, GoalObservation, GoalCount, Measurement, Host } from "./usage_statistics_goal_contract.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; type Interval = [number, number]; type MeasuredGoal = { key: string; measurement: Measurement; host: Host; first: number; last: number; intervals: Interval[]; total: number; watermark: number; day: string; reported?: string }; type GoalState = { generation: string; goals: MeasuredGoal[] }; @@ -30,7 +31,7 @@ async function load(path: string, generation: string): Promise { if (value.generation !== generation) return { generation, goals: [] }; if (!Array.isArray(value.goals) || value.goals.length > MAX_GOALS || value.goals.some(g => !object(g) || !MEASUREMENTS.includes(g.measurement) || !HOSTS.includes(g.host) - || typeof g.key !== "string" || !/^[a-f0-9]{64}$/.test(g.key) + || typeof g.key !== "string" || !BARE_SHA256_PATTERN.test(g.key) || ![g.first, g.last, g.total, g.watermark].every(n => Number.isSafeInteger(n) && n >= 0) || g.first > g.last || typeof g.day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(g.day) || !Array.isArray(g.intervals) || g.intervals.length > MAX_INTERVALS diff --git a/loopx/control_plane/todos/completion_transaction.ts b/loopx/control_plane/todos/completion_transaction.ts index 473383c475..d0df3c296b 100644 --- a/loopx/control_plane/todos/completion_transaction.ts +++ b/loopx/control_plane/todos/completion_transaction.ts @@ -37,6 +37,7 @@ import { resolveTodoCompletionPolicy, type TodoCompletionPolicyResult, } from "./completion_policy.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const TODO_COMPLETION_TRANSACTION_REQUEST_SCHEMA = "loopx_todo_completion_transaction_v0"; @@ -177,7 +178,7 @@ function optionalString(value: unknown, label: string): string | null { function optionalDigest(value: unknown, label: string): string | null { const normalized = optionalString(value, label); - if (normalized !== null && !/^[a-f0-9]{64}$/u.test(normalized)) { + if (normalized !== null && !BARE_SHA256_PATTERN.test(normalized)) { throw new EffectRuntimeRequestError(`${label} must be a SHA-256 digest or null`); } return normalized; diff --git a/loopx/control_plane/todos/completion_validation_revision.ts b/loopx/control_plane/todos/completion_validation_revision.ts index ab2d7de185..5b2a44e330 100644 --- a/loopx/control_plane/todos/completion_validation_revision.ts +++ b/loopx/control_plane/todos/completion_validation_revision.ts @@ -7,6 +7,7 @@ import { } from "../coordination/authority_store_codec.ts"; import {normalizeTodoAgent} from "../coordination/todo_agents.ts"; import {normalizeTodoCompletionValidationDeclaration} from "./completion_validation_declaration.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export const COMPLETION_VALIDATION_REVISION_SCHEMA = "loopx_todo_completion_validation_revision_v0"; @@ -25,7 +26,7 @@ export interface CompletionValidationRevision extends JsonObject { } const digest = (value: unknown, label: string): string => { - if (typeof value !== "string" || !/^[a-f0-9]{64}$/u.test(value)) { + if (typeof value !== "string" || !BARE_SHA256_PATTERN.test(value)) { throw new AuthorityStoreProtocolError(`${label} must be a SHA-256 digest`); } return value; diff --git a/loopx/control_plane/turn_driver/chat_turn_acceptance.ts b/loopx/control_plane/turn_driver/chat_turn_acceptance.ts index c30959b250..3ea714fee0 100644 --- a/loopx/control_plane/turn_driver/chat_turn_acceptance.ts +++ b/loopx/control_plane/turn_driver/chat_turn_acceptance.ts @@ -9,6 +9,7 @@ import { requireNonEmptyString, requireStringLiteral, } from "../runtime_decode.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; export const CHAT_TURN_ACCEPTANCE_REQUEST_SCHEMA = "loopx_chat_turn_acceptance_request_v0"; @@ -35,7 +36,7 @@ const TERMINAL_TURN_STATUSES = new Set([ "failed", ]); const OPAQUE_ID = /^[A-Za-z0-9._-]{1,160}$/; -const SHA256 = /^sha256:[0-9a-f]{64}$/; +const SHA256 = ENVELOPED_SHA256_PATTERN; const EMPTY_OBJECT_SHA256 = sha256("{}"); type OpaqueId = string & {readonly __brand: "OpaqueId"}; diff --git a/loopx/control_plane/work_items/pending_capability_intent.ts b/loopx/control_plane/work_items/pending_capability_intent.ts index 25f08f7216..2033071e8f 100644 --- a/loopx/control_plane/work_items/pending_capability_intent.ts +++ b/loopx/control_plane/work_items/pending_capability_intent.ts @@ -4,11 +4,12 @@ import { requireNonEmptyString as requiredString, requireJsonObject as requiredObject, } from "../runtime_decode.ts"; +import { ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; const SCHEMA_VERSION = "pending_capability_intent_projection_v0"; const TOKEN_RE = /^[a-z][a-z0-9_.:-]{2,127}$/; const IDEMPOTENCY_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$/; -const DIGEST_RE = /^sha256:[0-9a-f]{64}$/; +const DIGEST_RE = ENVELOPED_SHA256_PATTERN; const FIELDS = new Set([ "schema_version", "capability_id", diff --git a/loopx/control_plane/work_items/replan_history_snapshot.ts b/loopx/control_plane/work_items/replan_history_snapshot.ts index b65ba95ae1..e17d58900d 100644 --- a/loopx/control_plane/work_items/replan_history_snapshot.ts +++ b/loopx/control_plane/work_items/replan_history_snapshot.ts @@ -9,6 +9,7 @@ import type { JsonObject } from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { requireJsonObject, requireNonEmptyString, requireStringLiteral } from "../runtime_decode.ts"; import { projectReplanHistory } from "./replan_history.ts"; +import { BARE_SHA256_PATTERN } from "../content_digest.ts"; export async function projectReplanHistorySnapshot(value: unknown): Promise { const request = requireJsonObject(value, "replan history snapshot"); @@ -16,7 +17,7 @@ export async function projectReplanHistorySnapshot(value: unknown): Promise = { + "control_plane/agents/delivery_workspace.ts": + "GIT_REVISION_DIGEST_PATTERN is case-insensitive (/i): a git revision may be written " + + "in either case, so this surface is a different policy, not a stale copy.", +}; + +/** + * Constructions whose pattern is assembled from runtime values. None of them can be + * folded here, so each is named with the shape it answers; a new one has to be declared, + * and a digest restatement cannot hide in this bucket (the last assertion below proves it). + */ +const DECLARED_UNFOLDABLE: Record = { + "control_plane/coordination/authority_format_inspection.ts": { + count: 1, + reason: "store identity binds a provider name at runtime; 32 hex, not a 64 digest", + }, + "control_plane/coordination/local_authority_migration.ts": { + count: 2, + reason: "store identity per provider arm; 32 hex, not a 64 digest", + }, + "control_plane/coordination/local_authority_provider.ts": { + count: 2, + reason: "store identity selector; 32 hex, not a 64 digest", + }, + "control_plane/coordination/todo_agents.ts": { + count: 2, + reason: "whitespace class assembled from a shared character-class constant", + }, + "control_plane/quota/monitor_poll_commit.ts": { + count: 1, + reason: "artifact file name grammar keyed by a runtime effect token", + }, + "control_plane/todos/priority.ts": { + count: 2, + reason: "legacy patterns are carried by the contract record, not by this module", + }, +}; + +/** Every module that reads the owner, pinned so that dropping an import is loud. */ +const CANONICAL_CONSUMERS = [ + "control_plane/agents/supervisor_event_append.ts", + "control_plane/capabilities/external_evidence.ts", + "control_plane/collaboration/delegation.ts", + "control_plane/collaboration/return_delivery.ts", + "control_plane/collaboration/semantic_request.ts", + "control_plane/coordination/authority_archive_read.ts", + "control_plane/coordination/authority_source.ts", + "control_plane/coordination/local_authority_migration.ts", + "control_plane/coordination/local_authority_shadow.ts", + "control_plane/coordination/local_authority_shadow_outbox.ts", + "control_plane/coordination/reviewed_promotion_plan.ts", + "control_plane/coordination/runtime_shadow.ts", + "control_plane/coordination/shadow_drain_files.ts", + "control_plane/coordination/shadow_entry_delivery.ts", + "control_plane/coordination/shadow_management.ts", + "control_plane/coordination/shadow_registry_source.ts", + "control_plane/coordination/source_transfer.ts", + "control_plane/coordination/sqlite_authority_store.ts", + "control_plane/coordination/todo_terminal_lifecycle.ts", + "control_plane/coordination/todo_update_intent.ts", + "control_plane/effect_runtime_snapshot.ts", + "control_plane/goals/acceptance_authority.ts", + "control_plane/goals/acceptance_contract.ts", + "control_plane/goals/goal_amendment_proposal.ts", + "control_plane/goals/operator_actions.ts", + "control_plane/goals/shared_goal_alignment.ts", + "control_plane/goals/source_session_lifetime.ts", + "control_plane/governed_capability.ts", + "control_plane/quota/refresh_external_delivery.ts", + "control_plane/runtime/usage_statistics_cycles.ts", + "control_plane/runtime/usage_statistics_goal_contract.ts", + "control_plane/runtime/usage_statistics_goals.ts", + "control_plane/todos/completion_transaction.ts", + "control_plane/todos/completion_validation_revision.ts", + "control_plane/turn_driver/chat_turn_acceptance.ts", + "control_plane/work_items/pending_capability_intent.ts", + "control_plane/work_items/replan_history_snapshot.ts", + "control_plane/work_items/task_lease_acquire.ts", + "control_plane/work_items/task_lease_lifecycle.ts", + "control_plane/work_items/task_lease_lifecycle_request.ts", +]; + +function packageFiles(dir: string, base = ""): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = base ? `${base}/${entry.name}` : entry.name; + if (entry.isDirectory()) found.push(...packageFiles(join(dir, entry.name), rel)); + else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".generated.ts")) found.push(rel); + } + return found.sort(); +} + +/** The set a character class admits, so `[0-9a-f]` and `[a-f0-9]` compare equal. */ +function charClassMembers(body: string): string[] { + const chars = new Set(); + for (let index = 0; index < body.length; index += 1) { + const char = body[index] as string; + if (char === "\\") { + index += 1; + continue; + } + const limit = body[index + 2]; + if (body[index + 1] === "-" && limit !== undefined) { + for (let code = char.charCodeAt(0); code <= limit.charCodeAt(0); code += 1) { + chars.add(String.fromCharCode(code)); + } + index += 2; + continue; + } + chars.add(char); + } + return [...chars].sort(); +} + +/** Which whole-value digest verdict this pattern source states, if any. */ +function digestEnvelope(pattern: string): Envelope | null { + if (!pattern.startsWith("^") || !pattern.endsWith("$")) return null; + let body = pattern.slice(1, -1); + let envelope: Envelope = "bare"; + const prefixes: [string, Envelope][] = [ + ["(?:sha256:)?", "union"], + ["(sha256:)?", "union"], + ["(?:sha256:)", "enveloped"], + ["(sha256:)", "enveloped"], + ["sha256:", "enveloped"], + ]; + for (const [spelling, kind] of prefixes) { + if (body.startsWith(spelling)) { + envelope = kind; + body = body.slice(spelling.length); + break; + } + } + const classOnly = /^\[([^\]]*)\]\{64\}$/.exec(body); + if (classOnly === null) return null; + if (charClassMembers(classOnly[1] as string).join("") !== HEX_CHARS.join("")) return null; + return envelope; +} + +/** + * One lexical scope. A name maps to every declaration in that scope, independent of traversal + * order; a `null` init is a blocker - a parameter, an import, `let`/`var`, a second declaration of + * the same name - and a blocker stops the search instead of falling through to an outer binding, so + * shadowing cannot silently substitute a different value. `ctor` marks a name that denotes the + * built-in `RegExp` itself rather than a foldable string, which is how a name bound by destructuring + * (`const { RegExp: MAKE } = globalThis`) still resolves. + */ +type Binding = { init: ts.Expression | null; at: Scope; ctor: boolean }; +type Scope = { parent: Scope | null; vars: Map }; + +const SCOPE_OPENERS = new Set([ + ts.SyntaxKind.Block, + ts.SyntaxKind.CaseBlock, + ts.SyntaxKind.CatchClause, + ts.SyntaxKind.ClassDeclaration, + ts.SyntaxKind.ClassExpression, + ts.SyntaxKind.ForInStatement, + ts.SyntaxKind.ForOfStatement, + ts.SyntaxKind.ForStatement, + ts.SyntaxKind.FunctionDeclaration, + ts.SyntaxKind.FunctionExpression, + ts.SyntaxKind.ArrowFunction, + ts.SyntaxKind.MethodDeclaration, + ts.SyntaxKind.Constructor, + ts.SyntaxKind.GetAccessor, + ts.SyntaxKind.SetAccessor, +]); + +function declare(scope: Scope, name: string, init: ts.Expression | null, ctor = false): void { + const seen = scope.vars.get(name); + if (seen === undefined) scope.vars.set(name, [{ init, at: scope, ctor }]); + else seen.push({ init, at: scope, ctor }); +} + +/** The innermost binding for a name, or null when it is absent, blocked or ambiguous. */ +function binding(scope: Scope, name: string): Binding | null { + for (let current: Scope | null = scope; current !== null; current = current.parent) { + const seen = current.vars.get(name); + if (seen === undefined) continue; + return seen.length === 1 ? seen[0] as Binding : null; + } + return null; +} + +/** Names a `const` may not hold: only block-scoped `const` without a later write is foldable. */ +function isConstDeclaration(node: ts.VariableDeclaration): boolean { + const list = node.parent; + return ( + ts.isVariableDeclarationList(list) && + (list.flags & ts.NodeFlags.BlockScoped) !== 0 && + (list.flags & ts.NodeFlags.Const) !== 0 + ); +} + +/** + * Every identifier written to anywhere in the file. Increment targets are deliberately absent: + * `++`/`--` needs a mutable binding, and those are already blockers. + */ +function assignedNames(source: ts.SourceFile): Set { + const names = new Set(); + const collect = (node: ts.Node): void => { + if (ts.isIdentifier(node)) names.add(node.text); + ts.forEachChild(node, collect); + }; + (function visit(node: ts.Node): void { + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment + ) { + collect(node.left); + } + ts.forEachChild(node, visit); + })(source); + return names; +} + +/** The names a binding pattern introduces, flattened. */ +function boundNames(node: ts.Node | undefined): string[] { + if (node === undefined) return []; + if (ts.isIdentifier(node)) return [node.text]; + if (ts.isNamespaceImport(node)) return [node.name.text]; + if (ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node)) { + // An elided element (`const [, a] = …`) carries no name, so only real bindings are collected. + return node.elements.flatMap((element) => (ts.isBindingElement(element) ? boundNames(element.name) : [])); + } + return []; +} + +/** Fold an expression to the string it denotes, through scope-resolved const names and `+`. */ +function foldStringExpression( + expr: ts.Expression, + scope: Scope, + source: ts.SourceFile, + depth = 0, +): string | null { + if (depth > 8) return null; + if (ts.isParenthesizedExpression(expr)) { + return foldStringExpression(expr.expression, scope, source, depth + 1); + } + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + // The AST value is cooked. Source slicing would leave `\\x30` as four characters and let an + // ordinary escaped spelling of `[0-9a-f]` bypass the value-based ownership rule. + return expr.text; + } + if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = foldStringExpression(expr.left, scope, source, depth + 1); + const right = foldStringExpression(expr.right, scope, source, depth + 1); + return left === null || right === null ? null : left + right; + } + if (ts.isIdentifier(expr)) { + const found = binding(scope, expr.text); + // Absent, blocked or ambiguous all fail closed: the site becomes unfoldable and has to be + // declared, rather than folding to whatever a same-named binding elsewhere happens to hold. + if (found === null || found.init === null) return null; + return foldStringExpression(found.init, found.at, source, depth + 1); + } + return null; +} + +function parse(text: string, file: string): ts.SourceFile { + return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +function nameUsage(source: ts.SourceFile, localName: string): number { + let uses = 0; + (function visit(node: ts.Node): void { + // A reference inside the import clause is the declaration itself, not a use. + if (ts.isIdentifier(node) && node.text === localName && !isImportSpecifier(node)) uses += 1; + ts.forEachChild(node, visit); + })(source); + return uses; +} + +function isImportSpecifier(node: ts.Node): boolean { + let current: ts.Node = node; + while (current.kind !== ts.SyntaxKind.SourceFile) { + if (ts.isImportSpecifier(current) || ts.isImportClause(current)) return true; + current = current.parent; + } + return false; +} + +/** + * Every matcher this source can be shown to create: a regex literal, or a construction of the + * built-in `RegExp` reached by name, by a global receiver, by a `bind` hop, or by a const or + * destructured alias of those - plus a call that names the built-in in its arguments. A pattern + * assembled only at runtime is reported as unfoldable when the construction itself is recognised, + * and is outside the model when the constructor is (both pinned in the matrices below). + */ +/** The receivers `RegExp` can legitimately be reached through. */ +const GLOBAL_RECEIVERS = new Set(["globalThis", "window", "global", "self"]); + +/** `Function.prototype` hops that keep the built-in as the constructor. */ +const FUNCTION_METHODS = new Set(["call", "apply", "bind"]); + +/** + * A destructured name holds no foldable string, so it is a blocker - except for the one shape that + * still denotes the built-in: `const { RegExp: MAKE } = globalThis` / `const { RegExp } = globalThis`. + */ +function declarePatternBindings(scope: Scope, node: ts.VariableDeclaration, source: ts.SourceFile): void { + const fromGlobalObject = + node.initializer !== undefined && + ts.isIdentifier(node.initializer) && + GLOBAL_RECEIVERS.has(node.initializer.text); + if (!ts.isObjectBindingPattern(node.name)) { + for (const name of boundNames(node.name)) declare(scope, name, null); + return; + } + for (const element of node.name.elements) { + if (!ts.isBindingElement(element)) continue; + const key = element.propertyName; + const imported = key === undefined + ? (ts.isIdentifier(element.name) ? element.name.text : "") + : key.getText(source).replace(/^["']|["']$/g, ""); + for (const local of boundNames(element.name)) { + declare(scope, local, null, fromGlobalObject && imported === "RegExp"); + } + } +} + +/** + * Does this callee denote the built-in `RegExp`? Identifier spelling, a global receiver + * (`globalThis.RegExp`, `globalThis["RegExp"]`), a `Function.prototype` hop (`RegExp.call(null, …)`) + * or a const alias bound to one of those. Bounded on purpose - no checker, no symbol graph - so a + * callee is followed only through the same const bindings the pattern fold uses. A call whose callee + * does not denote the built-in is still scanned when one of its *arguments* denotes it + * (`Reflect.construct(RegExp, [pattern])`); a digest-shaped string reaching an unrelated call is not + * treated as a matcher, so error text cannot trip this gate. A constructor reached only at runtime + * (`const MAKE = pick(); new MAKE(pattern)`) is outside the static model and is pinned as such in + * the out-of-scope matrix below. + */ +function calleeNamesRegExp( + callee: ts.Expression, + scope: Scope, + source: ts.SourceFile, + depth = 0, +): boolean { + if (depth > 4) return false; + const expr = ts.isParenthesizedExpression(callee) ? callee.expression : callee; + if (ts.isIdentifier(expr)) { + if (expr.text === "RegExp") return true; + const found = binding(scope, expr.text); + if (found === null) return false; + if (found.ctor) return true; + return found.init !== null && calleeNamesRegExp(found.init, found.at, source, depth + 1); + } + if (ts.isCallExpression(expr)) { + // `RegExp.bind(null)` hands back a function that still constructs with the built-in, so an alias + // taken through `bind` is the same owner. `call`/`apply` return a match result, so they are not. + return ( + ts.isPropertyAccessExpression(expr.expression) && + expr.expression.name.text === "bind" && + calleeNamesRegExp(expr.expression, scope, source, depth + 1) + ); + } + if (ts.isPropertyAccessExpression(expr)) { + if (ts.isIdentifier(expr.expression) && GLOBAL_RECEIVERS.has(expr.expression.text)) { + return expr.name.text === "RegExp"; + } + // `RegExp.call(null, pattern)` and friends still construct with the built-in. + return FUNCTION_METHODS.has(expr.name.text) && calleeNamesRegExp(expr.expression, scope, source, depth + 1); + } + if (ts.isElementAccessExpression(expr)) { + const reached = calleeNamesRegExp(expr.expression, scope, source, depth + 1); + if (reached) return true; + return ( + ts.isIdentifier(expr.expression) && + GLOBAL_RECEIVERS.has(expr.expression.text) && + foldStringExpression(expr.argumentExpression, scope, source) === "RegExp" + ); + } + return false; +} + +/** Does this expression statically denote the built-in `RegExp`, directly or through one alias? */ +function refersToRegExp(expr: ts.Expression, scope: Scope, source: ts.SourceFile): boolean { + return calleeNamesRegExp(expr, scope, source); +} + +/** + * A digest pattern reaching a call that is not a recognised construction. `Reflect.construct` + * carries it inside an argument list, so an inline or const-bound array is unfolded element-wise. + */ +function foldedDigestArgument( + arg: ts.Expression, + scope: Scope, + source: ts.SourceFile, + depth = 0, +): string[] { + if (depth > 8) return []; + if (ts.isParenthesizedExpression(arg)) { + return foldedDigestArgument(arg.expression, scope, source, depth + 1); + } + const direct = foldStringExpression(arg, scope, source); + if (direct !== null) return digestEnvelope(direct) === null ? [] : [direct]; + if (ts.isIdentifier(arg)) { + const found = binding(scope, arg.text); + if (found !== null && found.init !== null) { + return foldedDigestArgument(found.init, found.at, source, depth + 1); + } + return []; + } + if (ts.isArrayLiteralExpression(arg)) { + return arg.elements.flatMap((element) => + ts.isSpreadElement(element) ? [] : foldedDigestArgument(element, scope, source, depth + 1)); + } + return []; +} + +function foldedDigestArguments(args: readonly ts.Expression[], scope: Scope, source: ts.SourceFile): string[] { + const values: string[] = []; + for (const arg of args) { + values.push(...foldedDigestArgument(arg, scope, source)); + } + return values; +} + +function matchersInText(text: string, file: string): Matcher[] { + const source = parse(text, file); + const written = assignedNames(source); + const root: Scope = { parent: null, vars: new Map() }; + const scopes = new Map(); + const found: Matcher[] = []; + + // Imports bind names whose value lives in another module, so they are blockers at file scope. + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue; + for (const name of boundNames(statement.importClause?.namedBindings)) declare(root, name, null); + const defaultName = statement.importClause?.name; + if (defaultName !== undefined) declare(root, defaultName.text, null); + } + + // Build scopes and their bindings before inspecting any use site. Lexical bindings do not depend + // on source traversal order: a function may legitimately read a module `const` declared later, + // after module initialization has completed. The former one-pass walk silently missed that alias. + (function collect(node: ts.Node, scope: Scope): void { + const inner = + node.kind === ts.SyntaxKind.SourceFile || !SCOPE_OPENERS.has(node.kind) + ? scope + : { parent: scope, vars: new Map() }; + scopes.set(node, inner); + + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const foldable = isConstDeclaration(node) && node.initializer !== undefined && !written.has(node.name.text); + declare(inner, node.name.text, foldable ? node.initializer : null); + } else if (ts.isVariableDeclaration(node)) { + declarePatternBindings(inner, node, source); + } + if (ts.isFunctionLike(node)) { + for (const parameter of node.parameters) { + for (const name of boundNames(parameter.name)) declare(inner, name, null); + } + } + ts.forEachChild(node, (child) => collect(child, inner)); + })(source, root); + + (function walk(node: ts.Node, scope: Scope, insideConstruction: boolean): void { + const inner = scopes.get(node) ?? scope; + + const push = (pattern: string | null, flags: string | null, via: Matcher["via"]): void => { + const spelling = node.getText(source).replace(/\s+/g, " "); + found.push({ + file, + line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, + spelling, + flags, + pattern, + envelope: pattern !== null ? digestEnvelope(pattern) : VISIBLE_HEX64_CLASS.test(spelling) ? "unresolved-visible" : null, + via, + }); + }; + + let recognised = false; + if (ts.isRegularExpressionLiteral(node)) { + const raw = node.getText(source); + const close = raw.lastIndexOf("/"); + push(raw.slice(1, close), raw.slice(close + 1), "literal"); + recognised = true; + } else if (ts.isNewExpression(node) || ts.isCallExpression(node)) { + const args = node.arguments ?? []; + if (args.length > 0 && calleeNamesRegExp(node.expression, inner, source)) { + const patternArg = args[0]; + if (patternArg !== undefined) { + const flagsArg = args[1]; + push( + foldStringExpression(patternArg, inner, source), + flagsArg === undefined ? "" : foldStringExpression(flagsArg, inner, source), + "constructor", + ); + recognised = true; + } + } else if (!insideConstruction && args.some((arg) => refersToRegExp(arg, inner, source))) { + // The built-in is named as an argument, e.g. `Reflect.construct(RegExp, [pattern])`. + for (const value of foldedDigestArguments(args, inner, source)) push(value, null, "argument"); + } + } + + ts.forEachChild(node, (child) => walk(child, inner, recognised)); + })(source, root, false); + return found; +} + + +function importersInText(text: string, file: string): string[] { + const names: string[] = []; + for (const statement of parse(text, file).statements) { + if (!ts.isImportDeclaration(statement) || !statement.moduleSpecifier) continue; + const specifier = statement.moduleSpecifier.getText().slice(1, -1); + if (!specifier.endsWith("content_digest.ts")) continue; + const bindings = statement.importClause?.namedBindings; + if (bindings === undefined || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) names.push(element.name.text); + } + return names; +} + +let packageCache: { file: string; text: string }[] | null = null; + +function packageSources(): { file: string; text: string }[] { + if (packageCache === null) { + packageCache = packageFiles(PACKAGE_ROOT).map((file) => ({ + file, + text: readFileSync(join(PACKAGE_ROOT, file), "utf8"), + })); + } + return packageCache; +} + +function packageMatchers(): Matcher[] { + return packageSources().flatMap(({ file, text }) => matchersInText(text, file)); +} + +function describe(site: Matcher): string { + return `${site.file}:${site.line} ${site.spelling.slice(0, 60)}`; +} + +test("no second whole-value digest matcher is reachable in the static model, outside recorded exceptions", () => { + const offenders = packageMatchers().filter( + (site) => + site.envelope !== null && + site.file !== OWNER_FILE && + !(site.file in RECORDED_EXCEPTIONS), + ); + assert.deepEqual(offenders.map(describe), []); +}); + +test("restating the shape is the same violation in every form the constructor is reached by", () => { + // The bypass that made the first two versions of this guard unable to keep their + // promise: a consumer re-derives the shape without writing it as one literal, so + // behaviour is unchanged and a source-text scan sees nothing. The model here is the + // folded pattern value, so the spelling no longer decides the verdict. + const bypasses: [string, string][] = [ + ["regex literal", "const CHECK = /^[0-9a-f]{64}$/;\n"], + [ + "constructed from a string", + 'export function check(value: string): boolean {\n return new RegExp("^[0-9a-f]{64}$").test(value);\n}\n', + ], + ["single-quoted construction", "const CHECK = new RegExp('^[a-f0-9]{64}$');\n"], + ["template construction", "const CHECK = new RegExp(`^sha256:[0-9a-f]{64}$`);\n"], + ["cooked hex escapes", 'const CHECK = new RegExp("^[\\x30-\\x39a-f]{64}$");\n'], + [ + "cooked escapes split across constants", + 'const HEAD = "^[\\x30-";\nconst TAIL = "\\x39a-f]{64}$";\nconst CHECK = new RegExp(HEAD + TAIL);\n', + ], + [ + "two constant halves", + 'const HEAD = "^[0-9";\nconst TAIL = "a-f]{64}$";\nconst CHECK = new RegExp(HEAD + TAIL);\n', + ], + [ + "identifier holding the whole pattern", + 'const BODY = "^[0-9a-f]{64}$";\nconst CHECK = new RegExp(BODY);\n', + ], + [ + "parenthesised halves in a call", + 'const CHECK = new RegExp(("^" + "[0-9a-f]" + "{64}$"));\n', + ], + [ + "local const inside a function", + "function check(value: string): boolean {\n const local = /^[a-f0-9]{64}$/u;\n return local.test(value);\n}\n", + ], + ["flags assembled from a constant", 'const FLAGS = "u";\nconst CHECK = new RegExp("^[0-9a-f]{64}$", FLAGS);\n'], + ["union envelope restated", "const CHECK = /^(?:sha256:)?[0-9a-f]{64}$/;\n"], + [ + "pattern a call cannot fold, but its class is visible", + 'const CHECK = new RegExp(compile("^[0-9a-f]{64}$"));\n', + ], + [ + // The third review round's bypass: a *later* function declares a local of the same name, + // and a file-wide name-keyed binding table lets it overwrite the digest constant, so the + // earlier `new RegExp` folds to the wrong value. Binding is resolved through scopes. + "same-named local in a later function", + "function first(value: string): boolean {\n" + + ' const PRIVATE_PATTERN = "^[0-9a-f]{64}$";\n' + + " return new RegExp(PRIVATE_PATTERN).test(value);\n" + + "}\n" + + "function second(value: string): boolean {\n" + + ' const PRIVATE_PATTERN = "^unrelated$";\n' + + " return new RegExp(PRIVATE_PATTERN).test(value);\n" + + "}\n", + ], + [ + "same-named local in an earlier function", + "function first(value: string): boolean {\n" + + ' const PRIVATE_PATTERN = "^unrelated$";\n' + + " return new RegExp(PRIVATE_PATTERN).test(value);\n" + + "}\n" + + "function second(value: string): boolean {\n" + + ' const PRIVATE_PATTERN = "^[0-9a-f]{64}$";\n' + + " return new RegExp(PRIVATE_PATTERN).test(value);\n" + + "}\n", + ], + [ + "digest const in a nested block, unrelated const of the same name outside it", + 'const SHAPE = "^unrelated$";\nif (true) {\n const SHAPE = "^[0-9a-f]{64}$";\n new RegExp(SHAPE);\n}\nnew RegExp(SHAPE);\n', + ], + [ + // The fourth review round's bypass: the constructor is reached through `globalThis`, so the + // callee is a property access and an identifier-only check never enters the fold at all. + "global receiver construction", + 'export function check(value: string): boolean {\n return new globalThis.RegExp("^[0-9a-f]{64}$").test(value);\n}\n', + ], + ["global receiver without `new`", 'const CHECK = globalThis.RegExp("^[a-f0-9]{64}$");\n'], + ["computed global receiver", 'const CHECK = globalThis["RegExp"]("^sha256:[0-9a-f]{64}$");\n'], + ["another global spelling", 'const CHECK = new window.RegExp("^[0-9a-f]{64}$");\n'], + ["const alias of the built-in", 'const MAKE = RegExp;\nconst CHECK = new MAKE("^[0-9a-f]{64}$");\n'], + ["const alias of the global receiver", 'const MAKE = globalThis.RegExp;\nconst CHECK = new MAKE("^[a-f0-9]{64}$");\n'], + [ + "split halves through the global receiver", + 'const HEAD = "^[0-9";\nconst TAIL = "a-f]{64}$";\nconst CHECK = new globalThis.RegExp(HEAD + TAIL);\n', + ], + ["built-in named as an argument", 'const CHECK = Reflect.construct(RegExp, ["^[0-9a-f]{64}$"]);\n'], + [ + "built-in with a const-bound argument array", + 'const ARGS = ["^[0-9a-f]{64}$"];\nconst CHECK = Reflect.construct(RegExp, ARGS);\n', + ], + ["Function.prototype hop", 'const CHECK = RegExp.call(null, "^[0-9a-f]{64}$");\n'], + [ + "alias of an alias of the built-in", + "const A = RegExp;\nconst B = A;\nconst CHECK = new B(\"^[0-9a-f]{64}$\");\n", + ], + [ + "function reads a later module const alias", + 'function check(value: string): boolean {\n return new LATE("^[0-9a-f]{64}$").test(value);\n}\nconst LATE = RegExp;\n', + ], + [ + "alias taken through RegExp.bind", + "const MAKE = RegExp.bind(null);\nconst CHECK = new MAKE(\"^[0-9a-f]{64}$\");\n", + ], + [ + "renamed destructure from the global object", + "const { RegExp: MAKE } = globalThis;\nconst CHECK = new MAKE(\"^[0-9a-f]{64}$\");\n", + ], + [ + "shorthand destructure from the global object", + "const { RegExp } = globalThis;\nconst CHECK = new RegExp(\"^[0-9a-f]{64}$\");\n", + ], + [ + "Function.prototype hop on the built-in", + "const CHECK = RegExp.apply(null, [\"^[0-9a-f]{64}$\"]);\n", + ], + [ + "method body with a same-named parameter", + "class Holder {\n" + + " check(pattern: string): boolean {\n" + + ' const PRIVATE = "^[0-9a-f]{64}$";\n' + + " return new RegExp(PRIVATE).test(pattern);\n" + + " }\n" + + "}\n", + ], + ]; + for (const [name, source] of bypasses) { + const caught = matchersInText(source, "synthetic.ts").filter((site) => site.envelope !== null); + assert.equal(caught.length, 1, `${name} escaped the scan: ${JSON.stringify(source)}`); + } +}); + +test("real delegation consumer mutations cannot restore a private bare matcher", () => { + const file = "control_plane/collaboration/delegation.ts"; + const original = readFileSync(join(PACKAGE_ROOT, file), "utf8"); + const call = "BARE_SHA256_PATTERN.test(cursor)"; + assert.ok(original.includes(call), "delegation cursor no longer uses the canonical bare matcher"); + const mutations: [string, string, string][] = [ + [ + "cooked escape", + 'new RegExp("^[\\x30-\\x39a-f]{64}$").test(cursor)', + "", + ], + [ + "const-bound Reflect.construct arguments", + "Reflect.construct(RegExp, reviewOnlyMatcherArgs).test(cursor)", + '\nconst reviewOnlyMatcherArgs = ["^[0-9a-f]{64}$"];\n', + ], + [ + "later module const constructor alias", + 'new LATE_DIGEST_MATCHER("^[0-9a-f]{64}$").test(cursor)', + "\nconst LATE_DIGEST_MATCHER = RegExp;\n", + ], + ]; + for (const [name, replacement, suffix] of mutations) { + const source = original.replace(call, replacement) + suffix; + const offenders = matchersInText(source, file).filter((site) => site.envelope === "bare"); + assert.equal(offenders.length, 1, `${name} escaped the real consumer scan: ${JSON.stringify(offenders)}`); + } +}); + +test("a binding that cannot be trusted fails closed instead of folding to a neighbour's value", () => { + // These four are not digest findings: the value is genuinely not a compile-time constant here. + // What matters is that the site is reported as unfoldable, because an unfoldable site has to be + // declared in the inventory - so a name that shadows, mutates or repeats cannot hide a matcher by + // resolving to some other binding with the same spelling. + const ambiguous: [string, string][] = [ + [ + "parameter shadows the constant", + 'const DIGEST = "^[0-9a-f]{64}$";\nfunction check(DIGEST: string): boolean {\n return new RegExp(DIGEST).test("");\n}\n', + ], + [ + "mutable binding written later", + 'let DIGEST = "^[a-f0-9]{64}$";\nDIGEST = "^unrelated$";\nconst CHECK = new RegExp(DIGEST);\n', + ], + [ + "two declarations of one name in the same scope", + 'const DIGEST = "^[0-9a-f]{64}$";\nconst DIGEST = "^unrelated$";\nconst CHECK = new RegExp(DIGEST);\n', + ], + ["name bound to another module", 'import { DIGEST } from "./elsewhere.ts";\nconst CHECK = new RegExp(DIGEST);\n'], + ["pattern reached through an array element", 'const PARTS = ["^[0-9a-f]{64}$"];\nconst CHECK = new RegExp(PARTS[0]);\n'], + ["pattern reached through an object property", 'const SPEC = { p: "^[0-9a-f]{64}$" };\nconst CHECK = new RegExp(SPEC.p);\n'], + ]; + for (const [name, source] of ambiguous) { + const sites = matchersInText(source, "synthetic.ts").filter((site) => site.spelling.startsWith("new RegExp")); + assert.equal(sites.length, 1, `${name} produced ${sites.length} sites: ${JSON.stringify(source)}`); + assert.equal(sites[0]?.pattern, null, `${name} folded to a value it cannot vouch for`); + } +}); + +test("a matcher that answers a different question is not a restatement", () => { + // Widening this net would turn the guard into a rule against hex64 anywhere. These + // are the grammars the PR deliberately leaves with their own owners. + const outOfScope: [string, string][] = [ + ["entry id inside a larger grammar", "const ENTRY = /^local-shadow-tx-[0-9a-f]{64}$/;\n"], + ["compound drain cursor", "const CURSOR = /^1:[0-9a-f]{64}:[0-9a-f]{64}$/;\n"], + ["file name suffix", 'const FILE = new RegExp("^prq_[0-9a-f]{64}\\\\.json$");\n'], + ["git oid alternation", "const OID = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;\n"], + ["unanchored search", "const SEARCH = /[0-9a-f]{64}/;\n"], + ["accepts uppercase, so a different policy", "const UPPER = /^[0-9a-fA-F]{64}$/;\n"], + ["shorter digest", "const ID = /^[0-9a-f]{32}$/;\n"], + [ + "a digest-looking string in an unrelated call is not a matcher", + 'throw new Error("expected ^[0-9a-f]{64}$");\n', + ], + [ + "an unrelated constructor taking a string", + 'const CACHE = new Store("^sha256:[0-9a-f]{64}$");\n', + ], + [ + "constructor assembled from a code string (out of the static model)", + "const MAKE = Function(\"return new RegExp('^[0-9a-f]{64}$')\");\nMAKE();\n", + ], + [ + "constructor reached through a runtime value (out of the static model)", + 'const MAKE = pick();\nconst CHECK = new MAKE("^[0-9a-f]{64}$");\n', + ], + ]; + for (const [name, source] of outOfScope) { + const caught = matchersInText(source, "synthetic.ts").filter((site) => site.envelope !== null); + assert.deepEqual(caught.map(describe), [], `${name} was wrongly counted as a restatement`); + } +}); + +test("the owner module states each envelope exactly once", () => { + const sites = matchersInText(readFileSync(join(PACKAGE_ROOT, OWNER_FILE), "utf8"), OWNER_FILE); + assert.deepEqual( + sites.map((site) => site.envelope), + ["enveloped", "bare"], + JSON.stringify(sites.map(describe)), + ); + assert.deepEqual([...new Set(sites.map((site) => site.flags))], [""], "the owner carries a stray flag"); +}); + +test("a recorded exception is still the reason it was recorded", () => { + for (const file of Object.keys(RECORDED_EXCEPTIONS)) { + const sites = packageMatchers().filter( + (site) => site.file === file && site.envelope !== null && site.via !== "argument", + ); + assert.ok(sites.length > 0, `${file} no longer restates the shape; drop the exception`); + assert.ok( + sites.every((site) => site.flags !== null && site.flags.length > 0), + `${file} lost its per-surface flags; absorb it into the owner instead`, + ); + } +}); + +test("the owner's consumers are the pinned set, each reading a canonical export", () => { + const read = packageSources() + .filter(({ file }) => file !== OWNER_FILE) + .filter(({ file, text }) => importersInText(text, file).length > 0) + .map(({ file, text }) => ({ file, names: importersInText(text, file) })); + + assert.deepEqual( + read.map((row) => row.file), + [...CANONICAL_CONSUMERS].sort(), + "the set of modules reading the owner changed; a new consumer is a review event", + ); + + const source = new Map(packageSources().map(({ file, text }) => [file, text])); + for (const row of read) { + const parsed = parse(source.get(row.file) as string, row.file); + for (const localName of row.names) { + assert.ok( + nameUsage(parsed, localName) > 0, + `${row.file} imports ${localName} but never uses it, so it validates with something else`, + ); + } + } +}); + +test("an import of the owner can only name a canonical export", () => { + for (const { file, text } of packageSources()) { + for (const statement of parse(text, file).statements) { + if (!ts.isImportDeclaration(statement) || !statement.moduleSpecifier) continue; + if (!statement.moduleSpecifier.getText().slice(1, -1).endsWith("content_digest.ts")) continue; + const bindings = statement.importClause?.namedBindings; + assert.ok(bindings !== undefined && ts.isNamedImports(bindings), `${file} uses a default or namespace import`); + for (const element of bindings.elements) { + const imported = (element.propertyName ?? element.name).getText(); + assert.ok( + CANONICAL_EXPORTS.includes(imported), + `${file} imports ${imported} from the owner, which does not export it`, + ); + } + } + } +}); + +test("every recognised construction whose value cannot be folded is declared, and none hides a digest", () => { + const derived = new Map(); + for (const site of packageMatchers()) { + if (site.pattern !== null || site.via !== "constructor") continue; + derived.set(site.file, (derived.get(site.file) ?? 0) + 1); + if (site.envelope === "unresolved-visible") { + assert.fail(`${describe(site)} states a 64-hex class the scan can see; it is a restatement`); + } + } + assert.deepEqual( + [...derived.entries()].sort(), + Object.entries(DECLARED_UNFOLDABLE) + .map(([file, record]) => [file, record.count]) + .sort(), + "a RegExp whose pattern cannot be folded must be declared with the shape it answers", + ); +}); + +test("class order cannot change a verdict", () => { + const first = /^[a-f0-9]{64}$/; + const second = /^[0-9a-f]{64}$/; + for (const probe of ["b".repeat(64), "0123456789abcdef".repeat(4), "B".repeat(64), "b".repeat(63), "g".repeat(64)]) { + assert.equal(first.test(probe), second.test(probe), probe); + assert.equal(second.test(probe), BARE_SHA256_PATTERN.test(probe), probe); + } +}); + +test("dropping the unicode flag cannot change a verdict for this pattern", () => { + const flagged = /^[a-f0-9]{64}$/u; + for (const probe of ["b".repeat(64), "sha256:" + "b".repeat(64), "\u{1D7CF}".repeat(64)]) { + assert.equal(flagged.test(probe), BARE_SHA256_PATTERN.test(probe), probe); + } +}); + +test("the bare envelope rejects the prefixed form and vice versa", () => { + const hex = "a".repeat(64); + assert.ok(BARE_SHA256_PATTERN.test(hex)); + assert.ok(!BARE_SHA256_PATTERN.test(`sha256:${hex}`)); + assert.ok(ENVELOPED_SHA256_PATTERN.test(`sha256:${hex}`)); + assert.ok(!ENVELOPED_SHA256_PATTERN.test(hex)); + for (const bad of ["a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "", `pre-sha256:${hex}`]) { + assert.ok(!ENVELOPED_SHA256_PATTERN.test(bad), bad); + assert.ok(!BARE_SHA256_PATTERN.test(bad), bad); + } +}); + +test("promotion plan digest is read through the owner as a bare digest", () => { + const hex = "b".repeat(64); + assert.equal(promotionPlanDigest(hex), hex); + assert.throws(() => promotionPlanDigest(`sha256:${hex}`), /lowercase SHA-256/); +}); + +test("delegation inventory cursor is a bare digest, not an enveloped one", () => { + const hex = "c".repeat(64); + assert.equal(delegationInventoryQuery({ limit: 5, cursor: hex }).cursor, hex); + assert.throws( + () => delegationInventoryQuery({ limit: 5, cursor: `sha256:${hex}` }), + /invalid delegation inventory cursor/, + ); +}); + +test("collaboration brief input digests must be bare", () => { + const hex = "d".repeat(64); + const brief = (sha: string) => ({ + schema_version: "collaboration_brief_v0", + purpose: "p", + context: "c", + constraints: [], + inputs: [{ ref: "notes/a.md", description: "d", sha256: sha }], + acceptance: ["done when x"], + return_requirement: "r", + }); + assert.ok(normalizeCollaborationBrief(brief(hex))); + assert.throws( + () => normalizeCollaborationBrief(brief(`sha256:${hex}`)), + /input\.sha256 must be a SHA256 digest/, + ); +}); + +test("drain cursor keeps its envelope and its unbound option", () => { + const hex = "e".repeat(64); + const cursor = (digest: string | null) => ({ + schema_version: "loopx_local_authority_shadow_drain_cursor_v0", + partition: "todos", + last_seq: 1, + last_entry_id: `local-shadow-tx-${hex}`, + last_partition_digest: digest, + last_cursor: "c1", + last_provider_revision: "r1", + updated_at: "2026-09-28T00:00:00Z", + }); + assert.equal( + decodeOutboxCursor(cursor(`sha256:${hex}`), "todos").last_partition_digest, + `sha256:${hex}`, + ); + assert.equal(decodeOutboxCursor(cursor(null), "todos").last_partition_digest, null); + assert.throws(() => decodeOutboxCursor(cursor(hex), "todos"), /drain cursor binding/); +}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index ed7151069e..257508d2ad 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -45,6 +45,8 @@ "loopx/control_plane/coordination/nokv_jsonl_transport.ts", "loopx/control_plane/coordination/local_authority_shadow.ts", "loopx/control_plane/coordination/local_authority_shadow_outbox.ts", + "loopx/control_plane/content_digest.ts", + "tests/control_plane_ts/content_digest_single_owner.test.ts", "loopx/control_plane/coordination/postgresql_authority_store.ts", "loopx/control_plane/coordination/postgresql_authority_service.ts", "loopx/control_plane/coordination/sqlite_authority_migration.ts",