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;