@agentvault/claude-bridge 0.5.10 → 0.6.1

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/bridge.d.ts CHANGED
@@ -26,6 +26,8 @@ export interface ArmingSnapshot {
26
26
  * (backend `devices.work_allowed`, Task 2). Optional so older/test payloads
27
27
  * without it don't break — treated as `false` (fail-closed) when absent. */
28
28
  workAllowed?: boolean;
29
+ /** #627: owner's "Remove all gates" grant. Absent ⇒ gates stay in place. */
30
+ gatesRemoved?: boolean;
29
31
  }
30
32
  /** Metadata SecureChannel attaches to a 1:1 `message` event. `roomId` is set only
31
33
  * when the `message` actually originated in a room (handled by room_message), so
@@ -176,9 +178,16 @@ export declare function wireBridge(channel: RoomChannel, session: RoomSession, t
176
178
  * Task 4 (C2 shell-gate): true when this worker runs Bash unconfined
177
179
  * (AV_WORKER_OS_ISOLATED=1 — see config.ts/index.ts, threaded from the
178
180
  * 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.
181
+ * disarm-only regardless of B1/worker-capability — a shell worker is not
182
+ * armed by a web-originated flag (RCE risk).
183
+ *
184
+ * #627 EXCEPTION: unless the owner has removed all gates. That grant is an
185
+ * explicit, recorded decision to accept exactly this risk, so it lifts C2;
186
+ * revoking it disarms the rooms the web armed under it. See the
187
+ * `applyArmingSnapshot` comment block for the full rationale. Without the
188
+ * grant, C2 is unchanged.
189
+ *
190
+ * Falls back to reading the env directly only if the caller doesn't thread this.
182
191
  */
183
192
  osIsolated?: boolean;
184
193
  /**
@@ -190,5 +199,13 @@ export declare function wireBridge(channel: RoomChannel, session: RoomSession, t
190
199
  * (index.ts, arming-authoritative.test.ts, bridge.test.ts) stays intact.
191
200
  */
192
201
  onWorkAllowed?: (getter: () => boolean) => void;
202
+ /**
203
+ * #627: out-param that receives the live `gatesRemoved()` getter once,
204
+ * synchronously, during wiring — same shape as `onWorkAllowed` above, and
205
+ * for the same reason (keeps wireBridge's return type stable so existing
206
+ * callers are untouched). index.ts captures it and threads it into the
207
+ * worker session's gate.
208
+ */
209
+ onGatesRemoved?: (getter: () => boolean) => void;
193
210
  }): ArmingState;
194
211
  //# sourceMappingURL=bridge.d.ts.map
package/dist/index.js CHANGED
@@ -65555,7 +65555,7 @@ var init_channel = __esm2({
65555
65555
  */
65556
65556
  sendActivitySpan(spanData) {
65557
65557
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
65558
- const pluginVersion = true ? "0.23.7" : "0.0.0-dev";
65558
+ const pluginVersion = true ? "0.23.8" : "0.0.0-dev";
65559
65559
  const agentName = this.config.agentName ?? "Agent";
65560
65560
  const resource = {
65561
65561
  "service.name": "agentvault-agent",
@@ -67172,7 +67172,7 @@ var init_channel = __esm2({
67172
67172
  agentVersion: this.config.agentVersion ?? "0.0.0",
67173
67173
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67174
67174
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67175
- pluginVersion: true ? "0.23.7" : "0.0.0-dev"
67175
+ pluginVersion: true ? "0.23.8" : "0.0.0-dev"
67176
67176
  });
67177
67177
  this._telemetryReporter.startAutoFlush(3e4);
67178
67178
  }
@@ -67251,6 +67251,10 @@ var init_channel = __esm2({
67251
67251
  await this._handleDeviceRevoked();
67252
67252
  return;
67253
67253
  }
67254
+ if (data.event === "connection_rejected") {
67255
+ await this._handleConnectionRejected(data);
67256
+ return;
67257
+ }
67254
67258
  if (data.event === "device_linked") {
67255
67259
  await this._handleDeviceLinked(data.data);
67256
67260
  return;
@@ -67424,6 +67428,7 @@ var init_channel = __esm2({
67424
67428
  if (data.event === "arming_snapshot") {
67425
67429
  this.emit("arming_snapshot", {
67426
67430
  workAllowed: data.data?.work_allowed === true,
67431
+ gatesRemoved: data.data?.gates_removed === true,
67427
67432
  roomIds: data.data?.room_ids ?? []
67428
67433
  });
67429
67434
  }
@@ -67485,7 +67490,7 @@ var init_channel = __esm2({
67485
67490
  agentVersion: this.config.agentVersion ?? "0.0.0",
67486
67491
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67487
67492
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67488
- pluginVersion: true ? "0.23.7" : "0.0.0-dev"
67493
+ pluginVersion: true ? "0.23.8" : "0.0.0-dev"
67489
67494
  });
67490
67495
  this._telemetryReporter.startAutoFlush(3e4);
67491
67496
  }
@@ -70677,6 +70682,37 @@ ${messageText}`;
70677
70682
  * re-enrollment, which SHOULD wipe creds, goes through setup --force /
70678
70683
  * _forceReEnroll in start().
70679
70684
  */
70685
+ /**
70686
+ * Handle a WS `connection_rejected` event.
70687
+ *
70688
+ * The server accepts the WS upgrade, then — for a device it will not serve —
70689
+ * emits `{event:"connection_rejected", reason, retryable}` and closes with
70690
+ * code 4403. `retryable:false` (e.g. `device_revoked`) is a TERMINAL
70691
+ * credential failure: retrying can only reconnect, get rejected, and close
70692
+ * again. Left unhandled, that generic close drove the reconnect classifier
70693
+ * into "reconnect loop detected", which the bridge treats as a restartable
70694
+ * exit — so launchd/systemd relaunched us every ~30s to hammer the backend
70695
+ * with the same rejected handshake (empirically ~1150 rejects / 2h).
70696
+ *
70697
+ * So we route a non-retryable rejection to the SAME `auth_failed` surface the
70698
+ * proactive/reactive reissue paths use (see channel.ts emit sites). The
70699
+ * bridge wires `auth_failed` to a clean exit(0) (packages/claude-room-bridge
70700
+ * /src/bridge.ts), so launchd stops relaunching. Mirrors those emitters:
70701
+ * emit only + mark the session; the consumer decides how to stop. A
70702
+ * `retryable` rejection is transient — fall through to a normal reconnect.
70703
+ */
70704
+ async _handleConnectionRejected(data) {
70705
+ if (data?.retryable === true) {
70706
+ this._scheduleReconnect();
70707
+ return;
70708
+ }
70709
+ this._authFailedThisSession = true;
70710
+ const authReason = data?.reason === "device_jwt_expired" ? "device_jwt_expired" : "device_revoked";
70711
+ console.warn(
70712
+ `[SecureChannel] connection_rejected (reason=${data?.reason ?? "unknown"}, retryable=false) \u2014 terminal; surfacing auth_failed`
70713
+ );
70714
+ this.emit("auth_failed", { reason: authReason });
70715
+ }
70680
70716
  async _handleDeviceRevoked() {
70681
70717
  const now = Date.now();
70682
70718
  this._revokeRecoveries = this._revokeRecoveries.filter(
@@ -97232,7 +97268,7 @@ var init_index = __esm2({
97232
97268
  init_skill_invoker();
97233
97269
  await init_skill_telemetry();
97234
97270
  await init_policy_enforcer();
97235
- VERSION = true ? "0.23.7" : "0.0.0-dev";
97271
+ VERSION = true ? "0.23.8" : "0.0.0-dev";
97236
97272
  }
97237
97273
  });
97238
97274
  await init_index();
@@ -118580,6 +118616,12 @@ function gateDecision(toolName, input, opts) {
118580
118616
  if (!opts.isToolTurn()) {
118581
118617
  return { deny: true, reason: "tools are disabled on this turn; reply with the say tool only" };
118582
118618
  }
118619
+ if (opts.gatesRemoved?.()) {
118620
+ return { deny: false };
118621
+ }
118622
+ if (opts.isOwnerDmTurn?.()) {
118623
+ return { deny: false };
118624
+ }
118583
118625
  if (toolName === "Bash") {
118584
118626
  return opts.osIsolated ? { deny: false } : { deny: true, reason: "Bash is disabled in worker mode unless the process is OS-isolated (set AV_WORKER_OS_ISOLATED=1)" };
118585
118627
  }
@@ -132612,6 +132654,19 @@ var PersistentClaudeSession = class {
132612
132654
  // default (no getter ⇒ no tools). Read LIVE per tool decision so a Work-off
132613
132655
  // flip / fail-closed reconnect window denies the very next call.
132614
132656
  isToolTurn: () => (this.opts.workAllowed?.() ?? false) && (this.currentAutoReply || this.currentArmedGetter()),
132657
+ // Full-access lane: a 1:1 owner DM, whose ephemeral worker is seeded with
132658
+ // ONLY the owner's own message, so no third party can steer the turn.
132659
+ // Conjoined with !currentArmedGetter() deliberately: if a room turn ever
132660
+ // arrived with autoReplyOnText set, it must fall through to the allowlist
132661
+ // rather than inherit full access. Fail-safe, not merely descriptive.
132662
+ // Read LIVE per tool decision, like isToolTurn.
132663
+ isOwnerDmTurn: () => this.currentAutoReply && !this.currentArmedGetter(),
132664
+ // #627: the owner removed all gates for this device. Evaluated by
132665
+ // gateDecision AFTER isToolTurn, so an unarmed room turn stays chat-only
132666
+ // — this widens what a tool turn may do, never which turns get tools.
132667
+ // Read LIVE per decision so a revoke (or a reconnect reset) denies the
132668
+ // very next call, exactly like workAllowed above.
132669
+ gatesRemoved: () => this.opts.gatesRemoved?.() ?? false,
132615
132670
  // Slice 2 Plan B: emit an audit self-report for each tool decision on an
132616
132671
  // armed-room turn (both allow and deny). room_say is excluded by the hook.
132617
132672
  isArmedTurn: () => this.currentArmedGetter(),
@@ -132627,7 +132682,7 @@ var PersistentClaudeSession = class {
132627
132682
  }
132628
132683
  };
132629
132684
  console.error(
132630
- `[worker-gate] allowlist active \u2014 workspace=${this.opts.workspaceDir ?? "(none: file tools disabled)"}, bash=${this.opts.osIsolated ? "enabled (OS-isolated)" : "disabled"}`
132685
+ `[worker-gate] active \u2014 1:1 owner DM: FULL access, no allowlist, no workspace fence. Armed room: FULL access whenever "Remove all gates" is on (live, owner-controlled, off by default); otherwise allowlist, workspace=${this.opts.workspaceDir ?? "(none: file tools disabled)"}, bash=${this.opts.osIsolated ? "enabled (OS-isolated)" : "disabled"}`
132631
132686
  );
132632
132687
  return {
132633
132688
  ...base,
@@ -132764,7 +132819,7 @@ function makeRouter(deps) {
132764
132819
  push(text, reply, opts) {
132765
132820
  const replySink = reply ?? (() => {
132766
132821
  });
132767
- const isOwnerDm = opts?.autoReplyOnText === true && deps.workAllowed();
132822
+ const isOwnerDm = opts?.autoReplyOnText === true && opts?.armed === void 0 && deps.workAllowed();
132768
132823
  const isArmedRoom = opts?.armed?.() === true && deps.workAllowed();
132769
132824
  if (isOwnerDm) {
132770
132825
  deps.queue.enqueue({
@@ -133068,8 +133123,15 @@ function wireBridge(channel, session, target, opts = {}) {
133068
133123
  let workAllowed = false;
133069
133124
  const workAllowedGetter = () => workAllowed;
133070
133125
  opts.onWorkAllowed?.(workAllowedGetter);
133126
+ let gatesRemoved = false;
133127
+ const gatesRemovedGetter = () => gatesRemoved;
133128
+ opts.onGatesRemoved?.(gatesRemovedGetter);
133129
+ const webArmedUnderGrant = /* @__PURE__ */ new Set();
133071
133130
  channel.on("state", (s10) => {
133072
- if (s10 !== "ready") workAllowed = false;
133131
+ if (s10 !== "ready") {
133132
+ workAllowed = false;
133133
+ gatesRemoved = false;
133134
+ }
133073
133135
  });
133074
133136
  const arming = new ArmingState();
133075
133137
  if (opts.armRoom === true && opts.roomFilter) {
@@ -133119,6 +133181,7 @@ function wireBridge(channel, session, target, opts = {}) {
133119
133181
  channel.on("ready", () => heartbeat());
133120
133182
  const applyArmingSnapshot = (s10) => {
133121
133183
  workAllowed = s10?.workAllowed === true;
133184
+ gatesRemoved = s10?.gatesRemoved === true;
133122
133185
  if (!workerCapable) {
133123
133186
  log("arming_snapshot ignored \u2014 not worker-capable");
133124
133187
  return;
@@ -133126,7 +133189,20 @@ function wireBridge(channel, session, target, opts = {}) {
133126
133189
  const roomIds = s10?.roomIds ?? [];
133127
133190
  try {
133128
133191
  const before = new Set(arming.armedRooms());
133129
- if (osIsolated) {
133192
+ if (osIsolated && gatesRemoved) {
133193
+ arming.applyAuthoritative(roomIds);
133194
+ for (const r7 of roomIds) if (!before.has(r7)) webArmedUnderGrant.add(r7);
133195
+ } else if (osIsolated) {
133196
+ if (webArmedUnderGrant.size > 0) {
133197
+ const revoked = [...webArmedUnderGrant].filter((r7) => arming.isArmed(r7));
133198
+ for (const r7 of revoked) arming.disarm(r7);
133199
+ webArmedUnderGrant.clear();
133200
+ if (revoked.length > 0) {
133201
+ log(
133202
+ `arming_snapshot: "Remove all gates" was revoked \u2014 disarming the rooms it had armed on this OS-isolated worker: [${revoked.map((r7) => r7.slice(0, 8)).join(", ")}]`
133203
+ );
133204
+ }
133205
+ }
133130
133206
  const dropped = arming.reconcileDisarm(roomIds);
133131
133207
  if (dropped.length > 0) {
133132
133208
  log(
@@ -133205,7 +133281,7 @@ async function main() {
133205
133281
  "[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"
133206
133282
  );
133207
133283
  }
133208
- console.error(`[bridge] version: ${true ? "0.5.10" : "dev"}`);
133284
+ console.error(`[bridge] version: ${true ? "0.6.1" : "dev"}`);
133209
133285
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
133210
133286
  console.error(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
133211
133287
  if (cfg.armRoom) {
@@ -133217,7 +133293,7 @@ async function main() {
133217
133293
  );
133218
133294
  } else {
133219
133295
  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.'
133296
+ '[bridge] 1:1 owner DMs run with FULL tool access when "Allow tools" is on for this agent (dashboard switch, default off): any command, any file this user account can reach, network, skills and subagents \u2014 equivalent to a terminal on this machine, with no per-action prompt. Rooms are NOT affected: they stay say-only unless armed (AV_ARM_ROOM=1 with a pinned AV_ROOM_ID), and an armed room keeps the confined allowlist. Each tool-enabled turn runs in a fresh session seeded with only that one message, so room text cannot leak into a DM turn.'
133221
133297
  );
133222
133298
  }
133223
133299
  const target = new ActiveTarget();
@@ -133236,6 +133312,8 @@ async function main() {
133236
133312
  };
133237
133313
  let liveWorkAllowed = () => false;
133238
133314
  const workAllowed = () => liveWorkAllowed();
133315
+ let liveGatesRemoved = () => false;
133316
+ const gatesRemoved = () => liveGatesRemoved();
133239
133317
  const listener = new PersistentClaudeSession({
133240
133318
  model: cfg.model,
133241
133319
  systemPrompt: agentSystemPrompt,
@@ -133259,6 +133337,11 @@ async function main() {
133259
133337
  // Task 4: the hard backstop — no tool runs on any worker turn (owner DM or
133260
133338
  // armed room) unless Work is on. Read live per tool decision.
133261
133339
  workAllowed,
133340
+ // #627: only the WORKER session gets this. The listener is the locked
133341
+ // tools:[] facet and must stay that way — a grant that widened the
133342
+ // always-on listener would put an unrestricted shell behind every
133343
+ // untrusted room message, not just tool turns.
133344
+ gatesRemoved,
133262
133345
  ephemeral: true,
133263
133346
  maxTurns: WORKER_MAX_TURNS,
133264
133347
  workspaceDir: cfg.workspaceDir,
@@ -133291,6 +133374,9 @@ async function main() {
133291
133374
  // during wiring) into the indirection the router + worker session read live.
133292
133375
  onWorkAllowed: (getter) => {
133293
133376
  liveWorkAllowed = getter;
133377
+ },
133378
+ onGatesRemoved: (getter) => {
133379
+ liveGatesRemoved = getter;
133294
133380
  }
133295
133381
  }
133296
133382
  );
package/dist/session.d.ts CHANGED
@@ -86,6 +86,18 @@ export interface SessionOpts {
86
86
  * false, even if the router mis-routed. Read LIVE at each tool decision (never
87
87
  * snapshotted). Absent getter ⇒ `?? false` ⇒ fail-closed (no tools). */
88
88
  workAllowed?: () => boolean;
89
+ /**
90
+ * #627: live, fail-closed device-level "Remove all gates" grant. When this
91
+ * returns true the gate permits EVERY tool with no restrictions, in rooms
92
+ * and 1:1 alike — the owner's explicit decision, recorded in #627.
93
+ *
94
+ * NOT a replacement for `isToolTurn`: this is read AFTER it, so an UNARMED
95
+ * room turn is still chat-only. That ordering is what separates "a room you
96
+ * enabled" from "any room somebody added your agent to", and it costs
97
+ * nothing to keep. Read LIVE at each tool decision (never snapshotted);
98
+ * absent getter ⇒ `?? false` ⇒ gates stay in place.
99
+ */
100
+ gatesRemoved?: () => boolean;
89
101
  }
90
102
  export declare class PersistentClaudeSession {
91
103
  private opts;
@@ -13,6 +13,9 @@ export declare function makeWorkerPreToolUseHook(opts: {
13
13
  /** True when the process is attested OS-isolated, gating whether Bash is allowed at all. */
14
14
  osIsolated: boolean;
15
15
  isToolTurn: () => boolean;
16
+ /** True when this turn is a 1:1 owner DM. That lane gets full access; rooms
17
+ * keep the allowlist. Read live per tool call, like isToolTurn. */
18
+ isOwnerDmTurn?: () => boolean;
16
19
  /** Slice 2 Plan B: true when this turn comes from an armed room. When set,
17
20
  * each tool decision (allow AND deny) is forwarded to onToolDecision for
18
21
  * audit self-reporting. room_say is always excluded (it is the reply
@@ -36,5 +39,7 @@ export declare function makeWorkerPermission(opts: {
36
39
  /** True when the process is attested OS-isolated, gating whether Bash is allowed at all. */
37
40
  osIsolated: boolean;
38
41
  isToolTurn: () => boolean;
42
+ /** True when this turn is a 1:1 owner DM (full access lane). */
43
+ isOwnerDmTurn?: () => boolean;
39
44
  }): CanUseTool;
40
45
  //# sourceMappingURL=worker-permission.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.5.10",
3
+ "version": "0.6.1",
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",
@@ -23,10 +23,10 @@
23
23
  }
24
24
  },
25
25
  "scripts": {
26
- "build": "tsc --emitDeclarationOnly && node build.mjs",
26
+ "build": "npm --prefix ../plugin run build && tsc --emitDeclarationOnly && node build.mjs",
27
27
  "start": "node dist/index.js",
28
28
  "test": "vitest run",
29
- "prepublishOnly": "node build.mjs"
29
+ "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
32
  "@anthropic-ai/claude-agent-sdk": "^0.2.114",