From 549a717f8ee59a9a355165e6a941307946ebef74 Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:19:58 +0800 Subject: [PATCH 1/9] refactor(control-plane): one owner for the stored digest shape in TypeScript Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../agents/supervisor_event_append.ts | 3 +- .../capabilities/external_evidence.ts | 3 +- .../control_plane/collaboration/delegation.ts | 9 +- .../collaboration/return_delivery.ts | 3 +- .../collaboration/semantic_request.ts | 3 +- loopx/control_plane/content_digest.ts | 3 + .../coordination/authority_archive_read.ts | 3 +- .../coordination/authority_source.ts | 3 +- .../coordination/local_authority_migration.ts | 3 +- .../coordination/local_authority_shadow.ts | 3 +- .../local_authority_shadow_outbox.ts | 3 +- .../coordination/reviewed_promotion_plan.ts | 3 +- .../coordination/runtime_shadow.ts | 3 +- .../coordination/shadow_drain_files.ts | 3 +- .../coordination/shadow_entry_delivery.ts | 5 +- .../coordination/shadow_management.ts | 3 +- .../coordination/shadow_registry_source.ts | 3 +- .../coordination/source_transfer.ts | 3 +- .../coordination/sqlite_authority_store.ts | 3 +- .../coordination/todo_terminal_lifecycle.ts | 9 +- .../coordination/todo_update_intent.ts | 3 +- .../control_plane/effect_runtime_snapshot.ts | 3 +- .../goals/acceptance_authority.ts | 3 +- .../goals/acceptance_contract.ts | 5 +- .../goals/goal_amendment_proposal.ts | 3 +- loopx/control_plane/goals/operator_actions.ts | 3 +- .../goals/shared_goal_alignment.ts | 5 +- .../goals/source_session_lifetime.ts | 3 +- loopx/control_plane/governed_capability.ts | 3 +- .../quota/refresh_external_delivery.ts | 5 +- .../runtime/usage_statistics_cycles.ts | 5 +- .../runtime/usage_statistics_goal_contract.ts | 3 +- .../runtime/usage_statistics_goals.ts | 3 +- .../todos/completion_transaction.ts | 3 +- .../todos/completion_validation_revision.ts | 3 +- .../turn_driver/chat_turn_acceptance.ts | 3 +- .../work_items/pending_capability_intent.ts | 3 +- .../work_items/replan_history_snapshot.ts | 3 +- .../work_items/task_lease_acquire.ts | 3 +- .../work_items/task_lease_lifecycle.ts | 11 +- .../task_lease_lifecycle_request.ts | 3 +- .../content_digest_single_owner.test.ts | 164 ++++++++++++++++++ 42 files changed, 262 insertions(+), 55 deletions(-) create mode 100644 loopx/control_plane/content_digest.ts create mode 100644 tests/control_plane_ts/content_digest_single_owner.test.ts 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.", +}; + +function tsFiles(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(...tsFiles(join(dir, entry.name), rel)); + else if (entry.name.endsWith(".ts")) found.push(rel); + } + return found; +} + +function wholeValueSites(): { file: string; line: number; literal: string; flags: string }[] { + const rows: { file: string; line: number; literal: string; flags: string }[] = []; + for (const file of tsFiles(PACKAGE_ROOT)) { + if (file.endsWith(".generated.ts")) continue; + const text = readFileSync(join(PACKAGE_ROOT, file), "utf8"); + const lines = text.split("\n"); + lines.forEach((line, index) => { + for (const match of line.matchAll(WHOLE_VALUE)) { + const body = match[0].slice(1, match[0].length - 1 - (match[1] ?? "").length); + const classMatch = /\[([0-9a-f-]{6})\]/.exec(body); + if (!classMatch || !HEX_CLASSES.has(classMatch[1])) continue; + rows.push({ file, line: index + 1, literal: match[0], flags: match[1] ?? "" }); + } + }); + } + return rows; +} + +test("the whole-value digest shape is stated once, outside recorded exceptions", () => { + const offenders = wholeValueSites().filter( + (row) => row.file !== OWNER_FILE && !(row.file in RECORDED_EXCEPTIONS), + ); + assert.deepEqual( + offenders.map((row) => `${row.file}:${row.line} ${row.literal}`), + [], + ); +}); + +test("the owner module states each envelope exactly once", () => { + const owner = wholeValueSites().filter((row) => row.file === OWNER_FILE); + assert.equal(owner.length, 2); + assert.equal( + owner.filter((row) => row.literal.includes("sha256:")).length, + 1, + ); + assert.deepEqual([...new Set(owner.map((row) => row.flags))], [""]); +}); + +test("a recorded exception is still the reason it was recorded", () => { + for (const file of Object.keys(RECORDED_EXCEPTIONS)) { + const rows = wholeValueSites().filter((row) => row.file === file); + assert.ok(rows.length > 0, `${file} no longer restates the shape; drop the exception`); + assert.ok( + rows.every((row) => row.flags.length > 0), + `${file} lost its per-surface flags; absorb it into the owner instead`, + ); + } +}); + +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), "z".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; + const probes = ["b".repeat(64), "sha256:" + "b".repeat(64), "𝟏".repeat(64), "b".repeat(64) + "𝟏"]; + for (const probe of probes) 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.deepEqual( + 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 is invalid/); +}); From b8aa735e8a26d8472d7a5de821bc1aaac2d5d944 Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:22:38 +0800 Subject: [PATCH 2/9] fix(control-plane): make the digest ownership scan see constructed matchers too Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 110 ++++++++++++------ 1 file changed, 73 insertions(+), 37 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index b193697908..883ddef49d 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -14,14 +14,18 @@ import { decodeOutboxCursor } from "../../loopx/control_plane/coordination/local const PACKAGE_ROOT = new URL("../../loopx", import.meta.url).pathname; const OWNER_FILE = "control_plane/content_digest.ts"; -const WHOLE_VALUE = /\/\^(?:sha256:)?\[[0-9a-f-]{6}\]\{64\}\$\/([a-z]*)/g; -const HEX_CLASSES = new Set(["0-9a-f", "a-f0-9"]); -/** Sites that keep their own literal, each with the reason a reviewer can check. */ +// The same decision is written two ways: a regex literal, and a constructed RegExp over +// an equivalent string. Both are forbidden outside the owner, and the bypass cases below +// are what stop this scan from quietly decaying into a search for one spelling. +const WHOLE_VALUE = /\/\^(?:sha256:)?\[[0-9a-f-]{6}\]\{64\}\$\/(?[a-z]*)/g; +const CONSTRUCTED = /(?:new\s+)?RegExp\(\s*(?:["'`])(?\^?(?:sha256:)?\[[0-9a-f-]{6}\]\{64\}\$?)(?:["'`])/g; + +/** Sites that keep their own matcher, each with a reason a reviewer can check. */ const RECORDED_EXCEPTIONS: Record = { "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.", + "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.", }; function tsFiles(dir: string, base = ""): string[] { @@ -34,27 +38,35 @@ function tsFiles(dir: string, base = ""): string[] { return found; } -function wholeValueSites(): { file: string; line: number; literal: string; flags: string }[] { - const rows: { file: string; line: number; literal: string; flags: string }[] = []; +type Site = { file?: string; line: number; literal: string; flags: string }; + +function sitesInText(text: string): Site[] { + const rows: Site[] = []; + text.split("\n").forEach((line, index) => { + for (const match of line.matchAll(WHOLE_VALUE)) { + rows.push({ line: index + 1, literal: match[0], flags: match.groups?.flags ?? "" }); + } + for (const match of line.matchAll(CONSTRUCTED)) { + rows.push({ line: index + 1, literal: `new RegExp(${match.groups?.body})`, flags: "" }); + } + }); + return rows; +} + +function wholeValueSites(): Site[] { + const rows: Site[] = []; for (const file of tsFiles(PACKAGE_ROOT)) { if (file.endsWith(".generated.ts")) continue; - const text = readFileSync(join(PACKAGE_ROOT, file), "utf8"); - const lines = text.split("\n"); - lines.forEach((line, index) => { - for (const match of line.matchAll(WHOLE_VALUE)) { - const body = match[0].slice(1, match[0].length - 1 - (match[1] ?? "").length); - const classMatch = /\[([0-9a-f-]{6})\]/.exec(body); - if (!classMatch || !HEX_CLASSES.has(classMatch[1])) continue; - rows.push({ file, line: index + 1, literal: match[0], flags: match[1] ?? "" }); - } - }); + for (const hit of sitesInText(readFileSync(join(PACKAGE_ROOT, file), "utf8"))) { + rows.push({ ...hit, file }); + } } return rows; } test("the whole-value digest shape is stated once, outside recorded exceptions", () => { const offenders = wholeValueSites().filter( - (row) => row.file !== OWNER_FILE && !(row.file in RECORDED_EXCEPTIONS), + (row) => row.file !== OWNER_FILE && !(row.file! in RECORDED_EXCEPTIONS), ); assert.deepEqual( offenders.map((row) => `${row.file}:${row.line} ${row.literal}`), @@ -62,22 +74,45 @@ test("the whole-value digest shape is stated once, outside recorded exceptions", ); }); +test("a constructed private matcher is the same violation as a literal one", () => { + // The bypass that made the first version of this guard unable to keep its promise: + // a consumer re-derives the shape with `new RegExp`, behaviour is unchanged, and a + // literal-only scan sees nothing. + const constructed = + 'export function check(value: string): boolean {\n' + + ' return new RegExp("^[0-9a-f]{64}$").test(value);\n' + + '}\n'; + assert.equal(sitesInText(constructed).length, 1, "new RegExp bypass escaped the scan"); + + const literal = + "export function check(value: string): boolean {\n" + + " return /^[0-9a-f]{64}$/.test(value);\n" + + "}\n"; + assert.equal(sitesInText(literal).length, 1, "literal restatement escaped the scan"); + + const templated = + "export const CHECK = (v: string) => new RegExp(`^sha256:[0-9a-f]{64}$`).test(v);\n"; + assert.equal(sitesInText(templated).length, 1, "template-literal bypass escaped the scan"); + + // A case-insensitive git revision rule is a different policy and stays out of scope + // for the ownership assertion; it is the one recorded exception. + const caseInsensitive = 'const REVISION = /^[0-9a-f]{64}$/i;\n'; + assert.equal(sitesInText(caseInsensitive)[0]?.flags, "i"); +}); + test("the owner module states each envelope exactly once", () => { - const owner = wholeValueSites().filter((row) => row.file === OWNER_FILE); - assert.equal(owner.length, 2); - assert.equal( - owner.filter((row) => row.literal.includes("sha256:")).length, - 1, - ); - assert.deepEqual([...new Set(owner.map((row) => row.flags))], [""]); + const sites = sitesInText(readFileSync(join(PACKAGE_ROOT, OWNER_FILE), "utf8")); + assert.equal(sites.length, 2, JSON.stringify(sites)); + assert.equal(sites.filter((row) => row.literal.includes("sha256:")).length, 1); + assert.deepEqual([...new Set(sites.map((row) => row.flags))], [""]); }); test("a recorded exception is still the reason it was recorded", () => { for (const file of Object.keys(RECORDED_EXCEPTIONS)) { - const rows = wholeValueSites().filter((row) => row.file === file); - assert.ok(rows.length > 0, `${file} no longer restates the shape; drop the exception`); + const sites = sitesInText(readFileSync(join(PACKAGE_ROOT, file), "utf8")); + assert.ok(sites.length > 0, `${file} no longer restates the shape; drop the exception`); assert.ok( - rows.every((row) => row.flags.length > 0), + sites.every((row) => row.flags.length > 0), `${file} lost its per-surface flags; absorb it into the owner instead`, ); } @@ -86,7 +121,7 @@ test("a recorded exception is still the reason it was recorded", () => { 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), "z".repeat(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); } @@ -94,8 +129,9 @@ test("class order cannot change a verdict", () => { test("dropping the unicode flag cannot change a verdict for this pattern", () => { const flagged = /^[a-f0-9]{64}$/u; - const probes = ["b".repeat(64), "sha256:" + "b".repeat(64), "𝟏".repeat(64), "b".repeat(64) + "𝟏"]; - for (const probe of probes) assert.equal(flagged.test(probe), BARE_SHA256_PATTERN.test(probe), probe); + 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", () => { @@ -118,10 +154,7 @@ test("promotion plan digest is read through the owner as a bare digest", () => { test("delegation inventory cursor is a bare digest, not an enveloped one", () => { const hex = "c".repeat(64); - assert.deepEqual( - delegationInventoryQuery({ limit: 5, cursor: hex }).cursor, - hex, - ); + assert.equal(delegationInventoryQuery({ limit: 5, cursor: hex }).cursor, hex); assert.throws( () => delegationInventoryQuery({ limit: 5, cursor: `sha256:${hex}` }), /invalid delegation inventory cursor/, @@ -158,7 +191,10 @@ test("drain cursor keeps its envelope and its unbound option", () => { 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(`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 is invalid/); + assert.throws(() => decodeOutboxCursor(cursor(hex), "todos"), /drain cursor binding/); }); From 614eac4256868ddc0bbceb4074a49f2ef7c06011 Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:42:15 +0800 Subject: [PATCH 3/9] test(control-plane): gate digest ownership by folded pattern value, not spelling The guard recognised only the source spellings it listed, so a consumer could restore a private digest matcher with concatenated constants and stay green. It now parses each module, folds the pattern expression to a value across +, identifier bindings and quoting styles, and compares the admitted character set instead of the text. RegExp constructions whose value cannot be folded have to be declared with the shape they answer, and every consumer must still reference the canonical export it imports. Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 469 +++++++++++++++--- tsconfig.control-plane.json | 2 + 2 files changed, 408 insertions(+), 63 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 883ddef49d..0dcc7df742 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; +import ts from "typescript"; import { BARE_SHA256_PATTERN, @@ -14,12 +15,28 @@ import { decodeOutboxCursor } from "../../loopx/control_plane/coordination/local const PACKAGE_ROOT = new URL("../../loopx", import.meta.url).pathname; const OWNER_FILE = "control_plane/content_digest.ts"; +const CANONICAL_EXPORTS = ["BARE_SHA256_PATTERN", "ENVELOPED_SHA256_PATTERN"]; +const HEX_CHARS = [..."0123456789abcdef"]; -// The same decision is written two ways: a regex literal, and a constructed RegExp over -// an equivalent string. Both are forbidden outside the owner, and the bypass cases below -// are what stop this scan from quietly decaying into a search for one spelling. -const WHOLE_VALUE = /\/\^(?:sha256:)?\[[0-9a-f-]{6}\]\{64\}\$\/(?[a-z]*)/g; -const CONSTRUCTED = /(?:new\s+)?RegExp\(\s*(?:["'`])(?\^?(?:sha256:)?\[[0-9a-f-]{6}\]\{64\}\$?)(?:["'`])/g; +/** + * A construction whose pattern cannot be folded to a value still shows its character + * class in source, so it is caught by sight rather than by value. + */ +const VISIBLE_HEX64_CLASS = /\[[0-9a-f-]{6}\]\{64\}/; + +type Envelope = "bare" | "enveloped" | "union"; + +/** One RegExp created in source, however it was spelled or assembled. */ +type Matcher = { + file: string; + line: number; + spelling: string; + /** Null when the flags expression could not be folded to a value. */ + flags: string | null; + /** Null when the pattern expression could not be folded to a value. */ + pattern: string | null; + envelope: Envelope | "unresolved-visible" | null; +}; /** Sites that keep their own matcher, each with a reason a reviewer can check. */ const RECORDED_EXCEPTIONS: Record = { @@ -28,96 +45,422 @@ const RECORDED_EXCEPTIONS: Record = { "in either case, so this surface is a different policy, not a stale copy.", }; -function tsFiles(dir: string, base = ""): string[] { +/** + * 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(...tsFiles(join(dir, entry.name), rel)); - else if (entry.name.endsWith(".ts")) found.push(rel); + 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; + return found.sort(); } -type Site = { file?: string; line: number; literal: string; flags: string }; - -function sitesInText(text: string): Site[] { - const rows: Site[] = []; - text.split("\n").forEach((line, index) => { - for (const match of line.matchAll(WHOLE_VALUE)) { - rows.push({ line: index + 1, literal: match[0], flags: match.groups?.flags ?? "" }); +/** 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; } - for (const match of line.matchAll(CONSTRUCTED)) { - rows.push({ line: index + 1, literal: `new RegExp(${match.groups?.body})`, flags: "" }); + 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; } - }); - return rows; + 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; +} + +/** Fold an expression to the string it denotes, across `+`, const names and quoting. */ +function foldStringExpression( + expr: ts.Expression, + bindings: Map, + source: ts.SourceFile, + depth = 0, +): string | null { + if (depth > 8) return null; + if (ts.isParenthesizedExpression(expr)) { + return foldStringExpression(expr.expression, bindings, source, depth + 1); + } + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { + return expr.getText(source).slice(1, -1); + } + if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = foldStringExpression(expr.left, bindings, source, depth + 1); + const right = foldStringExpression(expr.right, bindings, source, depth + 1); + return left === null || right === null ? null : left + right; + } + if (ts.isIdentifier(expr)) { + const bound = bindings.get(expr.text); + return bound === undefined ? null : foldStringExpression(bound, bindings, 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 wholeValueSites(): Site[] { - const rows: Site[] = []; - for (const file of tsFiles(PACKAGE_ROOT)) { - if (file.endsWith(".generated.ts")) continue; - for (const hit of sitesInText(readFileSync(join(PACKAGE_ROOT, file), "utf8"))) { - rows.push({ ...hit, file }); +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 RegExp created in this source: literal, `new RegExp(...)` or `RegExp(...)`. */ +function matchersInText(text: string, file: string): Matcher[] { + const source = parse(text, file); + const bindings = new Map(); + (function collect(node: ts.Node): void { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + bindings.set(node.name.text, node.initializer); + } + ts.forEachChild(node, collect); + })(source); + + const found: Matcher[] = []; + const push = (node: ts.Node, pattern: string | null, flags: string | null): 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, + }); + }; + + (function visit(node: ts.Node): void { + if (ts.isRegularExpressionLiteral(node)) { + const raw = node.getText(source); + const close = raw.lastIndexOf("/"); + push(node, raw.slice(1, close), raw.slice(close + 1)); + } else if ( + (ts.isNewExpression(node) || ts.isCallExpression(node)) && + ts.isIdentifier(node.expression) && + node.expression.text === "RegExp" + ) { + const args = node.arguments ?? []; + const patternArg = args[0]; + if (patternArg !== undefined) { + const flagsArg = args[1]; + push( + node, + foldStringExpression(patternArg, bindings, source), + flagsArg === undefined ? "" : foldStringExpression(flagsArg, bindings, source), + ); + } } + ts.forEachChild(node, visit); + })(source); + 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 rows; + 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("the whole-value digest shape is stated once, outside recorded exceptions", () => { - const offenders = wholeValueSites().filter( - (row) => row.file !== OWNER_FILE && !(row.file! in RECORDED_EXCEPTIONS), - ); - assert.deepEqual( - offenders.map((row) => `${row.file}:${row.line} ${row.literal}`), - [], + const offenders = packageMatchers().filter( + (site) => + site.envelope !== null && + site.file !== OWNER_FILE && + !(site.file in RECORDED_EXCEPTIONS), ); + assert.deepEqual(offenders.map(describe), []); }); -test("a constructed private matcher is the same violation as a literal one", () => { - // The bypass that made the first version of this guard unable to keep its promise: - // a consumer re-derives the shape with `new RegExp`, behaviour is unchanged, and a - // literal-only scan sees nothing. - const constructed = - 'export function check(value: string): boolean {\n' + - ' return new RegExp("^[0-9a-f]{64}$").test(value);\n' + - '}\n'; - assert.equal(sitesInText(constructed).length, 1, "new RegExp bypass escaped the scan"); - - const literal = - "export function check(value: string): boolean {\n" + - " return /^[0-9a-f]{64}$/.test(value);\n" + - "}\n"; - assert.equal(sitesInText(literal).length, 1, "literal restatement escaped the scan"); - - const templated = - "export const CHECK = (v: string) => new RegExp(`^sha256:[0-9a-f]{64}$`).test(v);\n"; - assert.equal(sitesInText(templated).length, 1, "template-literal bypass escaped the scan"); - - // A case-insensitive git revision rule is a different policy and stays out of scope - // for the ownership assertion; it is the one recorded exception. - const caseInsensitive = 'const REVISION = /^[0-9a-f]{64}$/i;\n'; - assert.equal(sitesInText(caseInsensitive)[0]?.flags, "i"); +test("restating the shape is the same violation in every spelling", () => { + // 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"], + [ + "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', + ], + ]; + 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("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"], + ]; + 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 = sitesInText(readFileSync(join(PACKAGE_ROOT, OWNER_FILE), "utf8")); - assert.equal(sites.length, 2, JSON.stringify(sites)); - assert.equal(sites.filter((row) => row.literal.includes("sha256:")).length, 1); - assert.deepEqual([...new Set(sites.map((row) => row.flags))], [""]); + 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 = sitesInText(readFileSync(join(PACKAGE_ROOT, file), "utf8")); + const sites = packageMatchers().filter((site) => site.file === file && site.envelope !== null); assert.ok(sites.length > 0, `${file} no longer restates the shape; drop the exception`); assert.ok( - sites.every((row) => row.flags.length > 0), + 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 unfoldable construction is declared, and none of them hides a digest", () => { + const derived = new Map(); + for (const site of packageMatchers()) { + if (site.pattern !== null) 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}$/; diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 4ec2310ab9..47c1161653 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -44,6 +44,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", From d8c77d181be46bb624821792172a1dd0479c930d Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:01:31 +0800 Subject: [PATCH 4/9] chore(registry): refresh the census anchor main moved Main's own commits shifted the `loopx/cli.py::main` registry read from line 846 to 835, which leaves the checked-in manifest stale on b6c3d3d4b: regenerating it there changes exactly that one field, and the two architecture tests that read it fail on an unmodified main tree. Regenerated in place with scripts/generate_project_registry_io_manifest.py; this PR moves no Python line. Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- loopx/semantics/project_registry_io_manifest_v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 05cb12cb81..9ed1c447c8 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -487,7 +487,7 @@ }, { "site": "loopx/cli.py::.main::codec_read:load_project_registry#1", - "line": 846, + "line": 835, "column": 17, "kind": "codec_read", "api": "load_project_registry", From ff6f9923411e285fef1efa674c5dd0ac91f9a00d Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:38:00 +0800 Subject: [PATCH 5/9] test(control-plane): resolve folded bindings by lexical scope The fold looked identifiers up in one file-wide, text-keyed map, so a same-named local declared in a later function overwrote the earlier binding and a private digest matcher folded to someone else's value. Binding now walks the scope chain built during the traversal, and anything ambiguous, mutable, parameter-bound or import-bound fails closed as an unfoldable site that must be declared. Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 241 +++++++++++++++--- 1 file changed, 209 insertions(+), 32 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 0dcc7df742..5d86c87148 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -178,28 +178,119 @@ function digestEnvelope(pattern: string): Envelope | null { return envelope; } -/** Fold an expression to the string it denotes, across `+`, const names and quoting. */ +/** + * One lexical scope. A name maps to the declarations seen for it *before the current point in + * document 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. + */ +type Binding = { init: ts.Expression | null; at: Scope }; +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): void { + const seen = scope.vars.get(name); + if (seen === undefined) scope.vars.set(name, [{ init, at: scope }]); + else seen.push({ init, at: scope }); +} + +/** 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, - bindings: Map, + scope: Scope, source: ts.SourceFile, depth = 0, ): string | null { if (depth > 8) return null; if (ts.isParenthesizedExpression(expr)) { - return foldStringExpression(expr.expression, bindings, source, depth + 1); + return foldStringExpression(expr.expression, scope, source, depth + 1); } if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { return expr.getText(source).slice(1, -1); } if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = foldStringExpression(expr.left, bindings, source, depth + 1); - const right = foldStringExpression(expr.right, bindings, source, depth + 1); + 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 bound = bindings.get(expr.text); - return bound === undefined ? null : foldStringExpression(bound, bindings, source, depth + 1); + 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; } @@ -230,32 +321,52 @@ function isImportSpecifier(node: ts.Node): boolean { /** Every RegExp created in this source: literal, `new RegExp(...)` or `RegExp(...)`. */ function matchersInText(text: string, file: string): Matcher[] { const source = parse(text, file); - const bindings = new Map(); - (function collect(node: ts.Node): void { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { - bindings.set(node.name.text, node.initializer); + const written = assignedNames(source); + const root: Scope = { parent: null, vars: 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); + } + + (function walk(node: ts.Node, scope: Scope): void { + const inner = + node.kind === ts.SyntaxKind.SourceFile || !SCOPE_OPENERS.has(node.kind) + ? scope + : { parent: scope, vars: new Map() }; + + 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)) { + for (const name of boundNames(node.name)) declare(inner, name, null); + } + if (ts.isFunctionLike(node)) { + for (const parameter of node.parameters) { + for (const name of boundNames(parameter.name)) declare(inner, name, null); + } } - ts.forEachChild(node, collect); - })(source); - const found: Matcher[] = []; - const push = (node: ts.Node, pattern: string | null, flags: string | null): 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, - }); - }; + const push = (pattern: string | null, flags: string | null): 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, + }); + }; - (function visit(node: ts.Node): void { if (ts.isRegularExpressionLiteral(node)) { const raw = node.getText(source); const close = raw.lastIndexOf("/"); - push(node, raw.slice(1, close), raw.slice(close + 1)); + push(raw.slice(1, close), raw.slice(close + 1)); } else if ( (ts.isNewExpression(node) || ts.isCallExpression(node)) && ts.isIdentifier(node.expression) && @@ -266,17 +377,18 @@ function matchersInText(text: string, file: string): Matcher[] { if (patternArg !== undefined) { const flagsArg = args[1]; push( - node, - foldStringExpression(patternArg, bindings, source), - flagsArg === undefined ? "" : foldStringExpression(flagsArg, bindings, source), + foldStringExpression(patternArg, inner, source), + flagsArg === undefined ? "" : foldStringExpression(flagsArg, inner, source), ); } } - ts.forEachChild(node, visit); - })(source); + + ts.forEachChild(node, (child) => walk(child, inner)); + })(source, root); return found; } + function importersInText(text: string, file: string): string[] { const names: string[] = []; for (const statement of parse(text, file).statements) { @@ -355,6 +467,44 @@ test("restating the shape is the same violation in every spelling", () => { "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', + ], + [ + "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); @@ -362,6 +512,33 @@ test("restating the shape is the same violation in every spelling", () => { } }); +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'], + ]; + 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. From edf7eeb9cbf77505d1ca587b69c7c55699b54b0c Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:59:41 +0800 Subject: [PATCH 6/9] test(control-plane): recognise RegExp reached through globals, aliases and arguments The constructor check accepted only an identifier spelled `RegExp`, so `new globalThis.RegExp("^[0-9a-f]{64}$")` in a real consumer was neither folded nor counted as an unfoldable site. The callee is now resolved through global receivers (including computed ones), const aliases and Function.prototype hops, and a call that names the built-in in its arguments (`Reflect.construct`) is scanned as well. A digest-shaped string passed to an unrelated call is still not a matcher, so error text cannot trip the gate. Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 154 +++++++++++++++--- 1 file changed, 135 insertions(+), 19 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 5d86c87148..cdc3dc0cf4 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -36,6 +36,12 @@ type Matcher = { /** Null when the pattern expression could not be folded to a value. */ pattern: string | null; envelope: Envelope | "unresolved-visible" | null; + /** + * Which rule produced the site: a regex literal, a recognised construction of the built-in + * `RegExp`, or a digest pattern handed to some other call (`Reflect.construct(RegExp, […])`, + * `RegExp.call(null, …)`). Only `constructor` sites can be unfoldable. + */ + via: "literal" | "constructor" | "argument"; }; /** Sites that keep their own matcher, each with a reason a reviewer can check. */ @@ -319,6 +325,79 @@ function isImportSpecifier(node: ts.Node): boolean { } /** Every RegExp created in this source: literal, `new RegExp(...)` or `RegExp(...)`. */ +/** 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"]); + +/** + * Does this callee denote the built-in `RegExp`? Identifier spelling, a global receiver + * (`globalThis.RegExp`, `globalThis["RegExp"]`), or a const alias bound to one of those. Bounded on + * purpose - no checker and no symbol graph - so a callee is only followed through the same const + * bindings the pattern fold uses. What a call *passes* is folded separately, which is what stops an + * unrecognised callee from becoming an exit. + */ +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); + return found !== null && found.init !== null && calleeNamesRegExp(found.init, found.at, 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 a nested array literal is unfolded element-wise. + */ +function foldedDigestArguments(args: readonly ts.Expression[], scope: Scope, source: ts.SourceFile): string[] { + const values: string[] = []; + for (const arg of args) { + const direct = foldStringExpression(arg, scope, source); + if (direct !== null) { + if (digestEnvelope(direct) !== null) values.push(direct); + continue; + } + if (ts.isArrayLiteralExpression(arg)) { + for (const element of arg.elements) { + if (ts.isSpreadElement(element)) continue; + const nested = foldStringExpression(element, scope, source); + if (nested !== null && digestEnvelope(nested) !== null) values.push(nested); + } + } + } + return values; +} + function matchersInText(text: string, file: string): Matcher[] { const source = parse(text, file); const written = assignedNames(source); @@ -333,7 +412,7 @@ function matchersInText(text: string, file: string): Matcher[] { if (defaultName !== undefined) declare(root, defaultName.text, null); } - (function walk(node: ts.Node, scope: Scope): void { + (function walk(node: ts.Node, scope: Scope, insideConstruction: boolean): void { const inner = node.kind === ts.SyntaxKind.SourceFile || !SCOPE_OPENERS.has(node.kind) ? scope @@ -351,7 +430,7 @@ function matchersInText(text: string, file: string): Matcher[] { } } - const push = (pattern: string | null, flags: string | null): void => { + const push = (pattern: string | null, flags: string | null, via: Matcher["via"]): void => { const spelling = node.getText(source).replace(/\s+/g, " "); found.push({ file, @@ -360,31 +439,37 @@ function matchersInText(text: string, file: string): Matcher[] { 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)); - } else if ( - (ts.isNewExpression(node) || ts.isCallExpression(node)) && - ts.isIdentifier(node.expression) && - node.expression.text === "RegExp" - ) { + push(raw.slice(1, close), raw.slice(close + 1), "literal"); + recognised = true; + } else if (ts.isNewExpression(node) || ts.isCallExpression(node)) { const args = node.arguments ?? []; - const patternArg = args[0]; - if (patternArg !== undefined) { - const flagsArg = args[1]; - push( - foldStringExpression(patternArg, inner, source), - flagsArg === undefined ? "" : foldStringExpression(flagsArg, inner, source), - ); + 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)); - })(source, root); + ts.forEachChild(node, (child) => walk(child, inner, recognised)); + })(source, root, false); return found; } @@ -496,6 +581,23 @@ test("restating the shape is the same violation in every spelling", () => { "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'], + ["Function.prototype hop", 'const CHECK = RegExp.call(null, "^[0-9a-f]{64}$");\n'], [ "method body with a same-named parameter", "class Holder {\n" + @@ -550,6 +652,18 @@ test("a matcher that answers a different question is not a restatement", () => { ["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 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); @@ -569,7 +683,9 @@ test("the owner module states each envelope exactly once", () => { 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); + 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), @@ -623,7 +739,7 @@ test("an import of the owner can only name a canonical export", () => { test("every unfoldable construction is declared, and none of them hides a digest", () => { const derived = new Map(); for (const site of packageMatchers()) { - if (site.pattern !== null) continue; + 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`); From 48ed1d9d97578827f2bcee13801139db34858849 Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:00:11 +0800 Subject: [PATCH 7/9] test(control-plane): state the argument rule the gate actually applies The comment claimed every call's arguments were folded independently, which is wider than the implementation: only a call naming the built-in in an argument is scanned, so a digest-shaped string in unrelated error text stays out. The limit for a constructor that only exists at runtime is now named where it is pinned. Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index cdc3dc0cf4..676c76fff6 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -333,10 +333,14 @@ const FUNCTION_METHODS = new Set(["call", "apply", "bind"]); /** * Does this callee denote the built-in `RegExp`? Identifier spelling, a global receiver - * (`globalThis.RegExp`, `globalThis["RegExp"]`), or a const alias bound to one of those. Bounded on - * purpose - no checker and no symbol graph - so a callee is only followed through the same const - * bindings the pattern fold uses. What a call *passes* is folded separately, which is what stops an - * unrecognised callee from becoming an exit. + * (`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, From b5d98e0b437c18a7d158b220f3acc891df67568c Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:34:59 +0800 Subject: [PATCH 8/9] test(control-plane): close the alias forms the ownership claim already covered An escape probe over the static model found two more ways to denote the built-in that the guard claimed to bind but did not: a renamed destructure from the global object (`const { RegExp: MAKE } = globalThis`) and an alias taken through `RegExp.bind`. Both are now resolved, and the three test names plus the scanner doc were narrowed to say what the gate reaches - patterns assembled at runtime are declared, a constructor that only exists at runtime is out of the model and pinned as such. Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 95 ++++++++++++++++--- 1 file changed, 83 insertions(+), 12 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 676c76fff6..a17a94a021 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -188,9 +188,11 @@ function digestEnvelope(pattern: string): Envelope | null { * One lexical scope. A name maps to the declarations seen for it *before the current point in * document 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. + * 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 }; +type Binding = { init: ts.Expression | null; at: Scope; ctor: boolean }; type Scope = { parent: Scope | null; vars: Map }; const SCOPE_OPENERS = new Set([ @@ -211,10 +213,10 @@ const SCOPE_OPENERS = new Set([ ts.SyntaxKind.SetAccessor, ]); -function declare(scope: Scope, name: string, init: ts.Expression | null): void { +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 }]); - else seen.push({ init, at: scope }); + 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. */ @@ -324,13 +326,44 @@ function isImportSpecifier(node: ts.Node): boolean { return false; } -/** Every RegExp created in this source: literal, `new RegExp(...)` or `RegExp(...)`. */ +/** + * 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, …)`) @@ -353,7 +386,18 @@ function calleeNamesRegExp( if (ts.isIdentifier(expr)) { if (expr.text === "RegExp") return true; const found = binding(scope, expr.text); - return found !== null && found.init !== null && calleeNamesRegExp(found.init, found.at, source, depth + 1); + 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)) { @@ -424,9 +468,10 @@ function matchersInText(text: string, file: string): Matcher[] { 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); + const ctor = foldable && calleeNamesRegExp(node.initializer, inner, source); + declare(inner, node.name.text, foldable ? node.initializer : null, ctor); } else if (ts.isVariableDeclaration(node)) { - for (const name of boundNames(node.name)) declare(inner, name, null); + declarePatternBindings(inner, node, source); } if (ts.isFunctionLike(node)) { for (const parameter of node.parameters) { @@ -511,7 +556,7 @@ function describe(site: Matcher): string { return `${site.file}:${site.line} ${site.spelling.slice(0, 60)}`; } -test("the whole-value digest shape is stated once, outside recorded exceptions", () => { +test("no second whole-value digest matcher is reachable in the static model, outside recorded exceptions", () => { const offenders = packageMatchers().filter( (site) => site.envelope !== null && @@ -521,7 +566,7 @@ test("the whole-value digest shape is stated once, outside recorded exceptions", assert.deepEqual(offenders.map(describe), []); }); -test("restating the shape is the same violation in every spelling", () => { +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 @@ -602,6 +647,26 @@ test("restating the shape is the same violation in every spelling", () => { ], ["built-in named as an argument", 'const CHECK = Reflect.construct(RegExp, ["^[0-9a-f]{64}$"]);\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", + ], + [ + "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" + @@ -637,6 +702,8 @@ test("a binding that cannot be trusted fails closed instead of folding to a neig '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")); @@ -664,6 +731,10 @@ test("a matcher that answers a different question is not a restatement", () => { "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', @@ -740,7 +811,7 @@ test("an import of the owner can only name a canonical export", () => { } }); -test("every unfoldable construction is declared, and none of them hides a digest", () => { +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; From 67d6f13bc02a4b2ec96a016462f999d79c90ae16 Mon Sep 17 00:00:00 2001 From: karenchuu <25980598+karenchuu@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:08:25 +0800 Subject: [PATCH 9/9] test(control-plane): close static digest matcher escapes Signed-off-by: karenchuu <25980598+karenchuu@users.noreply.github.com> --- .../content_digest_single_owner.test.ts | 114 ++++++++++++++---- 1 file changed, 91 insertions(+), 23 deletions(-) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index a17a94a021..a8ea3796ae 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -185,12 +185,12 @@ function digestEnvelope(pattern: string): Envelope | null { } /** - * One lexical scope. A name maps to the declarations seen for it *before the current point in - * document 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. + * 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 }; @@ -286,7 +286,9 @@ function foldStringExpression( return foldStringExpression(expr.expression, scope, source, depth + 1); } if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { - return expr.getText(source).slice(1, -1); + // 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); @@ -425,23 +427,38 @@ function refersToRegExp(expr: ts.Expression, scope: Scope, source: ts.SourceFile /** * A digest pattern reaching a call that is not a recognised construction. `Reflect.construct` - * carries it inside an argument list, so a nested array literal is unfolded element-wise. + * 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) { - const direct = foldStringExpression(arg, scope, source); - if (direct !== null) { - if (digestEnvelope(direct) !== null) values.push(direct); - continue; - } - if (ts.isArrayLiteralExpression(arg)) { - for (const element of arg.elements) { - if (ts.isSpreadElement(element)) continue; - const nested = foldStringExpression(element, scope, source); - if (nested !== null && digestEnvelope(nested) !== null) values.push(nested); - } - } + values.push(...foldedDigestArgument(arg, scope, source)); } return values; } @@ -450,6 +467,7 @@ 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. @@ -460,16 +478,19 @@ function matchersInText(text: string, file: string): Matcher[] { if (defaultName !== undefined) declare(root, defaultName.text, null); } - (function walk(node: ts.Node, scope: Scope, insideConstruction: boolean): void { + // 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); - const ctor = foldable && calleeNamesRegExp(node.initializer, inner, source); - declare(inner, node.name.text, foldable ? node.initializer : null, ctor); + declare(inner, node.name.text, foldable ? node.initializer : null); } else if (ts.isVariableDeclaration(node)) { declarePatternBindings(inner, node, source); } @@ -478,6 +499,11 @@ function matchersInText(text: string, file: string): Matcher[] { 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, " "); @@ -579,6 +605,11 @@ test("restating the shape is the same violation in every form the constructor is ], ["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', @@ -646,11 +677,19 @@ test("restating the shape is the same violation in every form the constructor is '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", @@ -683,6 +722,35 @@ test("restating the shape is the same violation in every form the constructor is } }); +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