@securecoms/agent-bridge 0.26.4 → 0.26.5

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,7 +59,10 @@ 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 `[]`.
64
68
  * SEC-06 is LIVE for these two tools: docs/security/sec-06-runtime-bounds-audit.md.
@@ -1959,14 +1963,25 @@ export declare class BridgeAgent {
1959
1963
  *
1960
1964
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
1961
1965
  * 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
1966
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
1967
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
1968
+ * `is_agent_owner` on a member who is NOT the DM's single human
1969
+ * counterpart gets nothing; on the counterpart it is trusted (see
1970
+ * LIMIT below); OR
1965
1971
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
1966
1972
  * relayed `speaker_identity_id` (passed by the caller only for relay
1967
1973
  * turns) is that same pinned owner, whose roster row is the agent's
1968
1974
  * owner. A speaker claim from any non-relay sender gets nothing.
1969
1975
  * Everything else gets the frozen empty list.
1976
+ *
1977
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
1978
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
1979
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
1980
+ * separates Bob from the real owner is only the server's `is_agent_owner`
1981
+ * flag (connect_channels.py: counterpart user == agent's creator). A
1982
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
1983
+ * connector turns grants into its full toolset. Binding the owner identity
1984
+ * once, outside server control, is an open design follow-up.
1970
1985
  */
1971
1986
  private authorizationFor;
1972
1987
  /**
@@ -2298,6 +2313,19 @@ export declare class BridgeAgent {
2298
2313
  * ws-server.ts right after prior.cleanup() in the eviction path.
2299
2314
  */
2300
2315
  noteSessionEvicted(): void;
2316
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
2317
+ private harness;
2318
+ /**
2319
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
2320
+ * each time the attached harness turns to an inactive veto: such a harness
2321
+ * refuses every turn without a tool grant, so its rooms go quiet.
2322
+ */
2323
+ recordHarnessReport(report: HarnessReport): void;
2324
+ /** Forget `report` when the connector that sent it disconnects. A report
2325
+ * from a newer connector (a different object) is kept. */
2326
+ clearHarnessReport(report: HarnessReport): void;
2327
+ /** The latest harness report, or null (GET /v1/status). */
2328
+ harnessReport(): HarnessReport | null;
2301
2329
  health(): {
2302
2330
  status: "ok";
2303
2331
  identity_id: string;
@@ -2369,6 +2397,12 @@ export declare class BridgeAgent {
2369
2397
  * agent_sessions stays >=1 means two connectors are flapping the same
2370
2398
  * identity (split-brain), not one stable harness. */
2371
2399
  evictions: number;
2400
+ /** Harness tool gate H4: is the attached harness's tool veto active?
2401
+ * null when no connector is attached with a report. false means that
2402
+ * harness refuses every turn without a tool grant. Only this boolean is
2403
+ * on the unauthenticated /healthz; name and version are on the
2404
+ * authenticated GET /v1/status. */
2405
+ harness_tool_veto_active: boolean | null;
2372
2406
  uptime_s: number;
2373
2407
  version: string;
2374
2408
  /** M6: is the self-heal sweep running at all? It is the SOLE writer of the
@@ -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;
package/dist/index.js CHANGED
@@ -28638,7 +28638,7 @@ function assertManagedName(name) {
28638
28638
  }
28639
28639
  function isLocalOnlyName(name) {
28640
28640
  const n = name.toLowerCase().replace(/[. ]+$/, "");
28641
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28641
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
28642
28642
  }
28643
28643
  function atomicWrite(path, body, deps) {
28644
28644
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -28697,6 +28697,26 @@ function lineHash(line) {
28697
28697
  return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
28698
28698
  }
28699
28699
  var LOCAL_REPORT_FILE = "local-config.json";
28700
+ var SECRET_PATH_COUNT_MAX = 1e4;
28701
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
28702
+ function parseSecretPathsReport(raw) {
28703
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
28704
+ const r = raw;
28705
+ const f = r.outside_by_field;
28706
+ if (r.mode !== "report" && r.mode !== "enforce" || !isCount(r.inside) || !isCount(r.allowlisted) || !isCount(r.outside) || !f || typeof f !== "object" || Array.isArray(f) || !isCount(f.headers_file) || !isCount(f.postgres_url_file) || !isCount(f.search_key_file) || f.sqlite !== void 0 && !isCount(f.sqlite)) return void 0;
28707
+ return {
28708
+ mode: r.mode,
28709
+ inside: r.inside,
28710
+ allowlisted: r.allowlisted,
28711
+ outside: r.outside,
28712
+ outside_by_field: {
28713
+ headers_file: f.headers_file,
28714
+ postgres_url_file: f.postgres_url_file,
28715
+ search_key_file: f.search_key_file,
28716
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
28717
+ }
28718
+ };
28719
+ }
28700
28720
  var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
28701
28721
  var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
28702
28722
  var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
@@ -28765,7 +28785,8 @@ function readLocalReport(home, identityId) {
28765
28785
  persona,
28766
28786
  has_search_key: hasSearchKey,
28767
28787
  has_llm_key: hasLlmKey,
28768
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
28788
+ legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
28789
+ secret_paths: parseSecretPathsReport(doc.secret_paths)
28769
28790
  };
28770
28791
  }
28771
28792
  function deleteLegacyMemorySources(home, identityId, ids) {
@@ -29963,7 +29984,11 @@ var BridgeAgent = class _BridgeAgent {
29963
29984
  // Inert legacy files only. `readLocalReport` revalidates the current
29964
29985
  // regular-file bytes and strips every local path before this leaves the
29965
29986
  // machine.
29966
- legacy_memory: local?.legacy_memory ?? []
29987
+ legacy_memory: local?.legacy_memory ?? [],
29988
+ // §B.4: counts of server-named secret paths inside / allowlisted /
29989
+ // outside the secrets directory, and the runtime's mode. Numbers and
29990
+ // one enum only; readLocalReport rebuilds it so no path rides along.
29991
+ secret_paths: local?.secret_paths ?? null
29967
29992
  };
29968
29993
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
29969
29994
  method: "POST",
@@ -32191,14 +32216,25 @@ var BridgeAgent = class _BridgeAgent {
32191
32216
  *
32192
32217
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32193
32218
  * the kill switch is on, the channel is a DM, and EITHER
32194
- * (a) audience is owner_dm AND the sender is the pinned structural owner
32195
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32196
- * alone gets nothing; OR
32219
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
32220
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
32221
+ * `is_agent_owner` on a member who is NOT the DM's single human
32222
+ * counterpart gets nothing; on the counterpart it is trusted (see
32223
+ * LIMIT below); OR
32197
32224
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32198
32225
  * relayed `speaker_identity_id` (passed by the caller only for relay
32199
32226
  * turns) is that same pinned owner, whose roster row is the agent's
32200
32227
  * owner. A speaker claim from any non-relay sender gets nothing.
32201
32228
  * Everything else gets the frozen empty list.
32229
+ *
32230
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
32231
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
32232
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
32233
+ * separates Bob from the real owner is only the server's `is_agent_owner`
32234
+ * flag (connect_channels.py: counterpart user == agent's creator). A
32235
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
32236
+ * connector turns grants into its full toolset. Binding the owner identity
32237
+ * once, outside server control, is an open design follow-up.
32202
32238
  */
32203
32239
  authorizationFor(channelId, senderId, relaySpeakerId) {
32204
32240
  const meta = this.freshMeta(channelId);
@@ -33744,6 +33780,31 @@ var BridgeAgent = class _BridgeAgent {
33744
33780
  noteSessionEvicted() {
33745
33781
  this.evictions++;
33746
33782
  }
33783
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
33784
+ harness = null;
33785
+ /**
33786
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
33787
+ * each time the attached harness turns to an inactive veto: such a harness
33788
+ * refuses every turn without a tool grant, so its rooms go quiet.
33789
+ */
33790
+ recordHarnessReport(report) {
33791
+ const wasInactive = this.harness?.tool_veto === "inactive";
33792
+ this.harness = report;
33793
+ if (report.tool_veto === "inactive" && !wasInactive) {
33794
+ writeServiceLog2("warn", "harness.tool_veto_inactive", {
33795
+ harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
33796
+ });
33797
+ }
33798
+ }
33799
+ /** Forget `report` when the connector that sent it disconnects. A report
33800
+ * from a newer connector (a different object) is kept. */
33801
+ clearHarnessReport(report) {
33802
+ if (this.harness === report) this.harness = null;
33803
+ }
33804
+ /** The latest harness report, or null (GET /v1/status). */
33805
+ harnessReport() {
33806
+ return this.harness;
33807
+ }
33747
33808
  health() {
33748
33809
  let joined = 0;
33749
33810
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -33813,6 +33874,7 @@ var BridgeAgent = class _BridgeAgent {
33813
33874
  delivery_pings: this.pingsReceived,
33814
33875
  agent_sessions: this.authSessions,
33815
33876
  evictions: this.evictions,
33877
+ harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
33816
33878
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
33817
33879
  version: BRIDGE_VERSION,
33818
33880
  ...this.memory.stats?.() ?? {}
@@ -34345,6 +34407,9 @@ function createHttpServer(agent, opts) {
34345
34407
  const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
34346
34408
  return json(res, 200, r);
34347
34409
  }
34410
+ if (method === "GET" && path === "/v1/status") {
34411
+ return json(res, 200, { harness: agent.harnessReport() });
34412
+ }
34348
34413
  if (method === "POST" && path === "/v1/status") {
34349
34414
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
34350
34415
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -34434,6 +34499,17 @@ function createHttpServer(agent, opts) {
34434
34499
  return { server, closeAllSseStreams: () => closeSse() };
34435
34500
  }
34436
34501
 
34502
+ // src/harness-report.ts
34503
+ var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
34504
+ function parseHarnessReport(raw) {
34505
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
34506
+ const r = raw;
34507
+ const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
34508
+ const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
34509
+ const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
34510
+ return Object.freeze({ name, version, tool_veto });
34511
+ }
34512
+
34437
34513
  // src/ws-server.ts
34438
34514
  import { WebSocketServer, WebSocket } from "ws";
34439
34515
  var PING_INTERVAL_MS = 3e4;
@@ -34564,6 +34640,7 @@ function attachAgentWebSocket(server, agent, opts) {
34564
34640
  }
34565
34641
  delivered.clear();
34566
34642
  if (current?.sock === sock) current = null;
34643
+ if (session.harness) agent.clearHarnessReport(session.harness);
34567
34644
  };
34568
34645
  current = { sock, cleanup, sweepStale };
34569
34646
  sock.on("close", cleanup);
@@ -34626,7 +34703,11 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34626
34703
  return;
34627
34704
  }
34628
34705
  if (m.type === "hello") {
34629
- session.consumer = "runtime";
34706
+ const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
34707
+ if (report) {
34708
+ session.harness = report;
34709
+ agent.recordHarnessReport(report);
34710
+ } else session.consumer = "runtime";
34630
34711
  sendJson(sock, { type: "hello", features: ["nack"] });
34631
34712
  return;
34632
34713
  }
@@ -35,6 +35,28 @@ export interface LocalReport {
35
35
  has_search_key: boolean;
36
36
  has_llm_key: boolean;
37
37
  legacy_memory: LegacyMemoryReport[];
38
+ /** §B.4 counts; undefined when the runtime reported none or the block was malformed. */
39
+ secret_paths?: SecretPathsReport;
40
+ }
41
+ /**
42
+ * §B.4: how many server-named secret paths in the managed config are inside
43
+ * the secrets directory, owner-allowlisted, or outside it, and the runtime's
44
+ * mode. Rebuilt field by field from the runtime's file: numbers and one enum,
45
+ * so no path (the owner's `allowed_secret_paths` sits in the same file) can
46
+ * ride along to the backend.
47
+ */
48
+ export interface SecretPathsReport {
49
+ mode: "report" | "enforce";
50
+ inside: number;
51
+ allowlisted: number;
52
+ outside: number;
53
+ /** `sqlite` (H1) is absent from a runtime that predates it; absent means unknown, not zero. */
54
+ outside_by_field: {
55
+ headers_file: number;
56
+ postgres_url_file: number;
57
+ search_key_file: number;
58
+ sqlite?: number;
59
+ };
38
60
  }
39
61
  export type LegacyMemoryKind = "global-memory" | "conversation-history" | "captured-memory";
40
62
  export declare const LEGACY_MEMORY_FACT_MAX_BYTES = 1024;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.26.4";
1
+ export declare const BRIDGE_VERSION = "0.26.5";
@@ -1,80 +1,3 @@
1
- /**
2
- * Agent-facing keepalive WebSocket (/v1/ws).
3
- *
4
- * Adds a single persistent bidirectional socket for the AGENT⇄DAEMON hop,
5
- * modeled on AgentVault's keepalive WS (token in `?token=` query OR
6
- * `Authorization: Bearer`). This replaces the curl/long-poll loop: the agent
7
- * receives live inbound messages as JSON frames and sends/reports status over
8
- * the same socket.
9
- *
10
- * Protocol (all frames are JSON text):
11
- * Daemon → agent:
12
- * { "type":"message", channel_id, message_id, sender_id, text, ts,
13
- * authorization:{version,audience,sender_membership,tool_grants} } // inbound, decrypted
14
- * { "type":"sent", message_id } // ack of a send
15
- * { "type":"ok" } // ack of a status report
16
- * { "type":"error", error, message, detail? } // canonical codes (see errors.ts):
17
- * // channel_pending | rate_limited |
18
- * // invalid_request | agent_paused |
19
- * // send_forbidden (those two are
20
- * // TERMINAL: the in_reply_to message
21
- * // is acked by the daemon, no retry) |
22
- * // group_superseded (NOT terminal:
23
- * // the reply is republished after
24
- * // the agent rejoins)
25
- * Agent → daemon:
26
- * { "type":"send", channel_id, text?, a2a?, idempotency_key?,
27
- * in_reply_to? } // exactly one of text | a2a. SEC-06 #18: in_reply_to
28
- * // and/or idempotency_key make the send idempotent — a
29
- * // deterministic keyed message_id and a durable reply
30
- * // intent written before the publish, so a retry or a
31
- * // redelivery republishes the SAME reply
32
- * { "type":"status", channel_id, state, detail? }
33
- * { "type":"ack", message_id } // confirms a delivered `message` was consumed
34
- * { "type":"nack", message_id, reason? } // final-wave I1(b): THIS message's turn
35
- * // failed — requeued behind a backoff; the
36
- * // socket and other channels' turns stay up
37
- * { "type":"hello", features? } // → { "type":"hello", features:["nack"] }; a
38
- * // runtime nacks only after seeing "nack" here
39
- * { "type":"action_begin", channel_id } // → { "type":"action_begun", run_id }
40
- * { "type":"action_start", run_id, node_type, title, parent_id? } // → { "type":"action_started", node_id }
41
- * { "type":"action_update", run_id, node_id, status?, detail? } // → { "type":"ok" }
42
- * { "type":"action_finish", run_id, node_id, status, detail? } // → { "type":"ok" }
43
- * { "type":"action_settle", run_id, result_message_id? } // → { "type":"action_settled", run_id }
44
- *
45
- * Inbound delivery is at-least-once. A delivered `message` is held in-flight
46
- * until the agent sends a matching `ack`; if the socket drops first, the frame
47
- * is re-buffered and redelivered to the next consumer. The agent dedups by
48
- * message_id. SEC-06 #18: a redelivered message whose reply already has a
49
- * durable intent is NOT handed to the agent again; the daemon republishes that
50
- * reply itself (same message_id), so the model does not run twice.
51
- *
52
- * Bounded work (Phase 3):
53
- * - F2 ordering: at most ONE message per channel is in flight on a socket;
54
- * the next message for that channel is sent only after the previous one is
55
- * acked (or retired). A backlog replays in order instead of flooding the
56
- * runtime, which used to answer the overflow with a "busy" reply and ack it.
57
- * - F1 attempt cap: every delivery is counted durably; a message that
58
- * reaches the cap is dead-lettered (content-free log + ack + one
59
- * content-free notice to the channel per hour) rather than redelivered
60
- * forever, and a channel where the agent is paused is not delivered at
61
- * all (no model call).
62
- * - Final-wave I1: a FAILED delivery (a `nack`, or the socket closing with
63
- * it in flight — older runtimes close on any failed turn) is requeued
64
- * behind a 2^attempts × 5 s backoff. A nack fails only its own message.
65
- * Messages never handed over (mid-flush close, a failed frame write) go
66
- * back without that penalty.
67
- * - #842: a delivery still un-acked after INFLIGHT_STALE_AFTER_MS is LOGGED
68
- * once (`runtime_inbox.inflight_stale`) and otherwise left alone. There
69
- * is deliberately no lease: see INFLIGHT_STALE_AFTER_MS.
70
- *
71
- * Single consumer: the daemon serves one agent per process. A new `/v1/ws`
72
- * connection replaces the prior one (newest wins, mirroring the SSE path); the
73
- * replaced socket's un-acked in-flight messages are re-buffered.
74
- *
75
- * Keepalive uses the ws library's server-side ping (the protocol-level ping
76
- * frame, not an application message); dead sockets are reaped via pong tracking.
77
- */
78
1
  import type { Server as HttpServer } from "node:http";
79
2
  import type { BridgeAgent } from "./bridge-agent.js";
80
3
  export interface AgentWebSocketOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.26.4",
3
+ "version": "0.26.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"