@securecoms/agent-bridge 0.26.3 → 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.
package/dist/bin.js CHANGED
@@ -28512,11 +28512,26 @@ function clampContent(content, max = MAX_MESSAGE_CHARS) {
28512
28512
  const marker = "\u2026";
28513
28513
  return content.slice(0, Math.max(0, max - marker.length)) + marker;
28514
28514
  }
28515
+ var FAST_DEFER_RULE = `Whenever you cannot answer completely from what is shown here, never say you can't, that you lack a tool, access or real-time data: reply with one short sentence like "Let me check that." and end with the exact token [[DEFER]] so the full agent answers next.`;
28516
+ var REFUSAL_PATTERNS = [
28517
+ // "I can't / cannot / am unable to / am not able to <check|access|...>", optional adverb.
28518
+ /\b(?:can't|cannot|can\s+not|unable\s+to|not\s+able\s+to)\s+(?:\w+ly\s+)?(?:check|access|look|see|browse|search|get|provide|give\s+you|find|retrieve|fetch|pull|know|help\s+with\s+(?:that|this)|do\s+that)\b/,
28519
+ // "I don't / do not / doesn't have (a|any|the)? <tool|access|ability|real-time|live ...>".
28520
+ /\b(?:don't|do\s+not|doesn't|does\s+not|lack)\s+(?:have\s+)?(?:a\s+|any\s+|the\s+)?(?:tool|tools|access|ability|capability|real[-\s]?time|live|current|internet|web|way\s+to)\b/,
28521
+ /\bno\s+(?:access|tools?|internet|web\s+access|real[-\s]?time|live\s+data|way\s+to\s+(?:check|access|know|see))\b/,
28522
+ /\bas\s+an\s+(?:ai|a\.i\.|language\s+model)\b/,
28523
+ /\bbeyond\s+my\s+(?:capabilities|abilities|capability)\b/,
28524
+ /\b(?:knowledge\s+cutoff|training\s+data)\b/
28525
+ ];
28526
+ function isRefusal(reply) {
28527
+ const t = reply.toLowerCase().replace(/[‘’]/g, "'");
28528
+ return REFUSAL_PATTERNS.some((re) => re.test(t));
28529
+ }
28515
28530
  function buildFastMessages(args2) {
28516
28531
  const cap = args2.charCap ?? FAST_RECENT_CHARS_CAP;
28517
28532
  const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
28518
28533
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
28519
- const system = `${head} ${args2.guidance.trim()} ${CONTINUITY_CLAUSE}`;
28534
+ const system = `${head} ${args2.guidance.trim()} ${FAST_DEFER_RULE} ${CONTINUITY_CLAUSE}`;
28520
28535
  const recent = args2.memory.recent.map((t) => ({
28521
28536
  role: t.role === "agent" ? "assistant" : "user",
28522
28537
  content: t.text
@@ -28775,7 +28790,7 @@ function assertManagedName(name) {
28775
28790
  }
28776
28791
  function isLocalOnlyName(name) {
28777
28792
  const n = name.toLowerCase().replace(/[. ]+$/, "");
28778
- return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json";
28793
+ return n === "llm.key" || n.startsWith("llm.key.") || n === "local-config.json" || n === "secrets";
28779
28794
  }
28780
28795
  function atomicWrite(path, body, deps) {
28781
28796
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -28834,6 +28849,26 @@ function lineHash(line) {
28834
28849
  return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
28835
28850
  }
28836
28851
  var LOCAL_REPORT_FILE = "local-config.json";
28852
+ var SECRET_PATH_COUNT_MAX = 1e4;
28853
+ var isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= SECRET_PATH_COUNT_MAX;
28854
+ function parseSecretPathsReport(raw) {
28855
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
28856
+ const r = raw;
28857
+ const f = r.outside_by_field;
28858
+ 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;
28859
+ return {
28860
+ mode: r.mode,
28861
+ inside: r.inside,
28862
+ allowlisted: r.allowlisted,
28863
+ outside: r.outside,
28864
+ outside_by_field: {
28865
+ headers_file: f.headers_file,
28866
+ postgres_url_file: f.postgres_url_file,
28867
+ search_key_file: f.search_key_file,
28868
+ ...f.sqlite !== void 0 ? { sqlite: f.sqlite } : {}
28869
+ }
28870
+ };
28871
+ }
28837
28872
  var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
28838
28873
  var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
28839
28874
  var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
@@ -28902,7 +28937,8 @@ function readLocalReport(home, identityId) {
28902
28937
  persona,
28903
28938
  has_search_key: hasSearchKey,
28904
28939
  has_llm_key: hasLlmKey,
28905
- legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source)
28940
+ legacy_memory: localLegacySources(doc).map(({ path: _path, ...source }) => source),
28941
+ secret_paths: parseSecretPathsReport(doc.secret_paths)
28906
28942
  };
28907
28943
  }
28908
28944
  function deleteLegacyMemorySources(home, identityId, ids) {
@@ -29704,6 +29740,8 @@ var BridgeAgent = class _BridgeAgent {
29704
29740
  fastPathDeferred = 0;
29705
29741
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
29706
29742
  fastPathIntentDeferred = 0;
29743
+ /** Of those, fast-model replies that voiced inability ("I can't check...") — never spoken; the ack is spoken instead. */
29744
+ fastPathRefusalDeferred = 0;
29707
29745
  /**
29708
29746
  * Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
29709
29747
  * verb can tell "bridge process up" (the app's green dot, which only
@@ -30098,7 +30136,11 @@ var BridgeAgent = class _BridgeAgent {
30098
30136
  // Inert legacy files only. `readLocalReport` revalidates the current
30099
30137
  // regular-file bytes and strips every local path before this leaves the
30100
30138
  // machine.
30101
- legacy_memory: local?.legacy_memory ?? []
30139
+ legacy_memory: local?.legacy_memory ?? [],
30140
+ // §B.4: counts of server-named secret paths inside / allowlisted /
30141
+ // outside the secrets directory, and the runtime's mode. Numbers and
30142
+ // one enum only; readLocalReport rebuilds it so no path rides along.
30143
+ secret_paths: local?.secret_paths ?? null
30102
30144
  };
30103
30145
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
30104
30146
  method: "POST",
@@ -31981,11 +32023,15 @@ var BridgeAgent = class _BridgeAgent {
31981
32023
  });
31982
32024
  const maxSentences = this.voiceSettings?.brevity === "one" ? 1 : 2;
31983
32025
  const raw = await this.latency.time("voice.fast_llm", () => fastLlm.complete(messages));
31984
- const { reply, defer } = splitFastReply(raw, 200, maxSentences);
31985
- if (!reply) return "failed";
32026
+ const split3 = splitFastReply(raw, 200, maxSentences);
32027
+ if (!split3.reply) return "failed";
32028
+ const refusal = !isGreeting && isRefusal(split3.reply);
32029
+ const defer = split3.defer || refusal;
32030
+ const reply = refusal ? deferAck(m.messageId) : split3.reply;
31986
32031
  await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31987
32032
  this.fastPathSent++;
31988
32033
  if (defer) this.fastPathDeferred++;
32034
+ if (refusal) this.fastPathRefusalDeferred++;
31989
32035
  return defer ? "deferred" : "answered";
31990
32036
  } catch (e) {
31991
32037
  this.fastPathFailed++;
@@ -32322,14 +32368,25 @@ var BridgeAgent = class _BridgeAgent {
32322
32368
  *
32323
32369
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32324
32370
  * the kill switch is on, the channel is a DM, and EITHER
32325
- * (a) audience is owner_dm AND the sender is the pinned structural owner
32326
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32327
- * alone gets nothing; OR
32371
+ * (a) audience is owner_dm AND the sender is the channel's pinned DM
32372
+ * counterpart (`channelOwner`, never "anomalous"). A server-marked
32373
+ * `is_agent_owner` on a member who is NOT the DM's single human
32374
+ * counterpart gets nothing; on the counterpart it is trusted (see
32375
+ * LIMIT below); OR
32328
32376
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32329
32377
  * relayed `speaker_identity_id` (passed by the caller only for relay
32330
32378
  * turns) is that same pinned owner, whose roster row is the agent's
32331
32379
  * owner. A speaker claim from any non-relay sender gets nothing.
32332
32380
  * Everything else gets the frozen empty list.
32381
+ *
32382
+ * LIMIT (harness gate review H2, 2026-10-05): "pinned structural owner"
32383
+ * is PER CHANNEL. resolveAndPinOwner pins the single human counterpart of
32384
+ * ANY DM, so in a DM with member Bob, Bob is that channel's pin. What
32385
+ * separates Bob from the real owner is only the server's `is_agent_owner`
32386
+ * flag (connect_channels.py: counterpart user == agent's creator). A
32387
+ * backend that sets that flag wrongly gives Bob the grants, and a harness
32388
+ * connector turns grants into its full toolset. Binding the owner identity
32389
+ * once, outside server control, is an open design follow-up.
32333
32390
  */
32334
32391
  authorizationFor(channelId, senderId, relaySpeakerId) {
32335
32392
  const meta = this.freshMeta(channelId);
@@ -33875,6 +33932,31 @@ var BridgeAgent = class _BridgeAgent {
33875
33932
  noteSessionEvicted() {
33876
33933
  this.evictions++;
33877
33934
  }
33935
+ /** Harness tool gate H4: latest connector self-report (see harness-report.ts). */
33936
+ harness = null;
33937
+ /**
33938
+ * Record a connector's harness report (from its /v1/ws hello). WARN once
33939
+ * each time the attached harness turns to an inactive veto: such a harness
33940
+ * refuses every turn without a tool grant, so its rooms go quiet.
33941
+ */
33942
+ recordHarnessReport(report) {
33943
+ const wasInactive = this.harness?.tool_veto === "inactive";
33944
+ this.harness = report;
33945
+ if (report.tool_veto === "inactive" && !wasInactive) {
33946
+ writeServiceLog("warn", "harness.tool_veto_inactive", {
33947
+ harness: ServiceLogEnum.of(report.name, ["hermes", "openclaw", "other"])
33948
+ });
33949
+ }
33950
+ }
33951
+ /** Forget `report` when the connector that sent it disconnects. A report
33952
+ * from a newer connector (a different object) is kept. */
33953
+ clearHarnessReport(report) {
33954
+ if (this.harness === report) this.harness = null;
33955
+ }
33956
+ /** The latest harness report, or null (GET /v1/status). */
33957
+ harnessReport() {
33958
+ return this.harness;
33959
+ }
33878
33960
  health() {
33879
33961
  let joined = 0;
33880
33962
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -33937,12 +34019,14 @@ var BridgeAgent = class _BridgeAgent {
33937
34019
  fast_path_skipped: this.fastPathSkipped,
33938
34020
  fast_path_deferred: this.fastPathDeferred,
33939
34021
  fast_path_intent_deferred: this.fastPathIntentDeferred,
34022
+ fast_path_refusal_deferred: this.fastPathRefusalDeferred,
33940
34023
  fast_path_mode: this.cfg.voiceFastPathMode ?? "full",
33941
34024
  voice_settings_version: this.voiceSettingsVersion,
33942
34025
  voice_settings_source: this.voiceSettingsSource,
33943
34026
  delivery_pings: this.pingsReceived,
33944
34027
  agent_sessions: this.authSessions,
33945
34028
  evictions: this.evictions,
34029
+ harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
33946
34030
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
33947
34031
  version: BRIDGE_VERSION,
33948
34032
  ...this.memory.stats?.() ?? {}
@@ -34367,6 +34451,9 @@ function createHttpServer(agent, opts) {
34367
34451
  const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
34368
34452
  return json(res, 200, r);
34369
34453
  }
34454
+ if (method === "GET" && path === "/v1/status") {
34455
+ return json(res, 200, { harness: agent.harnessReport() });
34456
+ }
34370
34457
  if (method === "POST" && path === "/v1/status") {
34371
34458
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
34372
34459
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -34456,6 +34543,17 @@ function createHttpServer(agent, opts) {
34456
34543
  return { server, closeAllSseStreams: () => closeSse() };
34457
34544
  }
34458
34545
 
34546
+ // src/harness-report.ts
34547
+ var VERSION_TOKEN = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,39}$/;
34548
+ function parseHarnessReport(raw) {
34549
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
34550
+ const r = raw;
34551
+ const name = r.name === "hermes" || r.name === "openclaw" ? r.name : "other";
34552
+ const version = typeof r.version === "string" && VERSION_TOKEN.test(r.version) ? r.version : null;
34553
+ const tool_veto = r.tool_veto === "active" || r.tool_veto === "inactive" ? r.tool_veto : "unknown";
34554
+ return Object.freeze({ name, version, tool_veto });
34555
+ }
34556
+
34459
34557
  // src/ws-server.ts
34460
34558
  import { WebSocketServer, WebSocket } from "ws";
34461
34559
  var PING_INTERVAL_MS = 3e4;
@@ -34586,6 +34684,7 @@ function attachAgentWebSocket(server, agent, opts) {
34586
34684
  }
34587
34685
  delivered.clear();
34588
34686
  if (current?.sock === sock) current = null;
34687
+ if (session.harness) agent.clearHarnessReport(session.harness);
34589
34688
  };
34590
34689
  current = { sock, cleanup, sweepStale };
34591
34690
  sock.on("close", cleanup);
@@ -34648,7 +34747,11 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34648
34747
  return;
34649
34748
  }
34650
34749
  if (m.type === "hello") {
34651
- session.consumer = "runtime";
34750
+ const report = m.harness === void 0 ? null : parseHarnessReport(m.harness);
34751
+ if (report) {
34752
+ session.harness = report;
34753
+ agent.recordHarnessReport(report);
34754
+ } else session.consumer = "runtime";
34652
34755
  sendJson(sock, { type: "hello", features: ["nack"] });
34653
34756
  return;
34654
34757
  }
@@ -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.
@@ -800,6 +804,8 @@ export declare class BridgeAgent {
800
804
  private fastPathDeferred;
801
805
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
802
806
  private fastPathIntentDeferred;
807
+ /** Of those, fast-model replies that voiced inability ("I can't check...") — never spoken; the ack is spoken instead. */
808
+ private fastPathRefusalDeferred;
803
809
  /**
804
810
  * Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
805
811
  * verb can tell "bridge process up" (the app's green dot, which only
@@ -1957,14 +1963,25 @@ export declare class BridgeAgent {
1957
1963
  *
1958
1964
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
1959
1965
  * the kill switch is on, the channel is a DM, and EITHER
1960
- * (a) audience is owner_dm AND the sender is the pinned structural owner
1961
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
1962
- * 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
1963
1971
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
1964
1972
  * relayed `speaker_identity_id` (passed by the caller only for relay
1965
1973
  * turns) is that same pinned owner, whose roster row is the agent's
1966
1974
  * owner. A speaker claim from any non-relay sender gets nothing.
1967
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.
1968
1985
  */
1969
1986
  private authorizationFor;
1970
1987
  /**
@@ -2296,6 +2313,19 @@ export declare class BridgeAgent {
2296
2313
  * ws-server.ts right after prior.cleanup() in the eviction path.
2297
2314
  */
2298
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;
2299
2329
  health(): {
2300
2330
  status: "ok";
2301
2331
  identity_id: string;
@@ -2344,6 +2374,8 @@ export declare class BridgeAgent {
2344
2374
  fast_path_deferred: number;
2345
2375
  /** `full`-mode turns deferred by the deterministic pre-check (no fast model call). */
2346
2376
  fast_path_intent_deferred: number;
2377
+ /** Fast-model replies that voiced inability/lack of tools: never spoken, deferred behind the canned ack instead. */
2378
+ fast_path_refusal_deferred: number;
2347
2379
  /** Credential `voice_fast_path_mode`: "full" (default), "ack_only" or "off". */
2348
2380
  fast_path_mode: "full" | "ack_only" | "off";
2349
2381
  /** Plan 2026-09-06 (agent voice settings): version of the loaded backend
@@ -2365,6 +2397,12 @@ export declare class BridgeAgent {
2365
2397
  * agent_sessions stays >=1 means two connectors are flapping the same
2366
2398
  * identity (split-brain), not one stable harness. */
2367
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;
2368
2406
  uptime_s: number;
2369
2407
  version: string;
2370
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
@@ -28288,11 +28288,26 @@ function clampContent(content, max = MAX_MESSAGE_CHARS) {
28288
28288
  const marker = "\u2026";
28289
28289
  return content.slice(0, Math.max(0, max - marker.length)) + marker;
28290
28290
  }
28291
+ var FAST_DEFER_RULE = `Whenever you cannot answer completely from what is shown here, never say you can't, that you lack a tool, access or real-time data: reply with one short sentence like "Let me check that." and end with the exact token [[DEFER]] so the full agent answers next.`;
28292
+ var REFUSAL_PATTERNS = [
28293
+ // "I can't / cannot / am unable to / am not able to <check|access|...>", optional adverb.
28294
+ /\b(?:can't|cannot|can\s+not|unable\s+to|not\s+able\s+to)\s+(?:\w+ly\s+)?(?:check|access|look|see|browse|search|get|provide|give\s+you|find|retrieve|fetch|pull|know|help\s+with\s+(?:that|this)|do\s+that)\b/,
28295
+ // "I don't / do not / doesn't have (a|any|the)? <tool|access|ability|real-time|live ...>".
28296
+ /\b(?:don't|do\s+not|doesn't|does\s+not|lack)\s+(?:have\s+)?(?:a\s+|any\s+|the\s+)?(?:tool|tools|access|ability|capability|real[-\s]?time|live|current|internet|web|way\s+to)\b/,
28297
+ /\bno\s+(?:access|tools?|internet|web\s+access|real[-\s]?time|live\s+data|way\s+to\s+(?:check|access|know|see))\b/,
28298
+ /\bas\s+an\s+(?:ai|a\.i\.|language\s+model)\b/,
28299
+ /\bbeyond\s+my\s+(?:capabilities|abilities|capability)\b/,
28300
+ /\b(?:knowledge\s+cutoff|training\s+data)\b/
28301
+ ];
28302
+ function isRefusal(reply) {
28303
+ const t = reply.toLowerCase().replace(/[‘’]/g, "'");
28304
+ return REFUSAL_PATTERNS.some((re) => re.test(t));
28305
+ }
28291
28306
  function buildFastMessages(args2) {
28292
28307
  const cap = args2.charCap ?? FAST_RECENT_CHARS_CAP;
28293
28308
  const sp = systemPrompt(args2.persona, args2.settings, args2.agentName);
28294
28309
  const head = sp.slice(0, sp.length - CONTINUITY_CLAUSE.length).trimEnd();
28295
- const system = `${head} ${args2.guidance.trim()} ${CONTINUITY_CLAUSE}`;
28310
+ const system = `${head} ${args2.guidance.trim()} ${FAST_DEFER_RULE} ${CONTINUITY_CLAUSE}`;
28296
28311
  const recent = args2.memory.recent.map((t) => ({
28297
28312
  role: t.role === "agent" ? "assistant" : "user",
28298
28313
  content: t.text
@@ -28623,7 +28638,7 @@ function assertManagedName(name) {
28623
28638
  }
28624
28639
  function isLocalOnlyName(name) {
28625
28640
  const n = name.toLowerCase().replace(/[. ]+$/, "");
28626
- 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";
28627
28642
  }
28628
28643
  function atomicWrite(path, body, deps) {
28629
28644
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
@@ -28682,6 +28697,26 @@ function lineHash(line) {
28682
28697
  return createHash2("sha256").update(line.trim(), "utf8").digest("hex");
28683
28698
  }
28684
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
+ }
28685
28720
  var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
28686
28721
  var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
28687
28722
  var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
@@ -28750,7 +28785,8 @@ function readLocalReport(home, identityId) {
28750
28785
  persona,
28751
28786
  has_search_key: hasSearchKey,
28752
28787
  has_llm_key: hasLlmKey,
28753
- 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)
28754
28790
  };
28755
28791
  }
28756
28792
  function deleteLegacyMemorySources(home, identityId, ids) {
@@ -29552,6 +29588,8 @@ var BridgeAgent = class _BridgeAgent {
29552
29588
  fastPathDeferred = 0;
29553
29589
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
29554
29590
  fastPathIntentDeferred = 0;
29591
+ /** Of those, fast-model replies that voiced inability ("I can't check...") — never spoken; the ack is spoken instead. */
29592
+ fastPathRefusalDeferred = 0;
29555
29593
  /**
29556
29594
  * Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
29557
29595
  * verb can tell "bridge process up" (the app's green dot, which only
@@ -29946,7 +29984,11 @@ var BridgeAgent = class _BridgeAgent {
29946
29984
  // Inert legacy files only. `readLocalReport` revalidates the current
29947
29985
  // regular-file bytes and strips every local path before this leaves the
29948
29986
  // machine.
29949
- 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
29950
29992
  };
29951
29993
  const posted = await this.fetchBounded("runtime_config_report", "control", `${base}/report`, {
29952
29994
  method: "POST",
@@ -31829,11 +31871,15 @@ var BridgeAgent = class _BridgeAgent {
31829
31871
  });
31830
31872
  const maxSentences = this.voiceSettings?.brevity === "one" ? 1 : 2;
31831
31873
  const raw = await this.latency.time("voice.fast_llm", () => fastLlm.complete(messages));
31832
- const { reply, defer } = splitFastReply(raw, 200, maxSentences);
31833
- if (!reply) return "failed";
31874
+ const split3 = splitFastReply(raw, 200, maxSentences);
31875
+ if (!split3.reply) return "failed";
31876
+ const refusal = !isGreeting && isRefusal(split3.reply);
31877
+ const defer = split3.defer || refusal;
31878
+ const reply = refusal ? deferAck(m.messageId) : split3.reply;
31834
31879
  await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31835
31880
  this.fastPathSent++;
31836
31881
  if (defer) this.fastPathDeferred++;
31882
+ if (refusal) this.fastPathRefusalDeferred++;
31837
31883
  return defer ? "deferred" : "answered";
31838
31884
  } catch (e) {
31839
31885
  this.fastPathFailed++;
@@ -32170,14 +32216,25 @@ var BridgeAgent = class _BridgeAgent {
32170
32216
  *
32171
32217
  * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32172
32218
  * the kill switch is on, the channel is a DM, and EITHER
32173
- * (a) audience is owner_dm AND the sender is the pinned structural owner
32174
- * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32175
- * 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
32176
32224
  * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32177
32225
  * relayed `speaker_identity_id` (passed by the caller only for relay
32178
32226
  * turns) is that same pinned owner, whose roster row is the agent's
32179
32227
  * owner. A speaker claim from any non-relay sender gets nothing.
32180
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.
32181
32238
  */
32182
32239
  authorizationFor(channelId, senderId, relaySpeakerId) {
32183
32240
  const meta = this.freshMeta(channelId);
@@ -33723,6 +33780,31 @@ var BridgeAgent = class _BridgeAgent {
33723
33780
  noteSessionEvicted() {
33724
33781
  this.evictions++;
33725
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
+ }
33726
33808
  health() {
33727
33809
  let joined = 0;
33728
33810
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
@@ -33785,12 +33867,14 @@ var BridgeAgent = class _BridgeAgent {
33785
33867
  fast_path_skipped: this.fastPathSkipped,
33786
33868
  fast_path_deferred: this.fastPathDeferred,
33787
33869
  fast_path_intent_deferred: this.fastPathIntentDeferred,
33870
+ fast_path_refusal_deferred: this.fastPathRefusalDeferred,
33788
33871
  fast_path_mode: this.cfg.voiceFastPathMode ?? "full",
33789
33872
  voice_settings_version: this.voiceSettingsVersion,
33790
33873
  voice_settings_source: this.voiceSettingsSource,
33791
33874
  delivery_pings: this.pingsReceived,
33792
33875
  agent_sessions: this.authSessions,
33793
33876
  evictions: this.evictions,
33877
+ harness_tool_veto_active: this.harness === null ? null : this.harness.tool_veto === "active",
33794
33878
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
33795
33879
  version: BRIDGE_VERSION,
33796
33880
  ...this.memory.stats?.() ?? {}
@@ -34323,6 +34407,9 @@ function createHttpServer(agent, opts) {
34323
34407
  const r = await agent.vaultDelete(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
34324
34408
  return json(res, 200, r);
34325
34409
  }
34410
+ if (method === "GET" && path === "/v1/status") {
34411
+ return json(res, 200, { harness: agent.harnessReport() });
34412
+ }
34326
34413
  if (method === "POST" && path === "/v1/status") {
34327
34414
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
34328
34415
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -34412,6 +34499,17 @@ function createHttpServer(agent, opts) {
34412
34499
  return { server, closeAllSseStreams: () => closeSse() };
34413
34500
  }
34414
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
+
34415
34513
  // src/ws-server.ts
34416
34514
  import { WebSocketServer, WebSocket } from "ws";
34417
34515
  var PING_INTERVAL_MS = 3e4;
@@ -34542,6 +34640,7 @@ function attachAgentWebSocket(server, agent, opts) {
34542
34640
  }
34543
34641
  delivered.clear();
34544
34642
  if (current?.sock === sock) current = null;
34643
+ if (session.harness) agent.clearHarnessReport(session.harness);
34545
34644
  };
34546
34645
  current = { sock, cleanup, sweepStale };
34547
34646
  sock.on("close", cleanup);
@@ -34604,7 +34703,11 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
34604
34703
  return;
34605
34704
  }
34606
34705
  if (m.type === "hello") {
34607
- 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";
34608
34711
  sendJson(sock, { type: "hello", features: ["nack"] });
34609
34712
  return;
34610
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.3";
1
+ export declare const BRIDGE_VERSION = "0.26.5";
@@ -29,6 +29,15 @@ export declare const MAX_MESSAGE_CHARS = 8000;
29
29
  export declare const FAST_PATH_MEMORY_TURNS = 10;
30
30
  export declare const FAST_RECENT_CHARS_CAP = 1500;
31
31
  export declare const FAST_FACTS_CHARS_CAP = 2000;
32
+ /**
33
+ * Live bug 2026-10-02 (bridge 0.26.3): asked for Sydney's weather, the fast
34
+ * model said "I can't check the weather right now since I don't have a
35
+ * tool..." with no [[DEFER]], so the runtime (which has web_search) never got
36
+ * the turn. Placed last before the continuity clause so it is the final rule
37
+ * the model reads. Compiled in, never interpolated with peer data.
38
+ */
39
+ export declare const FAST_DEFER_RULE: string;
40
+ export declare function isRefusal(reply: string): boolean;
32
41
  export declare function buildFastMessages(args: {
33
42
  persona: VoicePersona;
34
43
  guidance: string;