@securecoms/agent-bridge 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,11 +43,16 @@ import { type ChatMessage } from "./voice/fast-prompt.js";
43
43
  import { VOICE_CALL_START_MARKER } from "./voice/prompt-compiler.js";
44
44
  import { type VaultListNode, type VaultReadExpectations } from "./vault.js";
45
45
  import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
46
+ import { removeManagedLlmSource, writeManagedFiles, writeManifestPolicy } from "./service/runtime-config-dir.js";
47
+ import { bindRuntimeConfig } from "./grants/binding.js";
46
48
  import type { RuntimeReplyReceipt } from "./reply-receipts.js";
47
49
  export type AgentAudience = "owner_dm" | "team_room" | "partner_room" | "other_human_dm" | "agent_to_agent" | "unknown";
48
- export type BridgeToolGrant = {
50
+ export type BridgeToolGrant =
51
+ /** `source: "manifest"` (owner grants plan 4): issued from an owner-signed manifest; enforced only by a manifest_grants_v1 runtime. */
52
+ {
49
53
  readonly tool: string;
50
54
  readonly mode: "read";
55
+ readonly source?: "manifest";
51
56
  } | {
52
57
  readonly tool: string;
53
58
  readonly mode: "side_effect";
@@ -99,6 +104,8 @@ export declare const ROOM_DEFAULT_READ_GRANTS: readonly BridgeToolGrant[];
99
104
  * construction (daemon startup). Unset or exactly `on` keeps the grants;
100
105
  * any other value (`off`, `0`, `false`, empty) sends `[]` on every turn.
101
106
  */
107
+ /** Audit L-2: the owner replay prune runs at most once per this interval. */
108
+ export declare const OWNER_REPLAY_PRUNE_INTERVAL_MS: number;
102
109
  export declare function ownerDmDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
103
110
  /**
104
111
  * Kill switch for ROOM_DEFAULT_READ_GRANTS (plan OQ-3, RULED), read once at
@@ -107,6 +114,13 @@ export declare function ownerDmDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): bo
107
114
  * room turn. It never touches the owner DM.
108
115
  */
109
116
  export declare function roomDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
117
+ /**
118
+ * Owner-signed tool grants kill switch, owner DM (spec §7.4). Read once at startup; unset or exactly
119
+ * "on" keeps. Off gives the fallback (constants ∩ signed, plus current_time), never more.
120
+ */
121
+ export declare function ownerDmManifestGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
122
+ /** Owner-signed tool grants kill switch, internal rooms (spec §7.4). Same rule as the owner-DM switch. */
123
+ export declare function roomManifestGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
110
124
  export interface BridgeAuthorization {
111
125
  readonly version: 1;
112
126
  readonly audience: AgentAudience;
@@ -316,7 +330,16 @@ export declare const ROOM_VOICE_GUIDANCE_WITH_RESPONDER = "You are in a group vo
316
330
  * `voiceDefaultResponder` (never a body-supplied claim).
317
331
  */
318
332
  export declare function selectVoiceGuidance(isDm: boolean | undefined, voiceDefaultResponder: boolean | undefined): string;
333
+ /** Owner grants plan 4 Task 11: TEST SEAM for the refetch's writers and binder (call order, throws). Production omits it. */
334
+ export interface GrantWriters {
335
+ writeManifestPolicy?: typeof writeManifestPolicy;
336
+ writeManagedFiles?: typeof writeManagedFiles;
337
+ bindRuntimeConfig?: typeof bindRuntimeConfig;
338
+ removeManagedLlmSource?: typeof removeManagedLlmSource;
339
+ }
319
340
  export interface BridgeAgentDeps {
341
+ /** Owner grants plan 4 Task 11: TEST SEAM only (see GrantWriters). */
342
+ grantWriters?: GrantWriters;
320
343
  /**
321
344
  * Unified memory part 1 (plan Task 8): TEST SEAM ONLY. Production (bin.ts) never passes it, so the compiled
322
345
  * UNIFIED_MEMORY_PART1 decides (switch.arch.test.ts pins both).
@@ -330,6 +353,9 @@ export interface BridgeAgentDeps {
330
353
  transport?: any;
331
354
  backend?: any;
332
355
  fetchImpl?: typeof fetch;
356
+ /** TEST SEAM: WebSocket constructor for the real RestTransport's delivery-ping
357
+ * socket. Production leaves it undefined (globalThis.WebSocket). */
358
+ wsImpl?: typeof WebSocket;
333
359
  /**
334
360
  * Round 5 (runtime config delivery): the home directory under which the
335
361
  * managed runtime-config directory lives. Injected only by tests, so they can
@@ -506,6 +532,7 @@ export declare function redeliveryDelayMs(attempts: number): number;
506
532
  * messages is dead-lettered. Fixed text — it never echoes the message.
507
533
  */
508
534
  export declare const DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
535
+ export declare const ROOM_SEARCH_CAP_NOTICE = "I've used today's web searches for this room. I can still help without searching. Searches are available again tomorrow (UTC).";
509
536
  /** At most one {@link DEAD_LETTER_NOTICE} per channel per this window. */
510
537
  export declare const DEAD_LETTER_NOTICE_INTERVAL_MS: number;
511
538
  /**
@@ -690,6 +717,15 @@ export declare class RoomClassChangedSendError extends SendForbiddenError {
690
717
  * `activeGroupId` is the group the backend named, or null when the channel has
691
718
  * no active group (fail closed: nothing to converge to).
692
719
  */
720
+ /**
721
+ * C10 (spec P-15, §4.1.6): start() was superseded (4409) before it finished.
722
+ * start() has already run the memoized close() quiesce when this is thrown, and
723
+ * armed none of its timers or the receive pump. runMode catches it: the process
724
+ * stays alive holding the lock and the port, and serves no route.
725
+ */
726
+ export declare class SessionSupersededError extends Error {
727
+ constructor();
728
+ }
693
729
  export declare class GroupSupersededSendError extends Error {
694
730
  readonly channelId: string;
695
731
  readonly activeGroupId: string | null;
@@ -756,8 +792,39 @@ export declare class BridgeAgent {
756
792
  private readonly backend;
757
793
  private readonly custody;
758
794
  private readonly fetchImpl;
795
+ /** P-1: set once a 4409 close says another copy took this agent's session.
796
+ * The gated fetch built in the constructor refuses every request after it. */
797
+ private sessionSuperseded;
798
+ /** C9: who hears `session_superseded` (runMode's quiesce). */
799
+ private readonly sessionSupersededListeners;
800
+ /** C9 (spec §4.1.6, low 3): the one close() run every caller shares. */
801
+ private closing;
759
802
  /** Round 5: home for the managed runtime-config directory (test seam). */
760
803
  private readonly home;
804
+ private readonly grantWriters;
805
+ /** Owner grants (spec §7.5, OQ-2): the operator cap, read once; the same variable the runtime reads. 0 = no room search. */
806
+ private readonly roomSearchDailyCap;
807
+ /** Review M-3 (ERIC APPROVALS 3): the per-agent total across rooms, read once; the same variable the runtime reads. */
808
+ private readonly agentRoomSearchDailyCap;
809
+ /** `${day}\n${channel}` keys already logged reached today (in memory; the store holds the durable flag). */
810
+ private readonly roomCapLogged;
811
+ /** Rooms whose K-14 send is in flight: a second frame posts nothing more. */
812
+ private readonly roomCapNoticePending;
813
+ /** Held entries whose staged copy lost web_search to the backstop; applied again at release. */
814
+ private readonly backstopStripped;
815
+ /**
816
+ * Owner grants plan 4 Task 11 (P-4, P-6): what the managed directory holds under the current manifest.
817
+ * Manifest grants require state "bound" at the applied counter (Task 13). null at startup, while a
818
+ * refetch narrows it, and after any throw (the fallback).
819
+ */
820
+ private manifestBinding;
821
+ /** skills.json tools.deny from the latest fetch, manifest or not (spec §5.2 `disabled`). */
822
+ private payloadToolDeny;
823
+ /** Spec §7.7 (OQ-5): the latest fetch's binding outcome, reported in tool_grant_status (Task 15). */
824
+ private configBindingMismatches;
825
+ private targetMismatches;
826
+ /** P-5 (Task 14): the legacy fact decisions the latest bound fetch may apply (assigns verified against the signed set). */
827
+ private boundLegacyDecisions;
761
828
  private readonly joinState;
762
829
  /**
763
830
  * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
@@ -1057,11 +1124,27 @@ export declare class BridgeAgent {
1057
1124
  private readonly ownerDmDefaultGrants;
1058
1125
  /** Read once at startup; see roomDefaultGrantsEnabled. */
1059
1126
  private readonly roomDefaultGrants;
1127
+ /** Owner grants (spec §7.4): the two manifest switches, read once at construction. */
1128
+ private readonly ownerDmManifestGrants;
1129
+ private readonly roomManifestGrants;
1060
1130
  /**
1061
1131
  * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
1062
1132
  * cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
1063
1133
  */
1064
1134
  private readonly ownerDmCycleFresh;
1135
+ private toolGrantManifest;
1136
+ private toolGrantCounter;
1137
+ private manifestState;
1138
+ /** Failed owner-turn checks since the last successful runtime-config report (§7.7). */
1139
+ private ownerVerifyFailures;
1140
+ /** Verified owner turns of this ingest cycle, recorded in owner_replay when staged (P-3). */
1141
+ private readonly ownerReplayPending;
1142
+ /** D-9: ids of owner turns that failed verification (never `replay`, audit E-3), id -> channel. Persisted too. */
1143
+ private readonly ownerVerifyFailedIds;
1144
+ private serverOwnerMismatchLogged;
1145
+ /** Audit L-2: the replay prune runs at most once per interval, on a monotonic clock. */
1146
+ private monoNow;
1147
+ private lastOwnerReplayPruneAt;
1065
1148
  /**
1066
1149
  * Audit M-B1: room-granted turns, keyed by the inbound message id, with the
1067
1150
  * channel, MLS group and epoch the room was classified at (review I-1: an
@@ -1179,6 +1262,12 @@ export declare class BridgeAgent {
1179
1262
  readonly action: AgentActionAPI;
1180
1263
  private readonly waiters;
1181
1264
  private pingHandle?;
1265
+ /** C10 (P-15): the delivery-ping subscription start() opens at its top,
1266
+ * held unconsumed until startReceivePump() takes it. */
1267
+ private earlyPings;
1268
+ /** C10 fix round 1: settles (never rejects) when start()'s body does; close()
1269
+ * awaits it, bounded, so the store never closes under a start() step. */
1270
+ private startInFlight;
1182
1271
  private safetyTimer?;
1183
1272
  /** The ingest cycle now running, if any (see ingestOnce: single-flight). */
1184
1273
  private ingestInFlight;
@@ -1400,6 +1489,20 @@ export declare class BridgeAgent {
1400
1489
  */
1401
1490
  refetchRuntimeConfig(): Promise<void>;
1402
1491
  private refetchRuntimeConfigOnce;
1492
+ /**
1493
+ * Plan 4 Task 11: the refetch under a manifest in force. Returns false when the binding failed (H-4),
1494
+ * after the memory was applied; the caller then writes nothing else. Throws only from a file write, with
1495
+ * the binding left null.
1496
+ */
1497
+ private applyUnderManifest;
1498
+ /** Spec §7.6 (plan 4 Task 15): content-free. One line per mismatched field, one per dropped target. */
1499
+ private logConfigBinding;
1500
+ /** H-4: the binding could not be computed. The memory still applies, with the signed mode; nothing else is written. */
1501
+ private bindFailed;
1502
+ /** No manifest in force: a manifest-policy.json left by an earlier one is replaced by an empty tool list. */
1503
+ private overwriteStalePolicy;
1504
+ /** The report half of refetchRuntimeConfigOnce (split out for the plan 4 Task 11 bind_failed path). */
1505
+ private postRuntimeConfigReport;
1403
1506
  /**
1404
1507
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
1405
1508
  *
@@ -1412,7 +1515,24 @@ export declare class BridgeAgent {
1412
1515
  * shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
1413
1516
  */
1414
1517
  private capBuffer;
1518
+ /**
1519
+ * C10 (spec P-15, OQ-8 (b)): subscribe early, consume late. The
1520
+ * delivery-ping socket opens here, before anything else, so this copy claims
1521
+ * the device's session (and another copy gets its 4409) BEFORE this copy's
1522
+ * boot drain runs. The pings it carries are NOT consumed until
1523
+ * startReceivePump(), the same place as before: starting the pump first is
1524
+ * still forbidden (DRAIN BEFORE ROTATE, startBody).
1525
+ *
1526
+ * A 4409 during start() (audit H-3): the gated fetch is already set (C9 step
1527
+ * 0). start() then arms nothing more (supersededDuringStart before each
1528
+ * timer and before the pump), runs the memoized close() quiesce, and rejects
1529
+ * with SessionSupersededError. A step that FAILED because of the supersede
1530
+ * (a refused fetch, a store the quiesce closed) is the same outcome.
1531
+ */
1415
1532
  start(): Promise<void>;
1533
+ /** C10: true once a 4409 landed; checked before each arming step of start(). */
1534
+ private supersededDuringStart;
1535
+ private startBody;
1416
1536
  /**
1417
1537
  * #811 L-1: send the re-Welcome signal ONCE for each channel whose onWedged
1418
1538
  * heal the boot drain refused. Runs after the drain settled and after the
@@ -2079,6 +2199,11 @@ export declare class BridgeAgent {
2079
2199
  * stale cache is refreshed once; any failure remains a fail-closed denial.
2080
2200
  */
2081
2201
  private mayReadOpenClawMemory;
2202
+ /**
2203
+ * Decision D-9 (audit H-3): a turn that failed owner verification gets only
2204
+ * its own text, never the prior-turn history snapshot, for every provider.
2205
+ */
2206
+ private mayReadHistoryForTurn;
2082
2207
  /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
2083
2208
  private mayReadOpenClawMemoryForTurn;
2084
2209
  /**
@@ -2087,6 +2212,49 @@ export declare class BridgeAgent {
2087
2212
  * never falls back to the runtime's retired plaintext JSON files.
2088
2213
  */
2089
2214
  private memoryForTurn;
2215
+ /**
2216
+ * Owner grants plan 4 Task 16 (T-30, audit r2 H-1): the voice fence is on whenever a pin is loaded, on EVERY runtime
2217
+ * turn (manifest, fallback, greeting, relayed, constants-only alike). It never depends on a turn's grants.
2218
+ */
2219
+ private voiceFenceOn;
2220
+ /** The keyed and legacy fast-reply ids of every peer row of the conversation (audit r2 M-1; never the 50-row window). */
2221
+ private fenceContext;
2222
+ /**
2223
+ * Drop agent lines classified fast_path, untrusted or unknown from a turn's memory (every turn of a pinned agent).
2224
+ * `strict` (review H-3, a turn that holds a manifest grant): relay-class peer lines go too (sender not the pinned
2225
+ * owner, or voice modality). Otherwise peer lines are never dropped.
2226
+ */
2227
+ private fenceMemory;
2228
+ /**
2229
+ * Review H-3: true when a delivered frame holds a line a manifest turn would exclude (a relay-class peer line; agent
2230
+ * lines in a frame are already fenced). Such a turn's reply is recorded "untrusted".
2231
+ */
2232
+ private frameHoldsRelayClass;
2233
+ /**
2234
+ * Review H-3 attribution: message ids of turns delivered on a pinned agent whose frame held no relay-class line.
2235
+ * A reply is recorded "reply" only when its in-reply-to is here; anything else (tainted, unknown after a restart,
2236
+ * no in-reply-to) is "untrusted". Bounded; the oldest are forgotten first (forgetting only makes a reply untrusted).
2237
+ */
2238
+ private readonly cleanTurns;
2239
+ private noteDeliveredFrame;
2240
+ private readonly taintedTurns;
2241
+ /**
2242
+ * Review R2-H2: on a pinned agent a 7a note or a unified memory_note is written only from a turn delivered with a clean
2243
+ * frame in the owner DM (cleanTurns, not taintedTurns), so a tainted fallback turn cannot plant a note that a later
2244
+ * manifest turn reads. Taint is noted at delivery, before any note frame for the turn can arrive.
2245
+ */
2246
+ private noteTurnClean;
2247
+ /**
2248
+ * Review R2-H1: the turn itself is relay-class (its frame memory is prior-turn only, so its own sender is never in it):
2249
+ * a sender that is not the pinned owner, a relay speaker claim, or a voice turn.
2250
+ */
2251
+ private turnIsRelayClass;
2252
+ /**
2253
+ * R2-M1 ruling: the channel where the H-3 taint and the `untrusted` exclusion apply, on a pinned agent: the signed
2254
+ * owner_dm_channel_id of the manifest in force. Before any manifest is held there is no signed DM, so every channel
2255
+ * not known to be a room counts (fail closed: a reply recorded there before the first manifest must not reach it).
2256
+ */
2257
+ private taintScope;
2090
2258
  /**
2091
2259
  * Spec §11.2 (OQ-L), §6.5: apply one parsed `payload.memory`. An unreadable
2092
2260
  * value turns notes off and deletes nothing. A readable one goes through
@@ -2630,6 +2798,41 @@ export declare class BridgeAgent {
2630
2798
  * cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
2631
2799
  */
2632
2800
  private refreshRostersForCycle;
2801
+ /**
2802
+ * Owner-signed tool grants, step 1 (spec §7.2; decisions D-1, D-2, D-4, D-8,
2803
+ * D-9; audits H-1, M-2, M-4, E-1..E-3). The verdict for one delivered turn.
2804
+ *
2805
+ * `unchecked` when no pin is loaded or the sender is not the pinned owner.
2806
+ * Otherwise, in order, the first failure wins: the identity signature over the
2807
+ * raw payload bytes with the pinned key; the verified bytes must be the
2808
+ * decrypted envelope; in a DM only (D-1), the signed DM channel and the MLS
2809
+ * leaf set; then the replay set and closed epochs, keyed by the agent's OWN
2810
+ * held group (never the backend's row label, M-2). NEVER throws (M-4): any
2811
+ * exception is `failed verify_error`, and the row is acked like any other turn.
2812
+ */
2813
+ private verifyOwnerTurn;
2814
+ /** Log, count and (except a replay, audit E-3) record one failed owner-turn check. Never throws (audit E-2). */
2815
+ private ownerTurnFailed;
2816
+ /** Review M-A: a verified original of this id was staged and is still pending, or was recorded in the replay set. */
2817
+ private isVerifiedOwnerOriginal;
2818
+ /** Review M-A: a verified original of this id was staged and acked (it is in the replay set, no longer pending). */
2819
+ private isAckedOwnerOriginal;
2820
+ /** D-9: memory first, then the store (a restart before the reply). Unpinned: never. A store read that throws withholds (fail closed). */
2821
+ private isOwnerVerifyFailed;
2822
+ /**
2823
+ * Decision D-2, audit L-2: prune the owner replay set at most once per
2824
+ * OWNER_REPLAY_PRUNE_INTERVAL_MS, keeping each channel's current (group,
2825
+ * epoch). A channel whose held position is unknown is left out (not pruned).
2826
+ */
2827
+ private maybePruneOwnerReplay;
2828
+ /** start(): drop other keys' manifest state, re-verify the stored one for the pinned key, and prune. */
2829
+ private loadToolGrantState;
2830
+ /**
2831
+ * Spec §7.1: verify and record a served manifest. Never writes a file and
2832
+ * never changes a grant in step 1. A save that throws keeps the old state.
2833
+ */
2834
+ private applyToolGrantManifest;
2835
+ private logManifest;
2633
2836
  /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
2634
2837
  private authorizeTurn;
2635
2838
  /**
@@ -2693,6 +2896,8 @@ export declare class BridgeAgent {
2693
2896
  * enforcement is attached
2694
2897
  * memory_write the turn carries memory_write (Markdown memory
2695
2898
  * 7a, spec §8.3): a boolean, never content
2899
+ * grant_source where the grants came from: constant | none in
2900
+ * step 1 (owner-signed tool grants §7.6)
2696
2901
  */
2697
2902
  private logAuthzDecision;
2698
2903
  /**
@@ -2860,7 +3065,19 @@ export declare class BridgeAgent {
2860
3065
  /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
2861
3066
  subscribe(listener: () => void): () => void;
2862
3067
  /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
3068
+ /** Review H-1: every buffer exit narrows manifest grants again to the state at delivery. */
2863
3069
  takeBuffered(channelId?: string): BridgeMessage[];
3070
+ takeDeliverable(busyChannels: ReadonlySet<string>): BridgeMessage[];
3071
+ /** Every buffer exit: re-narrow manifest grants (review H-1), then note the frame's attribution class (review H-3). */
3072
+ private delivering;
3073
+ /**
3074
+ * Review H-1 (kill switches reach staged and restored turns): a grant marked source "manifest" survives delivery
3075
+ * only while its audience's manifest switch is on, the binding is bound at the applied counter, and the current
3076
+ * manifest still signs the tool. Otherwise it becomes the fallback: kept unmarked if it is a constant of the
3077
+ * audience that is still signed, else dropped. Only narrows; an unchanged message is returned by reference.
3078
+ */
3079
+ private renarrowManifestGrants;
3080
+ private takeBufferedRaw;
2864
3081
  /**
2865
3082
  * Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
2866
3083
  * Splices out, oldest first, at most ONE buffered message per channel that
@@ -2870,7 +3087,7 @@ export declare class BridgeAgent {
2870
3087
  * working on — which it used to refuse with a "busy" reply and ack — and a
2871
3088
  * backlog replays one message per channel at a time after a restart.
2872
3089
  */
2873
- takeDeliverable(busyChannels: ReadonlySet<string>): BridgeMessage[];
3090
+ private takeDeliverableRaw;
2874
3091
  private hasReplyIntent;
2875
3092
  /**
2876
3093
  * SEC-06 #18: answer a redelivered message from its reply intent. Counts as
@@ -2911,6 +3128,20 @@ export declare class BridgeAgent {
2911
3128
  * at most once per channel per hour. Logs carry counts only.
2912
3129
  */
2913
3130
  private deadLetter;
3131
+ /** Spec §7.5: the room is capped today: cap 0, reached (by the runtime or the backstop), or at the cap. Fails closed. */
3132
+ private roomSearchCapped;
3133
+ /** Spec §7.5 (T-14c, M-4): the runtime reports an executed-call cap for a room. Never throws. */
3134
+ noteRoomCapReached(channelId: string): void;
3135
+ /** K-14, once per room per UTC day; the flag is persisted only after the send resolves. Called only from noteRoomCapReached. */
3136
+ private noticeRoomSearchCap;
3137
+ private logRoomCap;
3138
+ /**
3139
+ * Spec §7.5 (T-14b, audit r1 H-3): the silent bridge backstop. Counts a team_room turn that actually goes out
3140
+ * to the runtime with web_search, once per message id (callers run it after their runtimePendingIds early
3141
+ * return). Past the cap, and on a store failure (fail closed), the entry loses web_search. Never posts K-14.
3142
+ */
3143
+ private applyRoomSearchBackstop;
3144
+ private withoutWebSearch;
2914
3145
  private noticeDeadLetter;
2915
3146
  /** Poke consumers once the earliest redelivery backoff ends. */
2916
3147
  private armRedeliveryWake;
@@ -3056,6 +3287,24 @@ export declare class BridgeAgent {
3056
3287
  /** Forget `token` when its socket closes; a newer consumer's token is kept. */
3057
3288
  clearPerToolConsumer(token: object): void;
3058
3289
  perToolConsumerAttached(): boolean;
3290
+ /**
3291
+ * Owner grants (spec §7.9, plan 4 Task 5): the attached per-tool runtime socket whose hello
3292
+ * declared manifest_grants_v1, or null. A manifest grant is issued only while one is attached
3293
+ * (Task 13); every other socket receives manifest grants narrowed to the constants.
3294
+ * Newest wins, like perToolConsumer.
3295
+ */
3296
+ private manifestConsumer;
3297
+ /** OQ-5: the latest hello's class; "missing" until any hello. Reported in tool_grant_status (Task 15). */
3298
+ private lastConsumerCapability;
3299
+ noteManifestConsumer(token: object): void;
3300
+ clearManifestConsumer(token: object): void;
3301
+ manifestConsumerAttached(): boolean;
3302
+ noteConsumerCapability(state: "ok" | "missing" | "harness_consumer"): void;
3303
+ /**
3304
+ * Harness re-rule (2026-10-12, audit r2 H-3): true exactly when this agent has a pin loaded and a good
3305
+ * manifest. A harness consumer then receives [] in every audience (consumer-grants.ts withHarnessGrants).
3306
+ */
3307
+ harnessGrantsWithheld(): boolean;
3059
3308
  health(): {
3060
3309
  status: "ok";
3061
3310
  identity_id: string;
@@ -3278,5 +3527,23 @@ export declare class BridgeAgent {
3278
3527
  * and zero on restart (spec §5.5). */
3279
3528
  build: string;
3280
3529
  };
3530
+ /** C9: `session_superseded` once a 4409 said another copy took this
3531
+ * agent's session (the gated fetch already refuses every request). The
3532
+ * local HTTP routes read it: every route but `/healthz` answers 503. */
3533
+ get sessionState(): "live" | "session_superseded";
3534
+ /** C9: subscribe to `session_superseded`. runMode subscribes right after
3535
+ * construction, before start(), so a 4409 during start() is heard too. */
3536
+ on(event: "session_superseded", listener: () => void): () => void;
3537
+ /** P-1 step 0, then the event (spec §4.1.6): another copy of this agent took
3538
+ * the session (4409). The gated-fetch switch is set FIRST, so no HTTP
3539
+ * request leaves the process after this, whatever a listener does. */
3540
+ private onSessionSuperseded;
3541
+ /**
3542
+ * Idempotent (C9, spec §4.1.6 low 3): every call returns the FIRST call's
3543
+ * promise, so a SIGTERM during or after the supersede quiesce never runs
3544
+ * store.close() twice. A rejected close stays rejected: a retry would close
3545
+ * the store a second time.
3546
+ */
3281
3547
  close(): Promise<void>;
3548
+ private closeOnce;
3282
3549
  }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type VoiceConfig } from "./voice/persona.js";
2
+ import { type OwnerGrantPinResult } from "./grants/pin.js";
2
3
  import { type FastPathMode } from "./voice/defer-intent.js";
3
4
  export interface BridgeConfig {
4
5
  apiKey: string;
@@ -47,6 +48,12 @@ export interface BridgeConfig {
47
48
  modelSource: "download" | "import_only";
48
49
  /** Spec section 13 (R-U20): credential `memory.enabled`. Absent or true is on; any other value is off (plan I-9). Local only: the backend cannot set it. */
49
50
  memoryEnabled?: boolean;
51
+ /**
52
+ * Owner-signed tool grants (spec §6.1, §6.5): the grant pin, loaded once here
53
+ * from the credential's `owner_signing_key` and `owner-signing-key.json` beside
54
+ * the credential, never from the backend. Absent means `no_pin`.
55
+ */
56
+ ownerGrantPin?: OwnerGrantPinResult;
50
57
  }
51
58
  export declare const REQUIRED: readonly ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
52
59
  export declare function loadConfig(opts: {
@@ -21,3 +21,22 @@ export declare const PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
21
21
  * no non-per-tool consumer ever receives (Markdown memory 7a, review m-3).
22
22
  */
23
23
  export declare function withConsumerGrants(m: BridgeMessage, perToolConsumer: boolean): BridgeMessage;
24
+ /** The /v1/ws hello feature of a runtime that enforces manifest grants (runtime 0.13.0+). Pinned to the runtime's by capability-parity.test.ts. */
25
+ export declare const MANIFEST_GRANTS_FEATURE = "manifest_grants_v1";
26
+ export declare const CONSTANT_TOOL_NAMES: Readonly<Record<"owner_dm" | "team_room", readonly string[]>>;
27
+ /**
28
+ * Spec §7.9: a consumer that did not declare manifest_grants_v1 (runtime 0.12.2, a harness,
29
+ * every HTTP path) never receives a manifest grant. Its grants are narrowed to constants(a):
30
+ * a manifest-marked tool that is also a constant is kept unmarked; any other is dropped.
31
+ * Never mutates `m`.
32
+ */
33
+ export declare function withManifestCapability(m: BridgeMessage, manifestConsumer: boolean): BridgeMessage;
34
+ /**
35
+ * Harness re-rule (2026-10-12, supersedes P4 OQ-8's "constants ∩ signed"), audit r2 H-3. A harness turns
36
+ * ANY non-empty tool_grants into the owner's whole configured toolset (header of this file), so no
37
+ * non-empty list can stay within what the owner signed. On a pinned agent with a good manifest
38
+ * (`withheld === true`) a harness receives [] in EVERY audience. `withheld === false` (no pin, or no good
39
+ * manifest): `m` unchanged, by reference (today's behaviour). An issued [] is returned by reference.
40
+ * Never mutates `m`. Callers: the three harness delivery paths, after withConsumerGrants.
41
+ */
42
+ export declare function withHarnessGrants(m: BridgeMessage, withheld: boolean): BridgeMessage;
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
7
7
  * same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
8
8
  */
9
- export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "internal";
9
+ export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "session_superseded" | "internal";
10
10
  export interface BridgeErrorSpec {
11
11
  /** HTTP status this code maps to (the WS binding ignores it). */
12
12
  http: number;
@@ -0,0 +1,58 @@
1
+ import { type ToolGrantManifest } from "./manifest.js";
2
+ import type { ManifestPolicyDoc } from "../service/runtime-config-dir.js";
3
+ export declare const CONFIG_BINDING_FIELDS: readonly ["persona", "memory", "search", "tools_allow", "flags", "llm", "files", "legacy_assign"];
4
+ export type ConfigBindingField = (typeof CONFIG_BINDING_FIELDS)[number];
5
+ export interface BindPayload {
6
+ readonly version?: number;
7
+ readonly files?: Record<string, string>;
8
+ readonly skills_json?: Record<string, unknown>;
9
+ readonly memory?: unknown;
10
+ readonly persona?: unknown;
11
+ /** Raw, unfiltered saved tokens (runtime_config_service.py memory_prunes). Untrusted. */
12
+ readonly memory_prunes?: unknown;
13
+ readonly legacy_memory_fact_decisions?: unknown;
14
+ }
15
+ /** The search block last written under a good search match for this pinned key (Task 11's sealed record); undefined = never. */
16
+ export type AppliedSearch = {
17
+ readonly provider: "brave" | "tavily";
18
+ readonly keyFile?: string;
19
+ } | null;
20
+ export interface BindPrior {
21
+ readonly search: AppliedSearch | undefined;
22
+ }
23
+ export interface BindResult {
24
+ readonly mismatches: readonly ConfigBindingField[];
25
+ readonly targetMismatches: {
26
+ readonly endpoint: number;
27
+ readonly database: number;
28
+ };
29
+ readonly files: Record<string, string>;
30
+ readonly memory: unknown;
31
+ readonly signedTargets: {
32
+ readonly endpoints: number;
33
+ readonly databases: number;
34
+ };
35
+ readonly policy: ManifestPolicyDoc;
36
+ /** Set only when `search` matched: the block written, to be saved as the new last good one after the write succeeds. */
37
+ readonly appliedSearch: AppliedSearch | undefined;
38
+ /**
39
+ * P-5 (Task 14): the decisions to hand applyLegacyMemoryFactDecisions. On a legacy_assign match: the
40
+ * deletes plus each assign whose token, rebuilt from its own fields, is in the verified saved set. On a
41
+ * mismatch: the deletes only.
42
+ */
43
+ readonly legacyDecisions: unknown[];
44
+ }
45
+ /** P4 OQ-7: hex SHA-256 of JCS(sorted, deduplicated memory_prunes entries that begin "legacy-fact-assign:"). Non-strings are ignored. */
46
+ export declare function legacyAssignSha256(prunes: readonly unknown[]): string;
47
+ /** The backend's assign token (runtime_config_service.py _legacy_memory_fact_decision_token), rebuilt from the fields. */
48
+ export declare function legacyAssignToken(d: {
49
+ source_id: string;
50
+ fact_hash: string;
51
+ channel_id: string;
52
+ }): string;
53
+ export declare function bindRuntimeConfig(manifest: ToolGrantManifest, payload: BindPayload, prior: BindPrior,
54
+ /** Review H-1: the two manifest kill switches; an audience switched off contributes no tool to the policy file. */
55
+ switches?: {
56
+ readonly ownerDm: boolean;
57
+ readonly room: boolean;
58
+ }): BindResult;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The effective grant (owner-signed tool grants, spec §5.2, §5.4, §7.4; plan 4 Task 6).
3
+ * The one formula, pure, so every rule has its own test and the issuing code decides nothing.
4
+ *
5
+ * signed(a) = manifest.grants[a] ∩ TOOL_GRANT_CODE_CEILING[a]
6
+ * effective(a) = signed(a) ∖ disabled, minus http_request with no signed endpoint, minus sql_query
7
+ * with no signed database, minus team_room web_search when capped, ∪ {current_time}
8
+ * (only when every precondition holds: `eligible` and the manifest switch on)
9
+ * fallback(a) = (constants(a) ∩ signed(a)) ∪ {current_time} when a precondition fails
10
+ * = constants(a) by reference with no good manifest
11
+ * [] when today's predicate gives []
12
+ */
13
+ import { type ToolGrantManifest } from "./manifest.js";
14
+ /** Spec §5.4 code ceiling. The same frozen lists Plan 1 uses to drop unknown entries. */
15
+ export declare const TOOL_GRANT_CODE_CEILING: {
16
+ readonly owner_dm: readonly string[];
17
+ readonly team_room: readonly string[];
18
+ };
19
+ export type GrantAudience = "owner_dm" | "team_room";
20
+ export type GrantSource = "constant" | "manifest" | "fallback" | "none";
21
+ export interface ReadGrant {
22
+ readonly tool: string;
23
+ readonly mode: "read";
24
+ readonly source?: "manifest";
25
+ }
26
+ export interface EffectiveInput {
27
+ readonly audience: GrantAudience;
28
+ /** Today's list from the existing predicate: OWNER_DM_DEFAULT_READ_GRANTS, ROOM_DEFAULT_READ_GRANTS, or a frozen []. */
29
+ readonly base: readonly ReadGrant[];
30
+ /** The last good manifest, or null (no pin, or none verified). */
31
+ readonly manifest: ToolGrantManifest | null;
32
+ /** Every manifest-only precondition held (Task 13 computes it). */
33
+ readonly eligible: boolean;
34
+ readonly manifestSwitchOn: boolean;
35
+ readonly disabled: ReadonlySet<string>;
36
+ readonly signedTargets: {
37
+ readonly endpoints: number;
38
+ readonly databases: number;
39
+ };
40
+ readonly roomSearchCapped: boolean;
41
+ }
42
+ export interface EffectiveGrant {
43
+ readonly grants: readonly ReadGrant[];
44
+ readonly source: GrantSource;
45
+ readonly withheldBySearchCap: boolean;
46
+ }
47
+ export declare function effectiveToolGrants(i: EffectiveInput): EffectiveGrant;
@@ -0,0 +1,68 @@
1
+ import { type OwnerGrantPin } from "./pin.js";
2
+ export declare const TOOL_GRANTS_DOMAIN = "SECURECOMS-TOOL-GRANTS-V1:";
3
+ export declare const MANIFEST_STATES: readonly ["ok", "unchanged", "no_pin", "no_manifest", "malformed", "unsupported_algo", "bad_signature", "wrong_agent", "wrong_owner", "rollback", "unknown_entry", "kept_last_good"];
4
+ export type ManifestState = (typeof MANIFEST_STATES)[number];
5
+ /** Known names only for dropping unknown entries (§7.1 step 6). These are the §5.4 code-ceiling names; step 4 uses them as the ceiling. */
6
+ export declare const MANIFEST_KNOWN_TOOLS: {
7
+ readonly owner_dm: readonly string[];
8
+ readonly team_room: readonly string[];
9
+ };
10
+ export interface ToolGrantManifest {
11
+ readonly type: "securecoms.tool-grants";
12
+ readonly v: 1;
13
+ readonly agent_identity_id: string;
14
+ readonly owner_identity_id: string;
15
+ readonly owner_dm_channel_id?: string;
16
+ readonly owner_dm_relay_identity_id?: string;
17
+ readonly counter: number;
18
+ readonly grants: {
19
+ readonly owner_dm?: readonly string[];
20
+ readonly team_room?: readonly string[];
21
+ };
22
+ /** Plan 4 Task 7 (spec §8.1): signed targets. Absent in the wrapper = {}. A `headersFile: ""` is dropped (L-4). */
23
+ readonly endpoints: Readonly<Record<string, SignedEndpoint>>;
24
+ readonly databases: Readonly<Record<string, SignedDatabase>>;
25
+ /** Plan 4 Task 7 (spec §8.1, §7.10): the signed values the config binding compares. Required. */
26
+ readonly config: SignedConfig;
27
+ }
28
+ export interface SignedEndpoint {
29
+ readonly baseUrl: string;
30
+ readonly headersFile?: string;
31
+ }
32
+ export type SignedDatabase = {
33
+ readonly sqlite: string;
34
+ } | {
35
+ readonly postgresUrlFile: string;
36
+ };
37
+ export interface SignedConfig {
38
+ readonly persona_sha256: string;
39
+ readonly llm?: {
40
+ readonly provider: string;
41
+ readonly model: string;
42
+ };
43
+ readonly search?: {
44
+ readonly provider: "brave" | "tavily";
45
+ };
46
+ readonly tools_allow?: readonly string[];
47
+ readonly flags: readonly string[];
48
+ readonly memory: {
49
+ readonly mode: "markdown" | "semantic" | "off";
50
+ readonly recall_days?: 30 | 90 | 365;
51
+ };
52
+ readonly legacy_assign_sha256: string;
53
+ }
54
+ export type ManifestVerdict = {
55
+ state: "ok";
56
+ manifest: ToolGrantManifest;
57
+ droppedUnknown: boolean;
58
+ wrapper: unknown;
59
+ } | {
60
+ state: "unchanged" | "malformed" | "unsupported_algo" | "bad_signature" | "wrong_agent" | "wrong_owner" | "rollback";
61
+ };
62
+ /** utf8(TOOL_GRANTS_DOMAIN) || canonicalize(manifest). The domain keeps an envelope signature from ever verifying as a manifest. */
63
+ export declare function manifestSignedBytes(manifest: unknown): Uint8Array;
64
+ export declare function verifyToolGrantManifest(raw: unknown, ctx: {
65
+ pin: OwnerGrantPin;
66
+ agentIdentityId: string;
67
+ storedCounter: number | null;
68
+ }): ManifestVerdict;