Compare commits

...

1 Commits

Author SHA1 Message Date
Moshe Krupper af0d7c6153 refactor(guard): one runtime discriminator for Unguarded — isUnguarded()
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 <noreply@anthropic.com>
2026-07-09 11:21:55 +03:00
5 changed files with 20 additions and 9 deletions
+3 -3
View File
@@ -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<string, DeliveryEntry>();
function isUnguardedEntry(entry: DeliveryEntry): entry is Extract<DeliveryEntry, { guard: Unguarded }> {
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`,
);
+1
View File
@@ -20,6 +20,7 @@ export {
ALLOW,
DENY,
HOLD,
isUnguarded,
unguarded,
type GuardActor,
type GuardDecision,
+12 -2
View File
@@ -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 =
+2 -2
View File
@@ -20,7 +20,7 @@
* unguarded must declare so with `unguarded(<reason>)`, 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;
+2 -2
View File
@@ -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;