@securecoms/agent-bridge 0.26.4 → 0.26.6

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.
@@ -1,4 +1,5 @@
1
1
  import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
2
+ import type { HarnessReport } from "./harness-report.js";
2
3
  import type { MemoryContextResult } from "@agentvault/connect-mls-store";
3
4
  /**
4
5
  * M6 (ADR-0013 Decision H): what the sweep observed about one channel.
@@ -58,9 +59,15 @@ export type BridgeToolGrant = {
58
59
  * Tool grants (architect ruling, 2026-10-02): until channel-grant persistence
59
60
  * lands, the ONLY non-empty grant list is OWNER_DM_DEFAULT_READ_GRANTS
60
61
  * (`current_time` + `web_search`, read mode), and only on a DM whose sender is
61
- * the pinned structural owner (or a relayed voice turn whose speaker is). Every
62
+ * that DM's pinned human counterpart AND server-marked `is_agent_owner` (or a
63
+ * relayed voice turn whose speaker is). The pin is per channel, so the
64
+ * owner distinction rests on the server's flag: see authorizationFor's LIMIT
65
+ * note. Every
62
66
  * other audience, an unpinned or anomalous owner, and the kill switch
63
67
  * `SECURECOMS_OWNER_DM_DEFAULT_GRANTS` (anything but unset/`on`) get `[]`.
68
+ * PR R adds exactly one more non-empty list, ROOM_DEFAULT_READ_GRANTS
69
+ * (`current_time` only), for an INTERNAL room's same-org human sender; see
70
+ * authorizationFor.
64
71
  * SEC-06 is LIVE for these two tools: docs/security/sec-06-runtime-bounds-audit.md.
65
72
  */
66
73
  /**
@@ -71,17 +78,42 @@ export type BridgeToolGrant = {
71
78
  * and `http_request` are deliberately absent.
72
79
  */
73
80
  export declare const OWNER_DM_DEFAULT_READ_GRANTS: readonly BridgeToolGrant[];
81
+ /**
82
+ * PR R (spec §7A.1a; controller ruling 2026-10-05, audit r1: current_time
83
+ * only). Granted only in a room classified INTERNAL from the server roster
84
+ * (every row, leaf rows included, in the agent owner's org), to a same-org
85
+ * human sender, while the roster is as fresh as the agent's MLS epoch, while
86
+ * a declared per-tool consumer is attached (audit C-B1), and while
87
+ * SECURECOMS_ROOM_DEFAULT_GRANTS is on (plan OQ-3). Frozen; the runtime intersects
88
+ * it with ROOM_SAFE_READ_TOOLS (turn-loop.ts).
89
+ */
90
+ export declare const ROOM_DEFAULT_READ_GRANTS: readonly BridgeToolGrant[];
74
91
  /**
75
92
  * Kill switch for OWNER_DM_DEFAULT_READ_GRANTS, read once at BridgeAgent
76
93
  * construction (daemon startup). Unset or exactly `on` keeps the grants;
77
94
  * any other value (`off`, `0`, `false`, empty) sends `[]` on every turn.
78
95
  */
79
96
  export declare function ownerDmDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
97
+ /**
98
+ * Kill switch for ROOM_DEFAULT_READ_GRANTS (plan OQ-3, RULED), read once at
99
+ * BridgeAgent construction exactly like ownerDmDefaultGrantsEnabled. Unset or
100
+ * exactly `on` keeps the room default; any other value sends `[]` on every
101
+ * room turn. It never touches the owner DM.
102
+ */
103
+ export declare function roomDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
80
104
  export interface BridgeAuthorization {
81
105
  readonly version: 1;
82
106
  readonly audience: AgentAudience;
83
107
  readonly sender_membership: "active" | "unknown";
84
108
  readonly tool_grants: readonly BridgeToolGrant[];
109
+ /**
110
+ * Audit r2 R2-T1 (plan OQ-6 ruling): "owner" ONLY on authorizationFor's
111
+ * ownerRelayed branch: the channel's server-marked relay sent a turn whose
112
+ * relayed speaker is the channel's pinned owner. Set by the daemon, never
113
+ * copied from message text. A harness connector honours a grant on
114
+ * audience owner_dm or on this mark, nothing else.
115
+ */
116
+ readonly relay_speaker?: "owner";
85
117
  }
86
118
  export interface BridgeMessage {
87
119
  channel_id: string;
@@ -513,6 +545,15 @@ export declare class SendForbiddenError extends Error {
513
545
  readonly channelId: string;
514
546
  constructor(channelId: string);
515
547
  }
548
+ /**
549
+ * Audit M-B1 (controller ruling 2026-10-05): a reply to a room-granted turn
550
+ * would be sealed at an epoch the room was not classified at, and the room is
551
+ * not internal there. Dropped. A SendForbiddenError, so both transports treat
552
+ * it as terminal (send_forbidden: the runtime acks, nothing redelivers).
553
+ */
554
+ export declare class RoomClassChangedSendError extends SendForbiddenError {
555
+ constructor(channelId: string);
556
+ }
516
557
  /**
517
558
  * Stranded sender (spec 2026-09-26 §7.4): the backend refused a publish with
518
559
  * 409 `mls.group_superseded`: the envelope was sealed under a group that is no
@@ -892,6 +933,21 @@ export declare class BridgeAgent {
892
933
  private readonly selfHealSweepEnabled;
893
934
  /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
894
935
  private readonly ownerDmDefaultGrants;
936
+ /** Read once at startup; see roomDefaultGrantsEnabled. */
937
+ private readonly roomDefaultGrants;
938
+ /**
939
+ * Audit M-B4: DM channels whose roster was epoch-fresh at THIS ingest
940
+ * cycle's refresh (refreshRostersForCycle). Cleared at each cycle's start.
941
+ */
942
+ private readonly ownerDmCycleFresh;
943
+ /**
944
+ * Audit M-B1: room-granted turns, keyed by the inbound message id, with the
945
+ * channel, MLS group and epoch the room was classified at (review I-1: an
946
+ * epoch NUMBER alone repeats across channels and across a supersede). A
947
+ * reply to one is sealed only there, or re-classified once
948
+ * (publishBodyForReply). Forgotten at the runtime's ack.
949
+ */
950
+ private readonly roomGrantEpochs;
895
951
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
896
952
  private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
897
953
  /**
@@ -1505,6 +1561,25 @@ export declare class BridgeAgent {
1505
1561
  * there so a retry can resend the exact bytes).
1506
1562
  */
1507
1563
  private publishBody;
1564
+ /**
1565
+ * Audit M-B1 (controller ruling): a reply to a room-granted turn is sealed
1566
+ * only at the epoch the room was classified at. On a move, the room is
1567
+ * re-classified ONCE from a roster fetched at the new epoch: still internal,
1568
+ * the reply is sealed at that epoch; anything else, it is dropped. The check
1569
+ * throws before an envelope is built, so nothing is stored or posted.
1570
+ *
1571
+ * `inReplyTo` is the STORED intent's inbound id, not the send's options, so
1572
+ * the redelivery replay (replayReplyIntents, which has no in_reply_to in its
1573
+ * options) is checked the same as a live send.
1574
+ */
1575
+ private publishBodyForReply;
1576
+ /**
1577
+ * Re-read the room at the agent's current group and epoch; true only if it
1578
+ * is still internal there. Review I-1: the entry must come from THIS re-read
1579
+ * (a fail-soft refresh leaves an older entry whose epoch label can repeat
1580
+ * across a supersede), and the group must not move around the reads.
1581
+ */
1582
+ private reclassifyForReply;
1508
1583
  private enforceBodyRateFloor;
1509
1584
  /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
1510
1585
  private afterPublish;
@@ -1943,6 +2018,19 @@ export declare class BridgeAgent {
1943
2018
  private enclavesToCover;
1944
2019
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
1945
2020
  private freshRoster;
2021
+ /** The whole cache entry when fresh, else undefined (TTL; the epoch is checked by the caller). */
2022
+ private freshRosterEntry;
2023
+ /** The agent's held MLS group id for a channel; null when none (fail closed). */
2024
+ private heldGroupFor;
2025
+ /**
2026
+ * Review I-1 + fix round 2: the agent's held (group, epoch) for a channel.
2027
+ * The group is read on BOTH sides of the epoch read, so the epoch provably
2028
+ * belongs to it (a supersede in between installs a new group whose epoch
2029
+ * counter restarts); a group that moved, or none held, is null (fail closed).
2030
+ */
2031
+ private heldPosition;
2032
+ /** The agent's own MLS epoch for a channel; null when no group is held (fail closed). */
2033
+ private heldEpochFor;
1946
2034
  /**
1947
2035
  * Resolve one channel's roster (members + display names + is-agent) from the
1948
2036
  * GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
@@ -1951,6 +2039,15 @@ export declare class BridgeAgent {
1951
2039
  * attribution must NEVER block or drop a message.
1952
2040
  */
1953
2041
  private refreshChannelRoster;
2042
+ /**
2043
+ * S-1 (roster) + §7A.5 + audit M-B4: once per ingest cycle, per distinct
2044
+ * channel, re-read the roster when the TTL lapsed OR the epoch moved since
2045
+ * the fetch, then record which DMs are epoch-fresh for the rest of this
2046
+ * cycle. Fail-soft inside refreshChannelRoster (never blocks ingest).
2047
+ */
2048
+ private refreshRostersForCycle;
2049
+ /** Classify one inbound turn at the agent's CURRENT epoch for its channel. */
2050
+ private authorizeTurn;
1954
2051
  /**
1955
2052
  * Classify the sender only from server-authenticated channel/roster data.
1956
2053
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
@@ -1959,14 +2056,32 @@ export declare class BridgeAgent {
1959
2056
  *
1960
2057
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
1961
2058
  * the kill switch is on, the channel is a DM, and EITHER
1962
- * (a) audience is owner_dm AND the sender is the pinned structural owner
1963
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
1964
- * alone gets nothing; OR
2059
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
2060
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
2061
+ * `is_agent_owner` on a member who is NOT the DM's single human
2062
+ * counterpart gets nothing; on the counterpart it is trusted (see
2063
+ * LIMIT below); OR
1965
2064
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
1966
2065
  * relayed `speaker_identity_id` (passed by the caller only for relay
1967
2066
  * turns) is that same pinned owner, whose roster row is the agent's
1968
2067
  * owner. A speaker claim from any non-relay sender gets nothing.
2068
+ * Both need the roster fetched at `heldEpoch` (the agent's current MLS
2069
+ * epoch for the channel), or the DM marked epoch-fresh at this ingest
2070
+ * cycle's refresh (audit M-B4).
2071
+ * PR R: ROOM_DEFAULT_READ_GRANTS only when the room kill switch is on, a
2072
+ * per-tool consumer is attached, the roster was fetched at `heldEpoch`
2073
+ * (no cycle carve-out), the room is INTERNAL (roomIsCrossOrg === false
2074
+ * over every row, leaf rows included) and the audience is team_room.
1969
2075
  * Everything else gets the frozen empty list.
2076
+ *
2077
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
2078
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
2079
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
2080
+ * separates Bob from the real owner is only the server's `is_agent_owner`
2081
+ * flag (connect_channels.py: counterpart user == agent's creator). A
2082
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
2083
+ * connector turns grants into its full toolset. Binding the owner identity
2084
+ * once, outside server control, is an open design follow-up.
1970
2085
  */
1971
2086
  private authorizationFor;
1972
2087
  /**
@@ -1985,6 +2100,13 @@ export declare class BridgeAgent {
1985
2100
  * startup: `on` = default grants enabled
1986
2101
  * meta_fresh / roster_fresh whether the channel meta / roster caches were
1987
2102
  * fresh (either stale forces audience=unknown)
2103
+ * room_class the room's class from the server roster (`na`
2104
+ * for a DM): internal | cross_org | unknown
2105
+ * epoch_fresh the roster was fetched at the agent's current
2106
+ * MLS epoch for the channel
2107
+ * room_kill_switch SECURECOMS_ROOM_DEFAULT_GRANTS as read at startup
2108
+ * per_tool_consumer a consumer that declared per-tool grant
2109
+ * enforcement is attached
1988
2110
  */
1989
2111
  private logAuthzDecision;
1990
2112
  /**
@@ -2298,6 +2420,31 @@ export declare class BridgeAgent {
2298
2420
  * ws-server.ts right after prior.cleanup() in the eviction path.
2299
2421
  */
2300
2422
  noteSessionEvicted(): void;
2423
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
2424
+ private harness;
2425
+ /**
2426
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
2427
+ * each time the attached harness turns to an inactive veto: such a harness
2428
+ * refuses every turn without a tool grant, so its rooms go quiet.
2429
+ */
2430
+ recordHarnessReport(report: HarnessReport): void;
2431
+ /** Forget `report` when the connector that sent it disconnects. A report
2432
+ * from a newer connector (a different object) is kept. */
2433
+ clearHarnessReport(report: HarnessReport): void;
2434
+ /** The latest harness report, or null (GET /v1/status). */
2435
+ harnessReport(): HarnessReport | null;
2436
+ /**
2437
+ * Audit C-B1: the attached /v1/ws consumer that declared per-tool grant
2438
+ * enforcement (its hello carried "per_tool_grants" and no harness report),
2439
+ * or null. authorizationFor issues a room grant only while one is attached
2440
+ * (Task R2); every delivery path also strips room grants for any other
2441
+ * consumer (consumer-grants.ts). The token is the socket's session object.
2442
+ */
2443
+ private perToolConsumer;
2444
+ notePerToolConsumer(token: object): void;
2445
+ /** Forget `token` when its socket closes; a newer consumer's token is kept. */
2446
+ clearPerToolConsumer(token: object): void;
2447
+ perToolConsumerAttached(): boolean;
2301
2448
  health(): {
2302
2449
  status: "ok";
2303
2450
  identity_id: string;
@@ -2369,6 +2516,12 @@ export declare class BridgeAgent {
2369
2516
  * agent_sessions stays >=1 means two connectors are flapping the same
2370
2517
  * identity (split-brain), not one stable harness. */
2371
2518
  evictions: number;
2519
+ /** Harness tool gate H4: is the attached harness's tool veto active?
2520
+ * null when no connector is attached with a report. false means that
2521
+ * harness refuses every turn without a tool grant. Only this boolean is
2522
+ * on the unauthenticated /healthz; name and version are on the
2523
+ * authenticated GET /v1/status. */
2524
+ harness_tool_veto_active: boolean | null;
2372
2525
  uptime_s: number;
2373
2526
  version: string;
2374
2527
  /** M6: is the self-heal sweep running at all? It is the SOLE writer of the
@@ -0,0 +1,22 @@
1
+ import type { BridgeMessage } from "./bridge-agent.js";
2
+ /**
3
+ * The /v1/ws hello feature a consumer sends to declare per-tool grant
4
+ * enforcement. The runtime exports the same value (connect-agent-runtime
5
+ * data-plane.ts PER_TOOL_GRANTS_FEATURE); the two packages share no
6
+ * dependency, so ws-server.test.ts pins them equal and drives the bridge with
7
+ * the runtime's own hello frame.
8
+ */
9
+ export declare const PER_TOOL_GRANTS_FEATURE = "per_tool_grants";
10
+ /**
11
+ * `m` unchanged for a per-tool consumer; otherwise `m` with any room grant
12
+ * stripped to []. The owner's own turns, which a harness has always been
13
+ * granted for, pass unchanged: the owner's DM, and the owner-spoken relayed
14
+ * voice turn the daemon marks relay_speaker "owner" (audit r2 R2-T1; the SAME
15
+ * rule the connectors apply, Task R2a). Everything else with grants is
16
+ * stripped: an allowlist fails closed on any new granted audience.
17
+ *
18
+ * Never mutates `m`: the buffer and in-flight records keep the original, so a
19
+ * later per-tool consumer still receives it. The stripped copy keeps every
20
+ * other field (audience, relay_speaker) as is.
21
+ */
22
+ export declare function withConsumerGrants(m: BridgeMessage, perToolConsumer: boolean): BridgeMessage;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A connector's self-report, carried on its `/v1/ws` hello (harness tool
3
+ * gate, fix round 1 H4): which harness is attached and whether its tool veto
4
+ * is active. Untrusted input from a loopback client: every field is closed to
5
+ * a known set or a bounded version token, so only booleans/versions reach
6
+ * /healthz, GET /v1/status and the service log.
7
+ */
8
+ export interface HarnessReport {
9
+ readonly name: "hermes" | "openclaw" | "other";
10
+ readonly version: string | null;
11
+ /** "inactive": the harness refuses every turn without a tool grant. */
12
+ readonly tool_veto: "active" | "inactive" | "unknown";
13
+ }
14
+ export declare function parseHarnessReport(raw: unknown): HarnessReport | null;
@@ -16,6 +16,16 @@ export declare function writeSse(res: {
16
16
  writableEnded?: boolean;
17
17
  write: (s: string) => boolean;
18
18
  }, msgs: BridgeMessage[], onWriteError?: (error: unknown) => void): BridgeMessage[];
19
+ /**
20
+ * Audit C-B1 / r2 R2-T2: deliver one SSE batch. An SSE consumer never declares
21
+ * per-tool enforcement, so the WRITTEN copies have room grants stripped; the
22
+ * unwritten tail is requeued as the ORIGINAL messages, never a stripped copy,
23
+ * so a later per-tool consumer still receives the grant.
24
+ */
25
+ export declare function deliverSseBatch(res: Parameters<typeof writeSse>[0], batch: BridgeMessage[], agent: {
26
+ ackRuntimeMessages(ids: string[]): void;
27
+ requeueFront(msgs: BridgeMessage[]): void;
28
+ }, onWriteError?: (error: unknown) => void): void;
19
29
  /**
20
30
  * Once-per-connection write-failure reporter (#335). A throwing `res.write`
21
31
  * means the socket is dying — teardown follows via the request's `'close'` —