From af0d7c615306c1d16563302ea24fabfa4a2b4fe6 Mon Sep 17 00:00:00 2001 From: Moshe Krupper Date: Thu, 9 Jul 2026 11:21:55 +0300 Subject: [PATCH] =?UTF-8?q?refactor(guard):=20one=20runtime=20discriminato?= =?UTF-8?q?r=20for=20Unguarded=20=E2=80=94=20isUnguarded()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unguarded brand becomes a real (module-private) symbol and isUnguarded() its exported type guard, replacing three hand-rolled `'reason' in guardDecl` checks (delivery, router, response-registry) plus the one inside isUnguardedEntry. Only unguarded() can mint the brand now, so a look-alike { reason } object — or a guard spec that someday grows a `reason` field — can't pass as an unguarded declaration at runtime. Co-Authored-By: Claude Fable 5 --- src/delivery.ts | 6 +++--- src/guard/index.ts | 1 + src/guard/types.ts | 14 ++++++++++++-- src/response-registry.ts | 4 ++-- src/router.ts | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/delivery.ts b/src/delivery.ts index 668028754..c854f35a8 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -20,7 +20,7 @@ import { markDeliveryFailed, migrateDeliveredTable, } from './db/session-db.js'; -import { guard, type GuardedAction, type Unguarded } from './guard/index.js'; +import { guard, isUnguarded, type GuardedAction, type Unguarded } from './guard/index.js'; import { log } from './log.js'; import { normalizeOptions } from './channels/ask-question.js'; import { clearOutbox, openInboundDb, openOutboundDb, readOutboxFiles } from './session-manager.js'; @@ -438,7 +438,7 @@ type DeliveryEntry = const deliveryActions = new Map(); function isUnguardedEntry(entry: DeliveryEntry): entry is Extract { - return 'reason' in entry.guard; + return isUnguarded(entry.guard); } export function registerDeliveryAction(action: string, handler: DeliveryActionHandler, unguardedDecl: Unguarded): void; @@ -455,7 +455,7 @@ export function registerDeliveryAction( // skill that wants to extend a guarded action must compose at the // module's exported functions instead, or re-register with a guard spec // of its own. - if ('reason' in guardDecl && !isUnguardedEntry(existing)) { + if (isUnguarded(guardDecl) && !isUnguardedEntry(existing)) { throw new Error( `delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`, ); diff --git a/src/guard/index.ts b/src/guard/index.ts index 4d58ccaf9..891c3a72a 100644 --- a/src/guard/index.ts +++ b/src/guard/index.ts @@ -20,6 +20,7 @@ export { ALLOW, DENY, HOLD, + isUnguarded, unguarded, type GuardActor, type GuardDecision, diff --git a/src/guard/types.ts b/src/guard/types.ts index 960ed353a..93499a547 100644 --- a/src/guard/types.ts +++ b/src/guard/types.ts @@ -31,7 +31,7 @@ export interface GuardInput { grant?: PendingApproval | null; } -declare const unguardedBrand: unique symbol; +const unguardedBrand = Symbol('unguarded'); /** * A registration that deliberately carries no guard. Omission is not * representable — every registry requires either a guard spec or this @@ -42,7 +42,17 @@ declare const unguardedBrand: unique symbol; export type Unguarded = { readonly reason: string; readonly [unguardedBrand]: true }; export function unguarded(reason: string): Unguarded { - return Object.freeze({ reason }) as Unguarded; + return Object.freeze({ reason, [unguardedBrand]: true as const }); +} + +/** + * The one runtime discriminator for guard declarations. The brand symbol is + * module-private, so `unguarded()` is the only mint — a look-alike + * `{ reason }` object (or a guard spec that someday grows a `reason` field) + * doesn't pass. + */ +export function isUnguarded(decl: object): decl is Unguarded { + return unguardedBrand in decl; } export type GuardDecision = diff --git a/src/response-registry.ts b/src/response-registry.ts index bb7cd8c21..59c8ba884 100644 --- a/src/response-registry.ts +++ b/src/response-registry.ts @@ -20,7 +20,7 @@ * unguarded must declare so with `unguarded()`, at the registration * site, in the diff that adds it. */ -import { guard, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; +import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { log } from './log.js'; export interface ResponsePayload { @@ -50,7 +50,7 @@ function responseActor(payload: ResponsePayload): GuardActor { } export function registerResponseHandler(handler: ResponseHandler, guardDecl: ResponseGuardSpec | Unguarded): void { - if ('reason' in guardDecl) { + if (isUnguarded(guardDecl)) { // Explicitly declared unguarded — the carried reason is the reviewable record. responseHandlers.push(handler); return; diff --git a/src/router.ts b/src/router.ts index dbca8c218..6b7e33ffa 100644 --- a/src/router.ts +++ b/src/router.ts @@ -20,7 +20,7 @@ import { getChannelAdapter } from './channels/channel-registry.js'; import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; -import { guard, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; +import { guard, isUnguarded, type GuardActor, type GuardedAction, type Unguarded } from './guard/index.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { createMessagingGroup, @@ -145,7 +145,7 @@ export function registerMessageInterceptor( fn: MessageInterceptorFn, guardDecl: InterceptorGuardSpec | Unguarded, ): void { - if ('reason' in guardDecl) { + if (isUnguarded(guardDecl)) { // Explicitly declared unguarded — the carried reason is the reviewable record. messageInterceptors.push(fn); return;