@securecoms/agent-bridge 0.35.0 → 0.36.0

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.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * MCP client Phase 1, Task 18 (spec §8 items 1, 2, 5, 6): the owner's `mcp_catalog_request` and the bridge's
3
+ * `mcp_catalog` reply, as pure functions. The bridge, never the runtime and never the model, builds and sends the reply.
4
+ *
5
+ * request {v: 1, type: "mcp_catalog_request", server_id, request_id} exact keys; request_id = 32 lowercase hex
6
+ * reply {v: 1, type: "mcp_catalog", server_id, request_id, vendor, issuer, listed_at, tools, truncated}
7
+ *
8
+ * The runtime answers the loopback `mcp_catalog_list` (connect-agent-runtime data-plane.ts, mcp/catalog-list.ts) with
9
+ * `{ok: true, server_id, vendor, listed_at, tools: [{name, projection?, grantable, reason}], truncated}` or
10
+ * `{ok: false, server_id, state}`. validateCatalogListReply keeps only those fields, re-checked: anything else the runtime
11
+ * sends (an issuer, a token, a path) never reaches the message. The issuer is the bridge's own catalog row (display only;
12
+ * the web signs from its own row, spec §8 item 6).
13
+ *
14
+ * Size (spec §8 item 5, plan P-4): the UTF-8 JSON of the whole body is at most MCP_CATALOG_MAX_BYTES. A tool that does
15
+ * not fit becomes `{name, grantable: false, reason: "too_large"}` and the body says `truncated: true`. The measured wire
16
+ * expansion of the bridge send path is pinned by bridge-agent.mcp-catalog.test.ts (P-4).
17
+ */
18
+ /** 384 KiB (spec §8 item 5). P-4 measured the real send path: see bridge-agent.mcp-catalog.test.ts. */
19
+ export declare const MCP_CATALOG_MAX_BYTES: number;
20
+ /** The runtime's listing cap (spec §9: 64 tools per server). A reply with more is malformed. */
21
+ export declare const MCP_CATALOG_MAX_TOOLS = 64;
22
+ /** The bridge's wait for the runtime's loopback answer. The runtime's own worst case is under 40 s; the web waits 60 s. */
23
+ export declare const MCP_CATALOG_LIST_TIMEOUT_MS = 50000;
24
+ /** A loopback catalog lister (ws-server.ts): resolves the runtime's result frame, or null (timeout, closed, too many pending). */
25
+ export type McpCatalogLister = (serverId: string, timeoutMs: number) => Promise<unknown>;
26
+ /** The runtime's content-free admission reasons (connect-agent-runtime mcp/admission.ts AdmissionReason). */
27
+ export declare const MCP_CATALOG_REASONS: ReadonlySet<string>;
28
+ type Bag = Record<string, unknown>;
29
+ export interface McpCatalogRequest {
30
+ readonly serverId: string;
31
+ readonly requestId: string;
32
+ }
33
+ /** The parsed body of an `mcp_catalog_request`, or null for anything that is not exactly that shape. */
34
+ export declare function parseMcpCatalogRequest(body: unknown): McpCatalogRequest | null;
35
+ export type McpCatalogTool = {
36
+ name: string;
37
+ projection: Bag;
38
+ grantable: true;
39
+ reason: null;
40
+ } | {
41
+ name: string;
42
+ grantable: false;
43
+ reason: string;
44
+ };
45
+ export interface McpCatalogListed {
46
+ readonly listedAt: string;
47
+ readonly truncated: boolean;
48
+ readonly tools: readonly McpCatalogTool[];
49
+ }
50
+ /**
51
+ * The runtime's `mcp_catalog_list_result` for `serverId` (whose policy vendor is `vendor`), re-checked; null when it is a
52
+ * failure (`ok: false`) or anything malformed. Never throws.
53
+ */
54
+ export declare function validateCatalogListReply(frame: unknown, serverId: string, vendor: string): McpCatalogListed | null;
55
+ export interface McpCatalogBody {
56
+ v: 1;
57
+ type: "mcp_catalog";
58
+ server_id: string;
59
+ request_id: string;
60
+ vendor: string;
61
+ issuer: string;
62
+ listed_at: string;
63
+ tools: McpCatalogTool[];
64
+ truncated: boolean;
65
+ }
66
+ export declare const mcpCatalogBodyBytes: (b: McpCatalogBody) => number;
67
+ /**
68
+ * The `mcp_catalog` body, cut to MCP_CATALOG_MAX_BYTES: every tool starts as its too_large stub; then, in order, a tool's
69
+ * full entry replaces its stub when the body still fits. Replacing one array element changes the size by exactly the
70
+ * difference of the two entries' JSON, so the running total is exact. Order and names are kept; nothing is dropped.
71
+ */
72
+ export declare function buildMcpCatalogBody(i: {
73
+ serverId: string;
74
+ requestId: string;
75
+ vendor: string;
76
+ issuer: string;
77
+ reply: McpCatalogListed;
78
+ }): McpCatalogBody;
79
+ export {};
@@ -0,0 +1,35 @@
1
+ /**
2
+ * MCP client Phase 1, Task 18 (spec §8 item 2, OQ-15 ruling): the rate limits on `mcp_catalog_request`.
3
+ *
4
+ * per server: at most 1 listing in flight (a request meanwhile is ignored), 3 per rolling 60 s, 20 per rolling hour;
5
+ * per agent: at most 40 per rolling hour.
6
+ *
7
+ * A request counts when it is ADMITTED (the bridge then asks the runtime, which spends vendor quota), whatever the runtime
8
+ * answers. Over-limit and in-flight requests are ignored, never queued, and do not count. One `catalog_rate_limited`
9
+ * observation per window per scope (`onRateLimited`, content-free scope names). In bridge memory only: the counters
10
+ * reset on restart (the limit protects vendor quota and the owner's other devices; it is not a security boundary).
11
+ */
12
+ export type McpCatalogRateVerdict = "ok" | "in_flight" | "rate_limited";
13
+ export type McpCatalogRateScope = "server_minute" | "server_hour" | "agent_hour";
14
+ export declare const MCP_CATALOG_PER_SERVER_MINUTE = 3;
15
+ export declare const MCP_CATALOG_PER_SERVER_HOUR = 20;
16
+ export declare const MCP_CATALOG_PER_AGENT_HOUR = 40;
17
+ export interface McpCatalogRateDeps {
18
+ now?: () => number;
19
+ /** Called once per window per scope when a request is refused for the rate. */
20
+ onRateLimited?: (scope: McpCatalogRateScope) => void;
21
+ }
22
+ export declare class McpCatalogRateLimiter {
23
+ private readonly now;
24
+ private readonly onRateLimited;
25
+ private readonly inFlight;
26
+ private readonly perServer;
27
+ private agent;
28
+ /** Per scope key: no further log until this time (the end of the window that was full when it last logged). */
29
+ private readonly quietUntil;
30
+ constructor(deps?: McpCatalogRateDeps);
31
+ /** "ok" admits (and counts) the request and marks the server in flight; the caller MUST call release(serverId). */
32
+ admit(serverId: string): McpCatalogRateVerdict;
33
+ release(serverId: string): void;
34
+ private limited;
35
+ }
@@ -45,7 +45,7 @@ export declare function decodeReplyBody(bytes: Uint8Array): ReplyBody;
45
45
  * byte-identical to the four-argument form, so every existing row and every uncredited reply verifies unchanged; a
46
46
  * flipped or nulled reply_credit column, or a credited row read by an older bridge, fails verification.
47
47
  */
48
- export declare function replyBodyMac(key: Uint8Array, replyId: string, channelId: string, body: Uint8Array, credit?: ReplyCredit): string;
48
+ export declare function replyBodyMac(key: Uint8Array, replyId: string, channelId: string, body: Uint8Array, credit?: ReplyCredit, mcpTurn?: boolean): string;
49
49
  /** The ordinal of an unkeyed reply part (`p:<n>`), or null for a keyed part. */
50
50
  export declare function partOrdinal(part: string): number | null;
51
51
  /**
@@ -13,6 +13,19 @@ export interface FsDeps {
13
13
  unlink: (p: string) => void;
14
14
  }
15
15
  export declare function runtimeConfigDir(home: string, identityId: string): string;
16
+ /**
17
+ * The files the header says the backend never sends: the LLM key files
18
+ * (legacy `llm.key` / `llm.key.host` and the per-provider
19
+ * `llm.key.<slug>` / `llm.key.<slug>.host`, see connect-agent-runtime
20
+ * llm-source.ts) and the runtime's `local-config.json`. A backend that could
21
+ * write one of these could replace a customer's key, or plant its own key and
22
+ * host binding so decrypted prompts go to the attacker's provider account.
23
+ * The comparison ignores case (macOS volumes are case-insensitive by default)
24
+ * and trailing dots and spaces (Windows drops them), so neither "LLM.KEY" nor
25
+ * "llm.key." lands on the real file.
26
+ */
27
+ /** Exported for the same-build invariant test (ws-server.test.ts): MCP_POLICY_WRITER_FEATURE requires mcp-policy.json here. */
28
+ export declare function isLocalOnlyName(name: string): boolean;
16
29
  /** Owner grants (spec §7.8): the runtime honours a manifest-marked grant only for a tool listed here. */
17
30
  export declare const MANIFEST_POLICY_FILE = "manifest-policy.json";
18
31
  export interface ManifestPolicyDoc {
@@ -28,6 +41,48 @@ export interface ManifestPolicyDoc {
28
41
  export declare function removeManagedLlmSource(home: string, identityId: string, deps?: FsDeps): void;
29
42
  /** The bridge's own writer for manifest-policy.json (writeManagedFiles refuses the name). Atomic, 0600. Throws. */
30
43
  export declare function writeManifestPolicy(home: string, identityId: string, policy: ManifestPolicyDoc, deps?: FsDeps): void;
44
+ /**
45
+ * MCP Phase 1 Task 15 (spec §12.1, §6.5): the bridge-written MCP policy. The runtime reader
46
+ * (`connect-agent-runtime/src/mcp/policy-file.ts`) accepts exactly these keys; `mcp-policy-reader.parity.test.ts`
47
+ * feeds every document the bridge can compute through it.
48
+ */
49
+ export declare const MCP_POLICY_FILE = "mcp-policy.json";
50
+ export declare const MCP_POLICY_TOP_STATES: readonly ["ok", "not_entitled", "kill_switch", "suspended", "no_config", "switch_off", "no_manifest", "malformed", "no_capability", "section_absent"];
51
+ export type McpPolicyTopState = (typeof MCP_POLICY_TOP_STATES)[number];
52
+ export type McpPolicyAuth = {
53
+ readonly mode: "oauth";
54
+ readonly issuer: string;
55
+ readonly client: "securecoms_cimd" | "customer";
56
+ readonly client_id: string;
57
+ } | {
58
+ readonly mode: "key";
59
+ };
60
+ export type McpPolicyServer = {
61
+ readonly state: "refused";
62
+ } | {
63
+ readonly state: "ok";
64
+ readonly vendor: string;
65
+ readonly url: string;
66
+ readonly auth: McpPolicyAuth;
67
+ readonly tools: Readonly<Record<string, {
68
+ readonly sha256: string;
69
+ readonly class: "read";
70
+ }>>;
71
+ };
72
+ export interface McpPolicyDoc {
73
+ readonly v: 1;
74
+ readonly state: McpPolicyTopState;
75
+ /** Round 2 H-B: in EVERY document, = signedMcpPresent || mcp_seen. */
76
+ readonly r3_hold: boolean;
77
+ readonly servers: Readonly<Record<string, McpPolicyServer>>;
78
+ /**
79
+ * C-NEW-1 (2026-10-30): in EVERY document. The `ok` document copies the signed `mcp.removed` (server ids, or `"*"`);
80
+ * every other document writes `[]`. The runtime deletes on `ok` only what this names; absence never deletes.
81
+ */
82
+ readonly remove: readonly string[] | "*";
83
+ }
84
+ /** The bridge's own writer for mcp-policy.json (writeManagedFiles refuses the name). Atomic, 0600, dirs 0700. Throws. */
85
+ export declare function writeMcpPolicy(home: string, identityId: string, doc: McpPolicyDoc, deps?: FsDeps): void;
31
86
  export declare function writeManagedFiles(home: string, identityId: string, files: Record<string, string>, deps?: FsDeps): void;
32
87
  /** Every path skills.json names, checked on disk. Contents are never read. */
33
88
  export declare function checkConfiguredPaths(skillsJson: Record<string, unknown>, statFn?: (p: string) => {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Tool status, Phase 0 (spec §3.5; plan Task 9; round 1 H-3; round 2 M-2, M-8; architect ruling M-2): the post of the
3
+ * owner-DM last-turn record. The only code that moves the record off the host besides the apply-time report.
4
+ *
5
+ * Rules (ruling M-2: 15-minute UTC buckets, A1 §8):
6
+ * - two slots: `open` holds the latest noted record while its bucket (`at` + 15 min) has not closed; `closed` holds
7
+ * the latest noted record whose bucket has closed. An attempt only ever posts `closed`, never `open`;
8
+ * - attempts happen only from the one timer, set for the first bucket boundary strictly after now (advanced by whole
9
+ * buckets while before a 429's notBefore) plus a jitter in [0, 60 s) drawn fresh per timer from `random`;
10
+ * - compared with sameTuple against the LAST SUCCESSFULLY POSTED record (`at` is not in the tuple): unchanged posts
11
+ * nothing, so a change and a revert inside one bucket post nothing;
12
+ * - the last-posted record advances only on a 2xx (round 1 H-3);
13
+ * - 429 sets notBefore to the attempt time plus its Retry-After (seconds, at least 60 s; missing or invalid means 60 s);
14
+ * - any other 4xx is terminal for that record: no retry until note() brings a different tuple (round 2 M-2);
15
+ * - 5xx, a timeout and a network error leave `closed` pending for the next boundary.
16
+ * carry() is what the apply-time report sends: the closed record, so a report inside a bucket never reveals that
17
+ * bucket's change early. Never throws to the caller. Advisory: the record authorizes nothing.
18
+ */
19
+ import { type OwnerDmTurnRecord } from "./grants/tool-status.js";
20
+ /** Ruling M-2: an attempt lands in [boundary, boundary + 60 s). */
21
+ export declare const TOOL_STATUS_JITTER_MS = 60000;
22
+ /** What one post attempt observed: an HTTP status (with the raw Retry-After header), a deadline, or a network error. */
23
+ export type ToolStatusPostResult = {
24
+ readonly status: number;
25
+ readonly retryAfter?: string | null;
26
+ } | "timeout" | "network";
27
+ export type ToolStatusFailure = "http_error" | "timeout" | "network";
28
+ export interface ToolStatusSenderOptions {
29
+ post: (rec: OwnerDmTurnRecord) => Promise<ToolStatusPostResult>;
30
+ now?: () => number;
31
+ setTimer?: (fn: () => void, ms: number) => unknown;
32
+ clearTimer?: (handle: unknown) => void;
33
+ /** The jitter source, in [0, 1). Default Math.random. Depends on nothing in the record. */
34
+ random?: () => number;
35
+ /** Called once per failed attempt with its class only (never the URL, the body or a status text). */
36
+ onFailure?: (kind: ToolStatusFailure) => void;
37
+ }
38
+ /** The default timer: setTimeout, unref'd so a pending post never holds the process open. */
39
+ export declare function defaultToolStatusSetTimer(fn: () => void, ms: number): unknown;
40
+ export declare function defaultToolStatusClearTimer(h: unknown): void;
41
+ export declare class ToolStatusSender {
42
+ private readonly postFn;
43
+ private readonly now;
44
+ private readonly setTimer;
45
+ private readonly clearTimer;
46
+ private readonly random;
47
+ private readonly onFailure;
48
+ /** The latest noted record while its bucket is still open. */
49
+ private open;
50
+ /** The latest noted record whose bucket has closed: the only record an attempt or carry() may send. */
51
+ private closed;
52
+ private lastPosted;
53
+ private terminalFor;
54
+ private notBefore;
55
+ private timer;
56
+ private inFlight;
57
+ private shut;
58
+ constructor(o: ToolStatusSenderOptions);
59
+ note(rec: OwnerDmTurnRecord): void;
60
+ /** Ruling M-2: what the apply-time report carries. The closed record only; an open bucket is never revealed early. */
61
+ carry(): OwnerDmTurnRecord | null;
62
+ /** R2-M1: the record belongs to an owner who is no longer the manifest's; forget it and what was posted. */
63
+ drop(): void;
64
+ close(): void;
65
+ private cancelTimer;
66
+ private promote;
67
+ private pending;
68
+ private schedule;
69
+ private fire;
70
+ private attempt;
71
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.35.0";
1
+ export declare const BRIDGE_VERSION = "0.36.0";
@@ -51,7 +51,7 @@ export declare const MEMORY_UNIFIED_FEATURE = "memory_unified";
51
51
  * fake against this list at run time (7b gate fix: a member added here without
52
52
  * a fake made three tests throw "is not a function").
53
53
  */
54
- export declare const WS_AGENT_MEMBERS: readonly ["ackRuntimeMessages", "action", "admitRuntimeDelivery", "applyMemoryNote", "clearHarnessReport", "clearManifestConsumer", "clearPerToolConsumer", "clearSearchProvidersSocket", "clearUtilityToolsSocket", "harnessGrantsWithheld", "signedSearchProvider", "isRuntimeMessageRetired", "noteConsumerCapability", "noteManifestConsumer", "noteRoomCapReached", "noteSearchProvidersSocket", "noteUtilityToolsSocket", "noteNotesConsumer", "notePerToolConsumer", "noteRecallConsumer", "noteUnifiedConsumer", "noteSessionClose", "noteSessionEvicted", "noteSessionOpen", "notesAppliedGeneration", "notesForDelivery", "recallForDelivery", "unifiedFeatureOn", "unifiedForDelivery", "recordHarnessReport", "recordRuntimeReply", "reportStatusGated", "requeueFailedRuntimeDeliveries", "requeueFront", "runtimeReplyReceipt", "send", "sendA2A", "subscribe", "takeDeliverable", "wakeListeners"];
54
+ export declare const WS_AGENT_MEMBERS: readonly ["ackRuntimeMessages", "action", "admitRuntimeDelivery", "applyMemoryNote", "clearHarnessReport", "clearManifestConsumer", "clearPerToolConsumer", "clearSearchProvidersSocket", "clearUtilityToolsSocket", "clearMcpClientSocket", "harnessGrantsWithheld", "signedSearchProvider", "r3Hold", "isRuntimeMessageRetired", "noteConsumerCapability", "noteManifestConsumer", "noteRoomCapReached", "noteSearchProvidersSocket", "noteUtilityToolsSocket", "noteMcpClientSocket", "noteMcpCatalogLister", "noteNotesConsumer", "notePerToolConsumer", "noteRecallConsumer", "noteUnifiedConsumer", "noteSessionClose", "noteSessionEvicted", "noteSessionOpen", "notesAppliedGeneration", "notesForDelivery", "recallForDelivery", "unifiedFeatureOn", "unifiedForDelivery", "recordHarnessReport", "recordRuntimeReply", "reportStatusGated", "requeueFailedRuntimeDeliveries", "requeueFront", "runtimeReplyReceipt", "send", "sendA2A", "subscribe", "takeDeliverable", "wakeListeners"];
55
55
  export type WsAgent = Pick<BridgeAgent, (typeof WS_AGENT_MEMBERS)[number]>;
56
56
  export declare function attachAgentWebSocket(server: HttpServer, agent: WsAgent, opts: AgentWebSocketOptions): {
57
57
  close(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -34,6 +34,7 @@
34
34
  "prepublishOnly": "bash scripts/pack-smoke.sh",
35
35
  "build": "tsc && node esbuild.config.mjs",
36
36
  "test": "vitest run",
37
+ "test:skew": "vitest run --config vitest.skew.config.ts",
37
38
  "test:watch": "vitest"
38
39
  },
39
40
  "dependencies": {