@agentvault/claude-bridge 0.5.8 → 0.5.10

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/arming.d.ts CHANGED
@@ -10,24 +10,54 @@ export declare class ArmingState {
10
10
  private pending;
11
11
  private consumed;
12
12
  /**
13
- * Replace the armed set. Use ONLY for the launch-env seed (AV_ARM_ROOM), which
14
- * is itself a host-local action and is therefore allowed to arm.
13
+ * Replace the armed set. Use for the host-local launch seed (AV_ARM_ROOM)
14
+ * itself a host-local action and therefore allowed to arm — and as the
15
+ * primitive `applyAuthoritative` below delegates to for non-shell workers.
15
16
  *
16
- * Do NOT use this for the backend connect `arming_snapshot`: that snapshot
17
- * reflects owner *intent* (set at web-request time, before any local approval),
18
- * so arming from it would let a web-session compromise arm a worker on reconnect
19
- * without host access defeating the 2-of-2 local-approval model. The channel
20
- * snapshot path uses `reconcileDisarm` (disarm-only) instead.
17
+ * The B-pure posture (Task 4) treats the backend connect/live
18
+ * `arming_snapshot` the same way see `applyAuthoritative`'s docstring for
19
+ * the current arm-from-snapshot rationale. Shell-capable (OS-isolated)
20
+ * workers do NOT use this path; they stay on `reconcileDisarm` (disarm-only,
21
+ * see below) until #20.
21
22
  */
22
23
  applySnapshot(roomIds: string[]): void;
23
24
  /**
24
- * Disarm-only reconciliation from the backend connect snapshot. Disarms any
25
- * currently-armed room whose intent is no longer armed (i.e. NOT in
26
- * `intendedArmed`) so a disarm issued while the bridge was offline still takes
27
- * effect on reconnect but NEVER arms a room. Arming stays exclusively behind a
28
- * local `approve-arm`. Returns the rooms that were disarmed. In-memory arming
29
- * survives a WS reconnect, so a genuinely-approved arm is unaffected here; only a
30
- * full process restart (which clears this state) requires re-approval on the host.
25
+ * Set the armed set to EXACTLY `intendedArmed`: arms every room in the list
26
+ * that isn't already armed, and disarms every currently-armed room that is
27
+ * absent from it. This is the AUTHORITATIVE arm-from-snapshot path (Task 4,
28
+ * reversing the prior disarm-only safeguard for non-shell workers).
29
+ *
30
+ * Why arming from the snapshot is now safe: `intendedArmed` is derived
31
+ * server-side from `devices.work_allowed`, and that flag can only be flipped
32
+ * by `PUT /devices/{id}/work_allowed`, which REJECTS any device-bound caller
33
+ * outright (C1 guard — a human account owner only; an agent, including a
34
+ * compromised one, cannot self-authorize). So "arm from snapshot" no longer
35
+ * means "a web session can arm a worker" in the old adversarial sense — it
36
+ * means "the verified owner's grant is applied end-to-end, live, without
37
+ * requiring a separate host-local approval step for every reconnect." This is
38
+ * the accepted B-pure posture; see #20 to harden it further with local
39
+ * approval for non-shell workers too. Shell-capable (OS-isolated) workers are
40
+ * carved out of this path entirely (C2) — see the shell-gate in bridge.ts —
41
+ * because a shell worker armed by a web flag is a live RCE surface even under
42
+ * an honest owner (a compromised owner web session, or a compromised backend,
43
+ * would get arbitrary code execution). Those workers stay on `reconcileDisarm`
44
+ * (disarm-only) until #20 delivers local approval for them as well.
45
+ */
46
+ applyAuthoritative(intendedArmed: string[]): void;
47
+ /**
48
+ * Disarm-only reconciliation from the backend connect/live snapshot. Disarms
49
+ * any currently-armed room whose intent is no longer armed (i.e. NOT in
50
+ * `intendedArmed`) — so a disarm issued while the bridge was offline still
51
+ * takes effect on reconnect — but NEVER arms a room. Used for OS-isolated
52
+ * (shell-capable) workers ONLY (C2): a shell worker must never be armed by a
53
+ * web-originated flag, because a compromised owner session or backend would
54
+ * translate directly into host code execution. Those workers can be armed
55
+ * only by the host-local launch seed (`AV_ARM_ROOM`, via `applySnapshot`)
56
+ * until #20 delivers a local-approval path for them too. Non-shell workers
57
+ * use `applyAuthoritative` instead (Task 4) — see its docstring. Returns the
58
+ * rooms that were disarmed. In-memory arming survives a WS reconnect, so a
59
+ * genuinely-armed room is unaffected here; only a full process restart
60
+ * (which clears this state) requires re-arming.
31
61
  */
32
62
  reconcileDisarm(intendedArmed: string[]): string[];
33
63
  isArmed(roomId: string): boolean;
package/dist/bridge.d.ts CHANGED
@@ -3,6 +3,13 @@ export interface RoomMessage {
3
3
  roomId: string;
4
4
  senderName: string;
5
5
  plaintext: string;
6
+ /** True when the sender is another agent (SecureChannel derives this from the
7
+ * room roster). Used to decide reply expectation: a human/owner speaking in a
8
+ * room always expects a reply; agent-authored traffic does not (the agent may
9
+ * stay silent, and it prevents agent↔agent reply loops). Absent on older
10
+ * payloads / tests → treated as "not an agent" is NOT assumed: replyExpected is
11
+ * set only when we affirmatively know the sender is a human (=== false). */
12
+ senderIsAgent?: boolean;
6
13
  }
7
14
  /** Slice 2 Plan C arming events (SecureChannel re-emits these from the backend). */
8
15
  export interface ArmRequested {
@@ -15,6 +22,10 @@ export interface DisarmEvent {
15
22
  }
16
23
  export interface ArmingSnapshot {
17
24
  roomIds: string[];
25
+ /** Task 3 (P1 bridge): the device-level owner grant this snapshot carries
26
+ * (backend `devices.work_allowed`, Task 2). Optional so older/test payloads
27
+ * without it don't break — treated as `false` (fail-closed) when absent. */
28
+ workAllowed?: boolean;
18
29
  }
19
30
  /** Metadata SecureChannel attaches to a 1:1 `message` event. `roomId` is set only
20
31
  * when the `message` actually originated in a room (handled by room_message), so
@@ -37,6 +48,11 @@ export interface RoomChannel {
37
48
  on(ev: "arm_requested", cb: (e: ArmRequested) => void): unknown;
38
49
  on(ev: "disarm", cb: (e: DisarmEvent) => void): unknown;
39
50
  on(ev: "arming_snapshot", cb: (e: ArmingSnapshot) => void): unknown;
51
+ /** Task 3: SecureChannel's connection lifecycle signal. It never emits a
52
+ * bare `"close"` — a WS drop is reported as `state` moving to
53
+ * `"disconnected"` (or `"error"`), and a completed reconnect as `"ready"`
54
+ * (both the dedicated `state` value AND the separate `"ready"` event). */
55
+ on(ev: "state", cb: (s: string) => void): unknown;
40
56
  sendToRoom(roomId: string, text: string): Promise<void>;
41
57
  send(text: string): Promise<void>;
42
58
  /** Optional so legacy/test fakes without arming support don't break wiring. */
@@ -59,9 +75,12 @@ export interface RoomSession {
59
75
  /** `reply` is the immutable reply sink captured for THIS message (see
60
76
  * ActiveTarget.snapshotReply) — the session invokes it when Claude answers.
61
77
  * `opts.autoReplyOnText` (set for 1:1 DMs) makes the session fall back to
62
- * sending plain assistant text when the model never calls the say tool (#416). */
78
+ * sending plain assistant text when the model never calls the say tool (#416).
79
+ * `opts.replyExpected` requests that same fallback for a room turn (a human/owner
80
+ * sender) WITHOUT enabling tools — decoupled from autoReplyOnText on purpose. */
63
81
  push(text: string, reply?: (text: string) => Promise<void>, opts?: {
64
82
  autoReplyOnText?: boolean;
83
+ replyExpected?: boolean;
65
84
  armed?: () => boolean;
66
85
  }): void;
67
86
  }
@@ -148,11 +167,28 @@ export declare function wireBridge(channel: RoomChannel, session: RoomSession, t
148
167
  roomFilter?: string;
149
168
  armRoom?: boolean;
150
169
  log?: (msg: string) => void;
151
- /** Slice 2 Plan C (T11): worker capability + local-approval marker dir. */
152
- worker?: boolean;
170
+ /** Slice 2 Plan C (T11): worker capability (derived from workspaceDir) + local-approval marker dir. */
153
171
  workspaceDir?: string;
154
172
  dataDir?: string;
155
173
  /** Override the approval poll interval (ms) — for tests. */
156
174
  approvalPollMs?: number;
175
+ /**
176
+ * Task 4 (C2 shell-gate): true when this worker runs Bash unconfined
177
+ * (AV_WORKER_OS_ISOLATED=1 — see config.ts/index.ts, threaded from the
178
+ * session's `osIsolated`). When true, the `arming_snapshot` handler stays
179
+ * disarm-only regardless of B1/worker-capability — a shell worker must
180
+ * never be armed by a web-originated flag (RCE risk). Falls back to
181
+ * reading the env directly only if the caller doesn't thread this.
182
+ */
183
+ osIsolated?: boolean;
184
+ /**
185
+ * Task 3 (P1 bridge): out-param that receives the live `workAllowed()`
186
+ * getter once, synchronously, during wiring. Callers (index.ts, Task 4)
187
+ * capture it to thread device-level tool-capability into the router +
188
+ * session gate. Kept as an out-param rather than changing wireBridge's
189
+ * return type so every existing `const arming = wireBridge(...)` caller
190
+ * (index.ts, arming-authoritative.test.ts, bridge.test.ts) stays intact.
191
+ */
192
+ onWorkAllowed?: (getter: () => boolean) => void;
157
193
  }): ArmingState;
158
194
  //# sourceMappingURL=bridge.d.ts.map
package/dist/config.d.ts CHANGED
@@ -14,8 +14,9 @@ export interface BridgeConfig {
14
14
  roomFilter?: string;
15
15
  model?: string;
16
16
  systemPrompt?: string;
17
- worker: boolean;
18
- workspaceDir?: string;
17
+ /** Per-agent filesystem sandbox (Facet-A fence). Always set: auto-defaults
18
+ * to a sibling of the key/data dir (H-2) unless AV_WORKSPACE_DIR overrides it. */
19
+ workspaceDir: string;
19
20
  permissionMode: "auto" | "acceptEdits" | "bypassPermissions";
20
21
  /** Slice 2: arm the pinned room (roomFilter) for worker tools on room turns.
21
22
  * Off by default. Valid only with worker + roomFilter + workspaceDir. */
package/dist/index.js CHANGED
@@ -10,8 +10,8 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/config.ts
13
- import { existsSync, readFileSync as readFileSync2 } from "node:fs";
14
- import { join as join5 } from "node:path";
13
+ import { existsSync, readFileSync as readFileSync2, mkdirSync, chmodSync } from "node:fs";
14
+ import { join as join5, resolve as resolve2, sep, dirname } from "node:path";
15
15
  function hasRecoverableBackup(dataDir) {
16
16
  try {
17
17
  const parsed = JSON.parse(readFileSync2(join5(dataDir, BACKUP_FILE), "utf-8"));
@@ -38,8 +38,22 @@ function resolveDataDir(env) {
38
38
  function loadConfig(env, argv = []) {
39
39
  const { dataDir, source: dataDirSource } = resolveDataDir(env);
40
40
  const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || "";
41
- const worker = env.AV_WORKER === "1" || env.AV_WORKER === "true";
42
- const workspaceDir = env.AV_WORKSPACE_DIR || void 0;
41
+ const workspaceDir = env.AV_WORKSPACE_DIR || join5(dirname(dataDir), "workspaces", slugify2(env.AV_AGENT_NAME ?? "claude"));
42
+ const wsReal = resolve2(workspaceDir);
43
+ const ddReal = resolve2(dataDir);
44
+ if (wsReal === ddReal || wsReal.startsWith(ddReal + sep) || ddReal.startsWith(wsReal + sep)) {
45
+ throw new Error(
46
+ `AV_WORKSPACE_DIR (${workspaceDir}) must be disjoint from the data dir (${dataDir})`
47
+ );
48
+ }
49
+ try {
50
+ mkdirSync(workspaceDir, { recursive: true, mode: 448 });
51
+ chmodSync(workspaceDir, 448);
52
+ } catch (e7) {
53
+ throw new Error(
54
+ `Failed to create/secure the agent workspace at ${workspaceDir} (override with AV_WORKSPACE_DIR): ${e7.message}`
55
+ );
56
+ }
43
57
  const osIsolated = env.AV_WORKER_OS_ISOLATED === "1" || env.AV_WORKER_OS_ISOLATED === "true";
44
58
  const PERMISSION_MODES = ["auto", "acceptEdits", "bypassPermissions"];
45
59
  const permissionMode = env.AV_PERMISSION_MODE ?? "auto";
@@ -48,16 +62,8 @@ function loadConfig(env, argv = []) {
48
62
  `invalid AV_PERMISSION_MODE: ${env.AV_PERMISSION_MODE} (expected one of ${PERMISSION_MODES.join(", ")})`
49
63
  );
50
64
  }
51
- if (worker && !workspaceDir) {
52
- throw new Error(
53
- "worker mode requires AV_WORKSPACE_DIR (the project directory the agent works in)"
54
- );
55
- }
56
65
  const armRoom = env.AV_ARM_ROOM === "1" || env.AV_ARM_ROOM === "true";
57
66
  if (armRoom) {
58
- if (!worker) {
59
- throw new Error("AV_ARM_ROOM requires worker mode (AV_WORKER=1)");
60
- }
61
67
  if (!env.AV_ROOM_ID) {
62
68
  throw new Error("AV_ARM_ROOM requires a pinned room (AV_ROOM_ID) \u2014 the armed collaboration room");
63
69
  }
@@ -82,7 +88,6 @@ function loadConfig(env, argv = []) {
82
88
  roomFilter: env.AV_ROOM_ID || void 0,
83
89
  model: env.AV_CLAUDE_MODEL || void 0,
84
90
  systemPrompt: env.AV_SYSTEM_PROMPT || void 0,
85
- worker,
86
91
  workspaceDir,
87
92
  permissionMode,
88
93
  armRoom,
@@ -99,7 +104,7 @@ var init_config = __esm({
99
104
  });
100
105
 
101
106
  // src/service/launcher.ts
102
- import { dirname as dirname2 } from "node:path";
107
+ import { dirname as dirname3 } from "node:path";
103
108
  function sq2(s10) {
104
109
  return `'${s10.replace(/'/g, `'\\''`)}'`;
105
110
  }
@@ -124,7 +129,7 @@ function renderLauncher(opts) {
124
129
  return lines.join("\n") + "\n";
125
130
  }
126
131
  function writeLauncher(deps, spec) {
127
- deps.mkdir(dirname2(spec.launcherPath));
132
+ deps.mkdir(dirname3(spec.launcherPath));
128
133
  deps.writeFile(spec.launcherPath, spec.launcherScript);
129
134
  deps.chmod(spec.launcherPath, 493);
130
135
  }
@@ -158,7 +163,6 @@ function buildServiceSpec(cfg, opts) {
158
163
  AV_API_URL: cfg.apiUrl,
159
164
  AV_PERMISSION_MODE: cfg.permissionMode
160
165
  };
161
- if (cfg.worker) env.AV_WORKER = "1";
162
166
  if (cfg.workspaceDir) env.AV_WORKSPACE_DIR = cfg.workspaceDir;
163
167
  if (cfg.roomFilter) env.AV_ROOM_ID = cfg.roomFilter;
164
168
  if (cfg.armRoom) env.AV_ARM_ROOM = "1";
@@ -171,7 +175,7 @@ function buildServiceSpec(cfg, opts) {
171
175
  launcherPath,
172
176
  launcherScript: renderLauncher({ installPath: opts.path, entrypoint: opts.entrypoint }),
173
177
  env,
174
- workingDir: cfg.worker && cfg.workspaceDir ? cfg.workspaceDir : opts.home,
178
+ workingDir: cfg.workspaceDir ? cfg.workspaceDir : opts.home,
175
179
  logOut: join7(cfg.dataDir, "logs", "bridge.log"),
176
180
  logErr: join7(cfg.dataDir, "logs", "bridge.error.log"),
177
181
  keepAliveUnlessCleanExit: true,
@@ -187,7 +191,7 @@ var init_spec = __esm({
187
191
  });
188
192
 
189
193
  // src/service/launchd.ts
190
- import { dirname as dirname3, join as join8 } from "node:path";
194
+ import { dirname as dirname4, join as join8 } from "node:path";
191
195
  function esc3(s10) {
192
196
  return s10.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
193
197
  }
@@ -241,9 +245,9 @@ var init_launchd = __esm({
241
245
  }
242
246
  install(spec) {
243
247
  const path2 = this.plistPath(spec.label);
244
- this.deps.mkdir(dirname3(spec.logOut));
248
+ this.deps.mkdir(dirname4(spec.logOut));
245
249
  writeLauncher(this.deps, spec);
246
- this.deps.mkdir(dirname3(path2));
250
+ this.deps.mkdir(dirname4(path2));
247
251
  this.deps.writeFile(path2, renderLaunchdPlist(spec));
248
252
  const svc = `${this.domain()}/${spec.label}`;
249
253
  this.deps.exec("launchctl", ["bootout", svc]);
@@ -277,7 +281,7 @@ var init_launchd = __esm({
277
281
  });
278
282
 
279
283
  // src/service/systemd.ts
280
- import { dirname as dirname4, join as join9 } from "node:path";
284
+ import { dirname as dirname5, join as join9 } from "node:path";
281
285
  function q5(s10) {
282
286
  const esc4 = s10.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/%/g, "%%");
283
287
  return `"${esc4}"`;
@@ -318,9 +322,9 @@ var init_systemd = __esm({
318
322
  }
319
323
  install(spec) {
320
324
  const path2 = this.unitPath(spec.label);
321
- this.deps.mkdir(dirname4(spec.logOut));
325
+ this.deps.mkdir(dirname5(spec.logOut));
322
326
  writeLauncher(this.deps, spec);
323
- this.deps.mkdir(dirname4(path2));
327
+ this.deps.mkdir(dirname5(path2));
324
328
  this.deps.writeFile(path2, renderSystemdUnit(spec));
325
329
  this.deps.exec("loginctl", ["enable-linger", this.deps.user]);
326
330
  this.deps.exec("systemctl", ["--user", "daemon-reload"]);
@@ -345,7 +349,7 @@ var init_systemd = __esm({
345
349
 
346
350
  // src/service/backend.ts
347
351
  import { spawnSync } from "node:child_process";
348
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2, rmSync as rmSync3, existsSync as existsSync4, chmodSync } from "node:fs";
352
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, rmSync as rmSync3, existsSync as existsSync4, chmodSync as chmodSync2 } from "node:fs";
349
353
  import { userInfo } from "node:os";
350
354
  function defaultDeps() {
351
355
  return {
@@ -357,8 +361,8 @@ function defaultDeps() {
357
361
  return { code: r7.status ?? 1, stdout: r7.stdout ?? "" };
358
362
  },
359
363
  writeFile: (p2, d10) => writeFileSync2(p2, d10),
360
- mkdir: (p2) => mkdirSync3(p2, { recursive: true }),
361
- chmod: (p2, m6) => chmodSync(p2, m6),
364
+ mkdir: (p2) => mkdirSync4(p2, { recursive: true }),
365
+ chmod: (p2, m6) => chmodSync2(p2, m6),
362
366
  rm: (p2) => rmSync3(p2, { force: true }),
363
367
  exists: (p2) => existsSync4(p2)
364
368
  };
@@ -589,7 +593,7 @@ var init_libsodium_sumo = __esm2({
589
593
  }
590
594
  }
591
595
  _Module = Module;
592
- Module.ready = new Promise(function(resolve3, reject) {
596
+ Module.ready = new Promise(function(resolve32, reject) {
593
597
  var Module2 = _Module;
594
598
  Module2.onAbort = reject;
595
599
  Module2.print = function(what) {
@@ -601,7 +605,7 @@ var init_libsodium_sumo = __esm2({
601
605
  Module2.onRuntimeInitialized = function() {
602
606
  try {
603
607
  Module2._crypto_secretbox_keybytes();
604
- resolve3();
608
+ resolve32();
605
609
  } catch (err2) {
606
610
  reject(err2);
607
611
  }
@@ -54446,8 +54450,8 @@ var init_mutex = __esm2({
54446
54450
  }
54447
54451
  async lock() {
54448
54452
  let releaseLock;
54449
- const nextLock = new Promise((resolve3) => {
54450
- releaseLock = resolve3;
54453
+ const nextLock = new Promise((resolve32) => {
54454
+ releaseLock = resolve32;
54451
54455
  });
54452
54456
  const previousLock = __classPrivateFieldGet(this, _Mutex_locked, "f");
54453
54457
  __classPrivateFieldSet(this, _Mutex_locked, nextLock, "f");
@@ -65246,6 +65250,58 @@ var init_channel = __esm2({
65246
65250
  }
65247
65251
  return void 0;
65248
65252
  }
65253
+ /**
65254
+ * True when a conversation is NOT a genuine 1:1 owner DM — i.e. it belongs to a
65255
+ * room OR an A2A channel — and therefore must NEVER surface to the native worker
65256
+ * tool-gate as a no-roomId `message` (the gate treats that as an authenticated
65257
+ * owner DM, tool-enabled, bypassing arming). This is the predicate every fallback
65258
+ * emit guard uses (MLS 1:1, DR-delivery queue, HTTP poll).
65259
+ *
65260
+ * Resolves from LOCAL persisted state — BOTH `_persisted.rooms[*].conversationIds`
65261
+ * and `_persisted.a2aChannels[*].conversationId` — and accepts EITHER the
65262
+ * conversation id or the conversation-group id, so a convGroupId-only room/A2A
65263
+ * payload is still caught while a genuine convGroupId-only 1:1 shared-group DM
65264
+ * (present in neither map) still returns false and emits.
65265
+ *
65266
+ * Some fallback guards additionally honor an AUTHORITATIVE server routing field as
65267
+ * a backstop for the cold-local-state window — but WHICH field each carries differs
65268
+ * per endpoint, so do NOT assume a blanket "backend always populates room_id/
65269
+ * a2a_channel_id" at a new call site:
65270
+ * - HTTP poll (`/devices/{id}/messages`): server `room_id` populated for room-backed
65271
+ * convs (conv_room_map). No `a2a_channel_id` on this endpoint → A2A caught locally.
65272
+ * - DR-delivery (`/dr/delivery` -> dr_delivery_service.pull_pending): server `room_id`
65273
+ * populated (from the conversation); NO `a2a_channel_id` (DrDeliveryQueue is
65274
+ * 1:1-only, never A2A). room_id is null in practice today but real for any future
65275
+ * room-over-DR row.
65276
+ * - MLS 1:1 (`_handleMessageMLS`): room/A2A are routed away UPSTREAM at the
65277
+ * MLS-delivery dispatcher (authoritative `room_id`/`a2a_channel_id`) before this
65278
+ * handler; the fields are absent on `data` here, so this predicate is the belt.
65279
+ * The MLS-delivery *queue* endpoint (mls_delivery_service.pull_pending) is the one that
65280
+ * returns all of room_id/a2a_channel_id/conversation_group_id — but that feeds the
65281
+ * dispatcher, not these emit guards directly.
65282
+ *
65283
+ * No fail-closed drop-unknown guard is used: that would risk dropping a legitimate
65284
+ * 1:1 owner DM that arrives before room/A2A hydration (a North-Star violation). The
65285
+ * reachable residual (truly-cold local state AND the server omitting its routing field
65286
+ * on a room/A2A row) is empty today because the only paths that carry room/A2A rows
65287
+ * (poll + MLS dispatcher) always populate the field; DR-delivery and sync are
65288
+ * structurally 1:1-only.
65289
+ */
65290
+ _isNonDmConversation(convId, convGroupId) {
65291
+ const ids = [convId, convGroupId].filter((x22) => !!x22);
65292
+ if (ids.length === 0) return false;
65293
+ if (this._persisted?.rooms) {
65294
+ for (const room of Object.values(this._persisted.rooms)) {
65295
+ if (room.conversationIds?.some((c22) => ids.includes(c22))) return true;
65296
+ }
65297
+ }
65298
+ if (this._persisted?.a2aChannels) {
65299
+ for (const ch2 of Object.values(this._persisted.a2aChannels)) {
65300
+ if (ch2.conversationId && ids.includes(ch2.conversationId)) return true;
65301
+ }
65302
+ }
65303
+ return false;
65304
+ }
65249
65305
  /**
65250
65306
  * Get recent message history for a specific room, for LLM context injection.
65251
65307
  * Returns the last N messages tagged with `room:{roomId}`.
@@ -65499,7 +65555,7 @@ var init_channel = __esm2({
65499
65555
  */
65500
65556
  sendActivitySpan(spanData) {
65501
65557
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
65502
- const pluginVersion = true ? "0.23.3" : "0.0.0-dev";
65558
+ const pluginVersion = true ? "0.23.7" : "0.0.0-dev";
65503
65559
  const agentName = this.config.agentName ?? "Agent";
65504
65560
  const resource = {
65505
65561
  "service.name": "agentvault-agent",
@@ -65575,7 +65631,7 @@ var init_channel = __esm2({
65575
65631
  * Optional timeout rejects with an Error.
65576
65632
  */
65577
65633
  waitForDecision(decisionId, timeoutMs) {
65578
- return new Promise((resolve3, reject) => {
65634
+ return new Promise((resolve32, reject) => {
65579
65635
  let timer = null;
65580
65636
  const handler = (plaintext, metadata) => {
65581
65637
  if (metadata.messageType !== "decision_response") return;
@@ -65584,7 +65640,7 @@ var init_channel = __esm2({
65584
65640
  if (parsed.decision?.decision_id === decisionId) {
65585
65641
  if (timer) clearTimeout(timer);
65586
65642
  this.removeListener("message", handler);
65587
- resolve3({
65643
+ resolve32({
65588
65644
  decision_id: parsed.decision.decision_id,
65589
65645
  selected_option_id: parsed.decision.selected_option_id,
65590
65646
  resolved_at: parsed.decision.resolved_at,
@@ -67116,7 +67172,7 @@ var init_channel = __esm2({
67116
67172
  agentVersion: this.config.agentVersion ?? "0.0.0",
67117
67173
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67118
67174
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67119
- pluginVersion: true ? "0.23.3" : "0.0.0-dev"
67175
+ pluginVersion: true ? "0.23.7" : "0.0.0-dev"
67120
67176
  });
67121
67177
  this._telemetryReporter.startAutoFlush(3e4);
67122
67178
  }
@@ -67366,7 +67422,10 @@ var init_channel = __esm2({
67366
67422
  this.emit("disarm", { roomId: data.data?.room_id });
67367
67423
  }
67368
67424
  if (data.event === "arming_snapshot") {
67369
- this.emit("arming_snapshot", { roomIds: data.data?.room_ids ?? [] });
67425
+ this.emit("arming_snapshot", {
67426
+ workAllowed: data.data?.work_allowed === true,
67427
+ roomIds: data.data?.room_ids ?? []
67428
+ });
67370
67429
  }
67371
67430
  if (data.event === "policy_blocked") {
67372
67431
  this.emit("policy_blocked", data.data);
@@ -67426,7 +67485,7 @@ var init_channel = __esm2({
67426
67485
  agentVersion: this.config.agentVersion ?? "0.0.0",
67427
67486
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67428
67487
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67429
- pluginVersion: true ? "0.23.3" : "0.0.0-dev"
67488
+ pluginVersion: true ? "0.23.7" : "0.0.0-dev"
67430
67489
  });
67431
67490
  this._telemetryReporter.startAutoFlush(3e4);
67432
67491
  }
@@ -67886,6 +67945,10 @@ var init_channel = __esm2({
67886
67945
  return;
67887
67946
  }
67888
67947
  if (messageType === "history_catchup_response") return;
67948
+ if (data.room_id || data.a2a_channel_id || this._isNonDmConversation(convId, convGroupId)) {
67949
+ console.warn(`[SecureChannel] Dropped 1:1 MLS emit for non-DM conv ${(convId ?? convGroupId ?? "?").slice(0, 8)} (room/A2A guard)`);
67950
+ return;
67951
+ }
67889
67952
  if (convId) {
67890
67953
  const session = this._sessions.get(convId);
67891
67954
  if (session && !session.activated) {
@@ -68732,7 +68795,12 @@ ${messageText}`;
68732
68795
  senderName: senderLabel,
68733
68796
  plaintext: messageText,
68734
68797
  messageType,
68735
- timestamp: data.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
68798
+ timestamp: data.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
68799
+ // Native bridge (claude-room-bridge) consumes this to decide reply
68800
+ // expectation: a human/owner speaking in a room always expects a reply,
68801
+ // an agent does not. Derived from the room roster (line ~5394) — the same
68802
+ // signal the OpenClaw mention-filter uses for loop prevention.
68803
+ senderIsAgent
68736
68804
  });
68737
68805
  const contextualMessage = senderIsAgent ? messageText : `[${senderLabel}]: ${messageText}`;
68738
68806
  Promise.resolve(this.config.onMessage?.(contextualMessage, metadata)).catch((err) => {
@@ -69856,16 +69924,7 @@ ${messageText}`;
69856
69924
  ackedIds.push(msg.queue_id);
69857
69925
  continue;
69858
69926
  }
69859
- let isRoom = false;
69860
- if (this._persisted?.rooms) {
69861
- for (const room of Object.values(this._persisted.rooms)) {
69862
- if (room.conversationIds?.includes(msg.conversation_id)) {
69863
- isRoom = true;
69864
- break;
69865
- }
69866
- }
69867
- }
69868
- if (isRoom) {
69927
+ if (msg.room_id || msg.a2a_channel_id || this._isNonDmConversation(msg.conversation_id, msg.conversation_group_id)) {
69869
69928
  ackedIds.push(msg.queue_id);
69870
69929
  continue;
69871
69930
  }
@@ -70508,7 +70567,7 @@ ${messageText}`;
70508
70567
  if (msg.sender_device_id === this._deviceId) continue;
70509
70568
  const session = this._sessions.get(msg.conversation_id);
70510
70569
  if (!session) continue;
70511
- if (this._roomIdForConversation(msg.conversation_id)) {
70570
+ if (msg.room_id || this._isNonDmConversation(msg.conversation_id)) {
70512
70571
  this._persisted.lastMessageTimestamp = msg.created_at;
70513
70572
  continue;
70514
70573
  }
@@ -86826,7 +86885,7 @@ var init_protocol = __esm2({
86826
86885
  return;
86827
86886
  }
86828
86887
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
86829
- await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
86888
+ await new Promise((resolve32) => setTimeout(resolve32, pollInterval));
86830
86889
  options?.signal?.throwIfAborted();
86831
86890
  }
86832
86891
  } catch (error210) {
@@ -86843,7 +86902,7 @@ var init_protocol = __esm2({
86843
86902
  */
86844
86903
  request(request, resultSchema, options) {
86845
86904
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
86846
- return new Promise((resolve3, reject) => {
86905
+ return new Promise((resolve32, reject) => {
86847
86906
  const earlyReject = (error210) => {
86848
86907
  reject(error210);
86849
86908
  };
@@ -86921,7 +86980,7 @@ var init_protocol = __esm2({
86921
86980
  if (!parseResult.success) {
86922
86981
  reject(parseResult.error);
86923
86982
  } else {
86924
- resolve3(parseResult.data);
86983
+ resolve32(parseResult.data);
86925
86984
  }
86926
86985
  } catch (error210) {
86927
86986
  reject(error210);
@@ -87182,12 +87241,12 @@ var init_protocol = __esm2({
87182
87241
  }
87183
87242
  } catch {
87184
87243
  }
87185
- return new Promise((resolve3, reject) => {
87244
+ return new Promise((resolve32, reject) => {
87186
87245
  if (signal.aborted) {
87187
87246
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
87188
87247
  return;
87189
87248
  }
87190
- const timeoutId = setTimeout(resolve3, interval);
87249
+ const timeoutId = setTimeout(resolve32, interval);
87191
87250
  signal.addEventListener("abort", () => {
87192
87251
  clearTimeout(timeoutId);
87193
87252
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -90172,7 +90231,7 @@ var require_compile = __commonJS({
90172
90231
  const schOrFunc = root3.refs[ref];
90173
90232
  if (schOrFunc)
90174
90233
  return schOrFunc;
90175
- let _sch = resolve3.call(this, root3, ref);
90234
+ let _sch = resolve32.call(this, root3, ref);
90176
90235
  if (_sch === void 0) {
90177
90236
  const schema = (_a32 = root3.localRefs) === null || _a32 === void 0 ? void 0 : _a32[ref];
90178
90237
  const { schemaId } = this.opts;
@@ -90199,7 +90258,7 @@ var require_compile = __commonJS({
90199
90258
  function sameSchemaEnv(s1, s22) {
90200
90259
  return s1.schema === s22.schema && s1.root === s22.root && s1.baseId === s22.baseId;
90201
90260
  }
90202
- function resolve3(root3, ref) {
90261
+ function resolve32(root3, ref) {
90203
90262
  let sch;
90204
90263
  while (typeof (sch = this.refs[ref]) == "string")
90205
90264
  ref = sch;
@@ -90766,7 +90825,7 @@ var require_fast_uri = __commonJS({
90766
90825
  }
90767
90826
  return uri;
90768
90827
  }
90769
- function resolve3(baseURI, relativeURI, options) {
90828
+ function resolve32(baseURI, relativeURI, options) {
90770
90829
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
90771
90830
  const resolved = resolveComponent(parse32(baseURI, schemelessOptions), parse32(relativeURI, schemelessOptions), schemelessOptions, true);
90772
90831
  schemelessOptions.skipEscape = true;
@@ -90993,7 +91052,7 @@ var require_fast_uri = __commonJS({
90993
91052
  var fastUri = {
90994
91053
  SCHEMES,
90995
91054
  normalize,
90996
- resolve: resolve3,
91055
+ resolve: resolve32,
90997
91056
  resolveComponent,
90998
91057
  equal,
90999
91058
  serialize,
@@ -95013,7 +95072,7 @@ var init_mcp = __esm2({
95013
95072
  let task = createTaskResult.task;
95014
95073
  const pollInterval = task.pollInterval ?? 5e3;
95015
95074
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
95016
- await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
95075
+ await new Promise((resolve32) => setTimeout(resolve32, pollInterval));
95017
95076
  const updatedTask = await extra.taskStore.getTask(taskId);
95018
95077
  if (!updatedTask) {
95019
95078
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -95954,7 +96013,7 @@ var init_dist2 = __esm2({
95954
96013
  });
95955
96014
  if (!chunk) {
95956
96015
  if (i2 === 1) {
95957
- await new Promise((resolve3) => setTimeout(resolve3));
96016
+ await new Promise((resolve32) => setTimeout(resolve32));
95958
96017
  maxReadCount = 3;
95959
96018
  continue;
95960
96019
  }
@@ -96454,9 +96513,9 @@ data:
96454
96513
  const initRequest = messages.find((m22) => isInitializeRequest(m22));
96455
96514
  const clientProtocolVersion = initRequest ? initRequest.params.protocolVersion : req.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
96456
96515
  if (this._enableJsonResponse) {
96457
- return new Promise((resolve3) => {
96516
+ return new Promise((resolve32) => {
96458
96517
  this._streamMapping.set(streamId, {
96459
- resolveJson: resolve3,
96518
+ resolveJson: resolve32,
96460
96519
  cleanup: () => {
96461
96520
  this._streamMapping.delete(streamId);
96462
96521
  }
@@ -97173,7 +97232,7 @@ var init_index = __esm2({
97173
97232
  init_skill_invoker();
97174
97233
  await init_skill_telemetry();
97175
97234
  await init_policy_enforcer();
97176
- VERSION = true ? "0.23.3" : "0.0.0-dev";
97235
+ VERSION = true ? "0.23.7" : "0.0.0-dev";
97177
97236
  }
97178
97237
  });
97179
97238
  await init_index();
@@ -118477,21 +118536,21 @@ function Z_($10, Q4) {
118477
118536
 
118478
118537
  // src/worker-permission.ts
118479
118538
  import { realpathSync as realpathSync2 } from "node:fs";
118480
- import { resolve as resolve2, dirname, basename, sep, isAbsolute } from "node:path";
118539
+ import { resolve as resolve3, dirname as dirname2, basename, sep as sep2, isAbsolute } from "node:path";
118481
118540
  var PATH_FIELDS = ["file_path", "path", "notebook_path"];
118482
118541
  function canonical(p2) {
118483
- const abs = resolve2(p2);
118542
+ const abs = resolve3(p2);
118484
118543
  try {
118485
118544
  return realpathSync2(abs);
118486
118545
  } catch {
118487
118546
  const suffix = [];
118488
118547
  let dir = abs;
118489
118548
  for (; ; ) {
118490
- const parent2 = dirname(dir);
118549
+ const parent2 = dirname2(dir);
118491
118550
  suffix.unshift(basename(dir));
118492
118551
  if (parent2 === dir) return abs;
118493
118552
  try {
118494
- return resolve2(realpathSync2(parent2), ...suffix);
118553
+ return resolve3(realpathSync2(parent2), ...suffix);
118495
118554
  } catch {
118496
118555
  dir = parent2;
118497
118556
  }
@@ -118508,7 +118567,7 @@ function pathsOf(input) {
118508
118567
  return out;
118509
118568
  }
118510
118569
  function within(canonTarget, canonRoot) {
118511
- return canonTarget === canonRoot || canonTarget.startsWith(canonRoot + sep);
118570
+ return canonTarget === canonRoot || canonTarget.startsWith(canonRoot + sep2);
118512
118571
  }
118513
118572
  function globPatternEscapes(pattern) {
118514
118573
  if (typeof pattern !== "string" || pattern.length === 0) return true;
@@ -132433,6 +132492,15 @@ var PersistentClaudeSession = class {
132433
132492
  * disarm denies the very next tool call. wireBridge binds it to the ArmingState
132434
132493
  * for this turn's room; an unarmed room turn's getter returns false. */
132435
132494
  currentArmedGetter = () => false;
132495
+ /** Per-turn: does this turn's sender expect a reply? DECOUPLED from
132496
+ * `currentAutoReply` on purpose — it drives ONLY the #416 plain-text fallback
132497
+ * (deliver assistant text when the model never calls say), NOT the tool gate.
132498
+ * True for owner 1:1 DMs (defaults from autoReplyOnText) AND for a HUMAN/owner
132499
+ * speaking in a room (wireBridge sets replyExpected from !senderIsAgent). An
132500
+ * agent-authored room turn keeps this false → the agent may still stay silent,
132501
+ * which also prevents agent↔agent reply loops. Tool access is unaffected: it
132502
+ * stays gated on currentAutoReply (owner DM) OR currentArmedGetter (armed room). */
132503
+ currentReplyExpected = false;
132436
132504
  saidThisTurn = false;
132437
132505
  turnText = "";
132438
132506
  /** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
@@ -132458,7 +132526,13 @@ var PersistentClaudeSession = class {
132458
132526
  parent_tool_use_id: null,
132459
132527
  session_id: ""
132460
132528
  };
132461
- const item = { msg, reply, autoReplyOnText: opts?.autoReplyOnText, armed: opts?.armed };
132529
+ const item = {
132530
+ msg,
132531
+ reply,
132532
+ autoReplyOnText: opts?.autoReplyOnText,
132533
+ replyExpected: opts?.replyExpected,
132534
+ armed: opts?.armed
132535
+ };
132462
132536
  if (this.waiting) {
132463
132537
  const w2 = this.waiting;
132464
132538
  this.waiting = null;
@@ -132487,12 +132561,13 @@ var PersistentClaudeSession = class {
132487
132561
  } else if (this.opts.ephemeral) {
132488
132562
  return;
132489
132563
  } else {
132490
- item = await new Promise((resolve3) => {
132491
- this.waiting = resolve3;
132564
+ item = await new Promise((resolve4) => {
132565
+ this.waiting = resolve4;
132492
132566
  });
132493
132567
  }
132494
132568
  this.activeReply = item.reply;
132495
132569
  this.currentAutoReply = item.autoReplyOnText ?? false;
132570
+ this.currentReplyExpected = item.replyExpected ?? item.autoReplyOnText ?? false;
132496
132571
  this.currentArmedGetter = item.armed ?? (() => false);
132497
132572
  this.saidThisTurn = false;
132498
132573
  this.turnText = "";
@@ -132530,7 +132605,13 @@ var PersistentClaudeSession = class {
132530
132605
  // D5: currentArmedGetter() is called HERE on every tool decision, so a
132531
132606
  // mid-turn disarm denies the next call. See worker-permission.test.ts +
132532
132607
  // session.test.ts for the covering assertions.
132533
- isToolTurn: () => this.currentAutoReply || this.currentArmedGetter(),
132608
+ //
132609
+ // Task 4 (C-1/H-3) HARD BACKSTOP: device-level `workAllowed()` is a top-level
132610
+ // AND over BOTH signals — NO tool runs (owner DM OR armed room) when Work is
132611
+ // off, even if the router ever mis-routed. `?? false` is the fail-closed
132612
+ // default (no getter ⇒ no tools). Read LIVE per tool decision so a Work-off
132613
+ // flip / fail-closed reconnect window denies the very next call.
132614
+ isToolTurn: () => (this.opts.workAllowed?.() ?? false) && (this.currentAutoReply || this.currentArmedGetter()),
132534
132615
  // Slice 2 Plan B: emit an audit self-report for each tool decision on an
132535
132616
  // armed-room turn (both allow and deny). room_say is excluded by the hook.
132536
132617
  isArmedTurn: () => this.currentArmedGetter(),
@@ -132606,7 +132687,7 @@ var PersistentClaudeSession = class {
132606
132687
  }
132607
132688
  } else if (m6.type === "result") {
132608
132689
  const reply = this.activeReply;
132609
- if (this.currentAutoReply && !this.saidThisTurn && this.turnText.trim() && reply) {
132690
+ if (this.currentReplyExpected && !this.saidThisTurn && this.turnText.trim() && reply) {
132610
132691
  void reply(this.turnText);
132611
132692
  }
132612
132693
  }
@@ -132652,6 +132733,7 @@ var WorkerQueue = class {
132652
132733
  session = this.deps.makeSession(task);
132653
132734
  session.push(task.instruction, task.reply, {
132654
132735
  autoReplyOnText: task.autoReplyOnText,
132736
+ replyExpected: task.replyExpected,
132655
132737
  armed: task.armed
132656
132738
  });
132657
132739
  const currentSession = session;
@@ -132682,22 +132764,26 @@ function makeRouter(deps) {
132682
132764
  push(text, reply, opts) {
132683
132765
  const replySink = reply ?? (() => {
132684
132766
  });
132685
- if (deps.worker) {
132686
- const isOwnerDm = opts?.autoReplyOnText === true;
132687
- const isArmedRoom = opts?.armed?.() === true;
132688
- if (isOwnerDm) {
132689
- deps.queue.enqueue({ instruction: text, reply: replySink, autoReplyOnText: true });
132690
- return;
132691
- }
132692
- if (isArmedRoom) {
132693
- deps.queue.enqueue({
132694
- instruction: text,
132695
- reply: replySink,
132696
- autoReplyOnText: false,
132697
- armed: opts.armed
132698
- });
132699
- return;
132700
- }
132767
+ const isOwnerDm = opts?.autoReplyOnText === true && deps.workAllowed();
132768
+ const isArmedRoom = opts?.armed?.() === true && deps.workAllowed();
132769
+ if (isOwnerDm) {
132770
+ deps.queue.enqueue({
132771
+ instruction: text,
132772
+ reply: replySink,
132773
+ autoReplyOnText: true,
132774
+ replyExpected: opts?.replyExpected
132775
+ });
132776
+ return;
132777
+ }
132778
+ if (isArmedRoom) {
132779
+ deps.queue.enqueue({
132780
+ instruction: text,
132781
+ reply: replySink,
132782
+ autoReplyOnText: false,
132783
+ replyExpected: opts?.replyExpected,
132784
+ armed: opts.armed
132785
+ });
132786
+ return;
132701
132787
  }
132702
132788
  deps.listener.push(text, reply, opts);
132703
132789
  }
@@ -132712,26 +132798,58 @@ var ArmingState = class {
132712
132798
  consumed = /* @__PURE__ */ new Set();
132713
132799
  // request-ids already approved (one-shot)
132714
132800
  /**
132715
- * Replace the armed set. Use ONLY for the launch-env seed (AV_ARM_ROOM), which
132716
- * is itself a host-local action and is therefore allowed to arm.
132801
+ * Replace the armed set. Use for the host-local launch seed (AV_ARM_ROOM)
132802
+ * itself a host-local action and therefore allowed to arm — and as the
132803
+ * primitive `applyAuthoritative` below delegates to for non-shell workers.
132717
132804
  *
132718
- * Do NOT use this for the backend connect `arming_snapshot`: that snapshot
132719
- * reflects owner *intent* (set at web-request time, before any local approval),
132720
- * so arming from it would let a web-session compromise arm a worker on reconnect
132721
- * without host access defeating the 2-of-2 local-approval model. The channel
132722
- * snapshot path uses `reconcileDisarm` (disarm-only) instead.
132805
+ * The B-pure posture (Task 4) treats the backend connect/live
132806
+ * `arming_snapshot` the same way see `applyAuthoritative`'s docstring for
132807
+ * the current arm-from-snapshot rationale. Shell-capable (OS-isolated)
132808
+ * workers do NOT use this path; they stay on `reconcileDisarm` (disarm-only,
132809
+ * see below) until #20.
132723
132810
  */
132724
132811
  applySnapshot(roomIds) {
132725
132812
  this.armed = new Set(roomIds);
132726
132813
  }
132727
132814
  /**
132728
- * Disarm-only reconciliation from the backend connect snapshot. Disarms any
132729
- * currently-armed room whose intent is no longer armed (i.e. NOT in
132730
- * `intendedArmed`) so a disarm issued while the bridge was offline still takes
132731
- * effect on reconnect but NEVER arms a room. Arming stays exclusively behind a
132732
- * local `approve-arm`. Returns the rooms that were disarmed. In-memory arming
132733
- * survives a WS reconnect, so a genuinely-approved arm is unaffected here; only a
132734
- * full process restart (which clears this state) requires re-approval on the host.
132815
+ * Set the armed set to EXACTLY `intendedArmed`: arms every room in the list
132816
+ * that isn't already armed, and disarms every currently-armed room that is
132817
+ * absent from it. This is the AUTHORITATIVE arm-from-snapshot path (Task 4,
132818
+ * reversing the prior disarm-only safeguard for non-shell workers).
132819
+ *
132820
+ * Why arming from the snapshot is now safe: `intendedArmed` is derived
132821
+ * server-side from `devices.work_allowed`, and that flag can only be flipped
132822
+ * by `PUT /devices/{id}/work_allowed`, which REJECTS any device-bound caller
132823
+ * outright (C1 guard — a human account owner only; an agent, including a
132824
+ * compromised one, cannot self-authorize). So "arm from snapshot" no longer
132825
+ * means "a web session can arm a worker" in the old adversarial sense — it
132826
+ * means "the verified owner's grant is applied end-to-end, live, without
132827
+ * requiring a separate host-local approval step for every reconnect." This is
132828
+ * the accepted B-pure posture; see #20 to harden it further with local
132829
+ * approval for non-shell workers too. Shell-capable (OS-isolated) workers are
132830
+ * carved out of this path entirely (C2) — see the shell-gate in bridge.ts —
132831
+ * because a shell worker armed by a web flag is a live RCE surface even under
132832
+ * an honest owner (a compromised owner web session, or a compromised backend,
132833
+ * would get arbitrary code execution). Those workers stay on `reconcileDisarm`
132834
+ * (disarm-only) until #20 delivers local approval for them as well.
132835
+ */
132836
+ applyAuthoritative(intendedArmed) {
132837
+ this.applySnapshot(intendedArmed);
132838
+ }
132839
+ /**
132840
+ * Disarm-only reconciliation from the backend connect/live snapshot. Disarms
132841
+ * any currently-armed room whose intent is no longer armed (i.e. NOT in
132842
+ * `intendedArmed`) — so a disarm issued while the bridge was offline still
132843
+ * takes effect on reconnect — but NEVER arms a room. Used for OS-isolated
132844
+ * (shell-capable) workers ONLY (C2): a shell worker must never be armed by a
132845
+ * web-originated flag, because a compromised owner session or backend would
132846
+ * translate directly into host code execution. Those workers can be armed
132847
+ * only by the host-local launch seed (`AV_ARM_ROOM`, via `applySnapshot`)
132848
+ * until #20 delivers a local-approval path for them too. Non-shell workers
132849
+ * use `applyAuthoritative` instead (Task 4) — see its docstring. Returns the
132850
+ * rooms that were disarmed. In-memory arming survives a WS reconnect, so a
132851
+ * genuinely-armed room is unaffected here; only a full process restart
132852
+ * (which clears this state) requires re-arming.
132735
132853
  */
132736
132854
  reconcileDisarm(intendedArmed) {
132737
132855
  const keep = new Set(intendedArmed);
@@ -132785,7 +132903,7 @@ var ArmingState = class {
132785
132903
  };
132786
132904
 
132787
132905
  // src/approve-cli.ts
132788
- import { mkdirSync as mkdirSync2, writeFileSync, readFileSync as readFileSync4, readdirSync as readdirSync2, rmSync as rmSync2, existsSync as existsSync3 } from "node:fs";
132906
+ import { mkdirSync as mkdirSync3, writeFileSync, readFileSync as readFileSync4, readdirSync as readdirSync2, rmSync as rmSync2, existsSync as existsSync3 } from "node:fs";
132789
132907
  import { join as join6 } from "node:path";
132790
132908
  var APPROVALS_SUBDIR = "arm-approvals";
132791
132909
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
@@ -132807,7 +132925,7 @@ function writeApproval(dataDir, requestId, roomId) {
132807
132925
  const id = sanitizeRequestId(requestId);
132808
132926
  const room = sanitizeRoomId(roomId);
132809
132927
  const dir = join6(dataDir, APPROVALS_SUBDIR);
132810
- mkdirSync2(dir, { recursive: true });
132928
+ mkdirSync3(dir, { recursive: true });
132811
132929
  writeFileSync(join6(dir, id), room);
132812
132930
  }
132813
132931
  function drainApprovals(dataDir) {
@@ -132947,6 +133065,12 @@ function pollApprovalsOnce(arming, dataDir, onArmed, log = () => {
132947
133065
  function wireBridge(channel, session, target, opts = {}) {
132948
133066
  const log = opts.log ?? (() => {
132949
133067
  });
133068
+ let workAllowed = false;
133069
+ const workAllowedGetter = () => workAllowed;
133070
+ opts.onWorkAllowed?.(workAllowedGetter);
133071
+ channel.on("state", (s10) => {
133072
+ if (s10 !== "ready") workAllowed = false;
133073
+ });
132950
133074
  const arming = new ArmingState();
132951
133075
  if (opts.armRoom === true && opts.roomFilter) {
132952
133076
  arming.applySnapshot([opts.roomFilter]);
@@ -132972,6 +133096,7 @@ function wireBridge(channel, session, target, opts = {}) {
132972
133096
  target.setRoom(e7.roomId);
132973
133097
  session.push(`[${e7.senderName}]: ${e7.plaintext}`, target.snapshotReply(channel, log), {
132974
133098
  autoReplyOnText: false,
133099
+ replyExpected: e7.senderIsAgent === false,
132975
133100
  armed: () => arming.isArmed(e7.roomId)
132976
133101
  });
132977
133102
  });
@@ -132981,7 +133106,8 @@ function wireBridge(channel, session, target, opts = {}) {
132981
133106
  target.setDm();
132982
133107
  session.push(text, target.snapshotReply(channel, log), { autoReplyOnText: true });
132983
133108
  });
132984
- const workerCapable = !!(opts.worker && opts.workspaceDir);
133109
+ const workerCapable = !!opts.workspaceDir;
133110
+ const osIsolated = opts.osIsolated !== void 0 ? opts.osIsolated : process.env.AV_WORKER_OS_ISOLATED === "1" || process.env.AV_WORKER_OS_ISOLATED === "true";
132985
133111
  const heartbeat = () => {
132986
133112
  try {
132987
133113
  channel.sendWorkerHeartbeat?.({ workerCapable, armedRooms: arming.armedRooms() });
@@ -132991,21 +133117,33 @@ function wireBridge(channel, session, target, opts = {}) {
132991
133117
  };
132992
133118
  heartbeat();
132993
133119
  channel.on("ready", () => heartbeat());
132994
- channel.on("arming_snapshot", (s10) => {
133120
+ const applyArmingSnapshot = (s10) => {
133121
+ workAllowed = s10?.workAllowed === true;
132995
133122
  if (!workerCapable) {
132996
133123
  log("arming_snapshot ignored \u2014 not worker-capable");
132997
133124
  return;
132998
133125
  }
133126
+ const roomIds = s10?.roomIds ?? [];
132999
133127
  try {
133000
- const dropped = arming.reconcileDisarm(s10?.roomIds ?? []);
133001
- if (dropped.length > 0) {
133002
- log(`arming snapshot reconcile \u2014 disarmed: [${dropped.map((r7) => r7.slice(0, 8)).join(", ")}]`);
133003
- heartbeat();
133128
+ const before = new Set(arming.armedRooms());
133129
+ if (osIsolated) {
133130
+ const dropped = arming.reconcileDisarm(roomIds);
133131
+ if (dropped.length > 0) {
133132
+ log(
133133
+ `arming_snapshot: OS-isolated worker \u2014 disarm-only until #20 (web cannot arm shell workers); disarmed: [${dropped.map((r7) => r7.slice(0, 8)).join(", ")}]`
133134
+ );
133135
+ }
133136
+ } else {
133137
+ arming.applyAuthoritative(roomIds);
133004
133138
  }
133139
+ const after = arming.armedRooms();
133140
+ const changed = after.length !== before.size || after.some((r7) => !before.has(r7));
133141
+ if (changed) heartbeat();
133005
133142
  } catch (err) {
133006
133143
  log(`arming_snapshot handling failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
133007
133144
  }
133008
- });
133145
+ };
133146
+ channel.on("arming_snapshot", applyArmingSnapshot);
133009
133147
  channel.on("disarm", (d10) => {
133010
133148
  if (!workerCapable) {
133011
133149
  log("disarm ignored \u2014 not worker-capable");
@@ -133067,22 +133205,20 @@ async function main() {
133067
133205
  "[bridge] warning: passing the invite token on the command line is visible to other local users via 'ps'. Prefer: AV_INVITE_TOKEN=\u2026 npx @agentvault/claude-bridge"
133068
133206
  );
133069
133207
  }
133070
- console.error(`[bridge] version: ${true ? "0.5.8" : "dev"}`);
133208
+ console.error(`[bridge] version: ${true ? "0.5.10" : "dev"}`);
133071
133209
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
133072
- if (cfg.worker) {
133073
- console.error(`[bridge] WORKER MODE \u2014 workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
133074
- if (cfg.armRoom) {
133075
- console.error(
133076
- `[bridge] ARMED ROOM ${cfg.roomFilter} \u2014 worker tools are enabled on this room's turns. The enclave key dir stays fenced; the workspace dir is ADVISORY (the SDK does not confine file ops to it \u2014 they root in $HOME). Blast radius = whatever this host/environment exposes.`
133077
- );
133078
- console.error(
133079
- "[bridge] ARMED ROOM WARNING: this room contains agents you do not control. A room peer's message can drive your agent's tools. The directory is NOT a sandbox \u2014 run this bridge in an owner-provisioned confined environment (dedicated droplet / container / OS-user) with no connected accounts or ambient credentials. See the Slice 2 hardening guide."
133080
- );
133081
- } else {
133082
- console.error(
133083
- "[bridge] WORKER MODE: tools run on owner DM turns only. Rooms stay say-only unless armed (AV_ARM_ROOM=1 with a pinned AV_ROOM_ID). Cross-turn injection note: a persistent session mixes room context with DM turns \u2014 run DM-only or OS-isolated if you do not arm a room."
133084
- );
133085
- }
133210
+ console.error(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
133211
+ if (cfg.armRoom) {
133212
+ console.error(
133213
+ `[bridge] ARMED ROOM ${cfg.roomFilter} \u2014 worker tools are enabled on this room's turns. The enclave key dir stays fenced; the workspace dir is ADVISORY (the SDK does not confine file ops to it \u2014 they root in $HOME). Blast radius = whatever this host/environment exposes.`
133214
+ );
133215
+ console.error(
133216
+ "[bridge] ARMED ROOM WARNING: this room contains agents you do not control. A room peer's message can drive your agent's tools. The directory is NOT a sandbox \u2014 run this bridge in an owner-provisioned confined environment (dedicated droplet / container / OS-user) with no connected accounts or ambient credentials. See the Slice 2 hardening guide."
133217
+ );
133218
+ } else {
133219
+ console.error(
133220
+ '[bridge] tools run on owner DM turns only when Work is allowed (the dashboard "Work" switch). Rooms stay say-only unless armed (AV_ARM_ROOM=1 with a pinned AV_ROOM_ID). Cross-turn injection note: a persistent session mixes room context with DM turns \u2014 run DM-only or OS-isolated if you do not arm a room.'
133221
+ );
133086
133222
  }
133087
133223
  const target = new ActiveTarget();
133088
133224
  if (cfg.roomFilter) target.setRoom(cfg.roomFilter);
@@ -133098,6 +133234,8 @@ async function main() {
133098
133234
  const c4 = channel;
133099
133235
  return c4._deviceJwt ?? c4._persisted?.deviceJwt ?? null;
133100
133236
  };
133237
+ let liveWorkAllowed = () => false;
133238
+ const workAllowed = () => liveWorkAllowed();
133101
133239
  const listener = new PersistentClaudeSession({
133102
133240
  model: cfg.model,
133103
133241
  systemPrompt: agentSystemPrompt,
@@ -133118,6 +133256,9 @@ async function main() {
133118
133256
  systemPrompt: agentSystemPrompt,
133119
133257
  onObserve: (text) => console.error(`[worker] observed (${text.length} chars, not sent)`),
133120
133258
  worker: true,
133259
+ // Task 4: the hard backstop — no tool runs on any worker turn (owner DM or
133260
+ // armed room) unless Work is on. Read live per tool decision.
133261
+ workAllowed,
133121
133262
  ephemeral: true,
133122
133263
  maxTurns: WORKER_MAX_TURNS,
133123
133264
  workspaceDir: cfg.workspaceDir,
@@ -133133,7 +133274,7 @@ async function main() {
133133
133274
  timeoutMs: WORKER_TIMEOUT_MS,
133134
133275
  log: (m6) => console.error(m6)
133135
133276
  });
133136
- const router = makeRouter({ worker: !!cfg.worker, listener, queue: workerQueue });
133277
+ const router = makeRouter({ workAllowed, listener, queue: workerQueue });
133137
133278
  wireBridge(
133138
133279
  channel,
133139
133280
  { push: (t7, reply, opts) => router.push(t7, reply, opts) },
@@ -133143,9 +133284,14 @@ async function main() {
133143
133284
  armRoom: cfg.armRoom,
133144
133285
  log: (m6) => console.error("[bridge] " + m6),
133145
133286
  // Slice 2 Plan C (T11): live arm/disarm + local-approval poll + heartbeat.
133146
- worker: cfg.worker,
133147
133287
  workspaceDir: cfg.workspaceDir,
133148
- dataDir: cfg.dataDir
133288
+ dataDir: cfg.dataDir,
133289
+ osIsolated: cfg.osIsolated,
133290
+ // Task 4: capture the live fail-closed workAllowed getter (set synchronously
133291
+ // during wiring) into the indirection the router + worker session read live.
133292
+ onWorkAllowed: (getter) => {
133293
+ liveWorkAllowed = getter;
133294
+ }
133149
133295
  }
133150
133296
  );
133151
133297
  attachLifecycle2(channel, {
package/dist/router.d.ts CHANGED
@@ -4,12 +4,21 @@ import type { WorkerTask } from "./worker-queue.js";
4
4
  export interface PushLike {
5
5
  push(text: string, reply?: ReplySink, opts?: {
6
6
  autoReplyOnText?: boolean;
7
+ replyExpected?: boolean;
7
8
  armed?: () => boolean;
8
9
  }): void;
9
10
  }
10
11
  export interface RouterDeps {
11
- /** AV_WORKER=1 → tool-eligible turns run in isolated workers; false → no worker ever. */
12
- worker: boolean;
12
+ /**
13
+ * Task 4 (C-1/H-3): live, fail-closed device-level tool capability ("Work").
14
+ * MUST be read live at each push (never snapshotted) so a mid-connection flip
15
+ * and the fail-closed reconnect window both take effect immediately. Gates
16
+ * BOTH tool-eligible lanes — owner-DM and armed-room: Work off → the turn
17
+ * falls through to the tools-absent listener (structural fail-safe), making
18
+ * `work_allowed` the single on/off control for tool capability. The
19
+ * session-level isToolTurn AND remains the hard backstop for both lanes.
20
+ */
21
+ workAllowed: () => boolean;
13
22
  /** Always-on locked listener (tools:[]) for untrusted/non-tool turns. */
14
23
  listener: PushLike;
15
24
  /** Serial queue of tool-eligible turns. */
@@ -23,8 +32,9 @@ export interface RouterDeps {
23
32
  * worker via the queue; everything else stays on the locked listener. A room
24
33
  * turn's `armed` getter is read once here at routing time to pick the lane; the
25
34
  * same live getter is threaded to the worker task so a mid-turn disarm still
26
- * denies the next tool call (Facet A D5). Locked agents (worker=false) always
27
- * route to the listener a strict no-op vs today.
35
+ * denies the next tool call (Facet A D5). `work_allowed` is the single control
36
+ * gating BOTH lanes: an armed room turn is only routed to the worker queue when
37
+ * workAllowed() is also true, so Work off always falls through to the listener.
28
38
  */
29
39
  export declare function makeRouter(deps: RouterDeps): PushLike;
30
40
  //# sourceMappingURL=router.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"spec.d.ts","sourceRoot":"","sources":["../../src/service/spec.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGjD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,wBAAwB,EAAE,OAAO,CAAC;IAClC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA2BvF"}
1
+ {"version":3,"file":"spec.d.ts","sourceRoot":"","sources":["../../src/service/spec.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGjD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,wBAAwB,EAAE,OAAO,CAAC;IAClC,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,gBAAgB,GAAG,WAAW,CA0BvF"}
package/dist/session.d.ts CHANGED
@@ -79,6 +79,13 @@ export interface SessionOpts {
79
79
  ephemeral?: boolean;
80
80
  /** Turn cap for a worker query() (bounds a runaway tool loop). */
81
81
  maxTurns?: number;
82
+ /**
83
+ * Task 4 (C-1/H-3): live, fail-closed device-level tool capability ("Work").
84
+ * Folded into `isToolTurn` as a top-level AND — the HARD backstop that makes
85
+ * the fail-safe real: NO tool runs (owner DM OR armed room) when this returns
86
+ * false, even if the router mis-routed. Read LIVE at each tool decision (never
87
+ * snapshotted). Absent getter ⇒ `?? false` ⇒ fail-closed (no tools). */
88
+ workAllowed?: () => boolean;
82
89
  }
83
90
  export declare class PersistentClaudeSession {
84
91
  private opts;
@@ -107,6 +114,15 @@ export declare class PersistentClaudeSession {
107
114
  * disarm denies the very next tool call. wireBridge binds it to the ArmingState
108
115
  * for this turn's room; an unarmed room turn's getter returns false. */
109
116
  private currentArmedGetter;
117
+ /** Per-turn: does this turn's sender expect a reply? DECOUPLED from
118
+ * `currentAutoReply` on purpose — it drives ONLY the #416 plain-text fallback
119
+ * (deliver assistant text when the model never calls say), NOT the tool gate.
120
+ * True for owner 1:1 DMs (defaults from autoReplyOnText) AND for a HUMAN/owner
121
+ * speaking in a room (wireBridge sets replyExpected from !senderIsAgent). An
122
+ * agent-authored room turn keeps this false → the agent may still stay silent,
123
+ * which also prevents agent↔agent reply loops. Tool access is unaffected: it
124
+ * stays gated on currentAutoReply (owner DM) OR currentArmedGetter (armed room). */
125
+ private currentReplyExpected;
110
126
  private saidThisTurn;
111
127
  private turnText;
112
128
  /** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
@@ -128,6 +144,7 @@ export declare class PersistentClaudeSession {
128
144
  */
129
145
  push(text: string, reply?: ReplySink, opts?: {
130
146
  autoReplyOnText?: boolean;
147
+ replyExpected?: boolean;
131
148
  armed?: () => boolean;
132
149
  }): void;
133
150
  /** Route a say-tool message to the reply bound to the in-flight message, falling
@@ -7,6 +7,11 @@ export type WorkerTask = {
7
7
  reply: ReplySink;
8
8
  /** Owner-DM turns want a plain-text reply fallback (#416). */
9
9
  autoReplyOnText: boolean;
10
+ /** A human/owner sender expects a reply: if the worker answers in plain text
11
+ * without calling say, deliver that text instead of dropping it. Decoupled from
12
+ * autoReplyOnText so it delivers on a tool-capable room turn WITHOUT enabling
13
+ * tools (arming governs tools). Absent/false for agent-authored turns. */
14
+ replyExpected?: boolean;
10
15
  /** Armed-room turns: live getter the gate reads per tool decision (mid-turn
11
16
  * disarm denies the next call). Absent for owner DMs. */
12
17
  armed?: () => boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "type": "module",
5
5
  "description": "AgentVault Claude Bridge — daemon for bridging a Claude agent into secure E2E-encrypted AgentVault 1:1 direct messages and rooms.",
6
6
  "main": "dist/index.js",