@agentvault/claude-bridge 0.6.5 → 0.7.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.
@@ -25,4 +25,13 @@ export declare function drainApprovals(dataDir: string): DrainedApproval[];
25
25
  * Usage: `av-bridge approve-arm <request-id> <room-id>`. Both are shown to the
26
26
  * operator in the bridge's arm-request log line and the web arm sheet. */
27
27
  export declare function maybeRunApproveArmSubcommand(argv: string[], dataDir: string): boolean;
28
+ /**
29
+ * #20 — `av-bridge trust-host [--yes]` / `av-bridge untrust-host [--all]`.
30
+ *
31
+ * Headless like `approve-arm` (no channel, no network) so it works over a bare
32
+ * SSH session with no daemon running. The device-id is ALWAYS read from the
33
+ * persisted identity, never taken from argv — an operator-typed id is exactly
34
+ * the confused-deputy input F1 closed for approve-arm.
35
+ */
36
+ export declare function maybeRunHostTrustSubcommand(argv: string[], dataDir: string, root?: string): boolean;
28
37
  //# sourceMappingURL=approve-cli.d.ts.map
package/dist/bridge.d.ts CHANGED
@@ -61,7 +61,17 @@ export interface RoomChannel {
61
61
  sendWorkerHeartbeat?(h: {
62
62
  workerCapable: boolean;
63
63
  armedRooms: string[];
64
+ hostTrusted: boolean;
65
+ gatesEffective: boolean;
64
66
  }): void;
67
+ /**
68
+ * #20: this worker's own device-id, checked against the host-trust directory
69
+ * to decide whether a web-granted "Remove all gates" switch actually takes
70
+ * effect (see bsafe-arming.ts). Optional so legacy/test fakes without it
71
+ * don't break wiring — `decideArming` treats a missing id as untrusted
72
+ * (fail-closed), matching `channel.deviceId ?? ""`.
73
+ */
74
+ deviceId?: string | null;
65
75
  }
66
76
  /**
67
77
  * #392 cooperative quiet: tracks per-room hush windows so the native agent holds
@@ -207,5 +217,14 @@ export declare function wireBridge(channel: RoomChannel, session: RoomSession, t
207
217
  * worker session's gate.
208
218
  */
209
219
  onGatesRemoved?: (getter: () => boolean) => void;
220
+ /**
221
+ * #20 test-only injection point: overrides where `isTrusted` (via
222
+ * `decideArming`) looks for host-trust markers, mirroring `host-trust.ts`'s
223
+ * own `root?` parameter. Production callers never pass this, so
224
+ * `decideArming` receives `undefined` and `isTrusted` falls back to its
225
+ * default `~/.agentvault` — unchanged from today. Tests pass a `mkdtemp`
226
+ * dir so they can `grant(...)` into it without touching the real host.
227
+ */
228
+ trustRoot?: string;
210
229
  }): ArmingState;
211
230
  //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1,30 @@
1
+ export type ArmingMode = "authoritative-under-grant" | "authoritative-bsafe" | "c2-disarm-only" | "authoritative-bpure";
2
+ export interface ArmingDecision {
3
+ /** The grant actually applied — web intent AND host trust. */
4
+ gatesRemoved: boolean;
5
+ mode: ArmingMode;
6
+ /**
7
+ * True when rooms armed from THIS snapshot must be remembered as revocable:
8
+ * the web armed them on an OS-isolated worker under an authority that can
9
+ * lapse. BOTH authorities qualify — the full-access grant AND host trust on
10
+ * its own — because both lapse into `c2-disarm-only`, which is the only place
11
+ * the record is ever consumed.
12
+ *
13
+ * This was `recordUnderGrant`, true for the grant alone, and that was the
14
+ * defect the 2026-07-27 whole-branch review found: rooms armed under
15
+ * `authoritative-bsafe` went unrecorded, so `av-bridge untrust-host` left them
16
+ * armed — and an armed room on an OS-isolated worker means Bash is allowed.
17
+ *
18
+ * `authoritative-bpure` stays false: a non-isolated worker never reaches
19
+ * `c2-disarm-only`, so the record would never be read.
20
+ */
21
+ recordWebArmed: boolean;
22
+ hostTrusted: boolean;
23
+ }
24
+ export declare function decideArming(opts: {
25
+ snapshotGatesRemoved: boolean;
26
+ osIsolated: boolean;
27
+ deviceId: string;
28
+ trustRoot?: string;
29
+ }): ArmingDecision;
30
+ //# sourceMappingURL=bsafe-arming.d.ts.map
@@ -0,0 +1,53 @@
1
+ export declare class HostTrustError extends Error {
2
+ }
3
+ export declare function trustDir(root?: string): string;
4
+ /**
5
+ * Fail-closed in every direction: any doubt at all means untrusted.
6
+ *
7
+ * `lstatSync`, NOT `statSync`: a symlink must never count as a marker. This
8
+ * used `statSync`, which FOLLOWS symlinks, so `ln -s <any regular file>
9
+ * ~/.agentvault/host-trust/<device-id>` read as a grant. That was not
10
+ * exploitable under today's threat model — the stated adversary is web-only and
11
+ * cannot create files on the host, and an agent with Bash could write the real
12
+ * marker anyway — but it is precisely the bypass that would defeat the deferred
13
+ * Task 10 root-owned marker, whose whole claim is that an agent running as the
14
+ * user cannot forge one. The trust DIRECTORY is deliberately user-owned so that
15
+ * revocation needs no privilege, so an agent with a shell can plant a symlink
16
+ * there; pointed at any root-owned, non-group/other-writable file (`/etc/passwd`
17
+ * is uid 0, mode 644 on both macOS and Linux) it would satisfy every clause of
18
+ * that check against the TARGET. Rejecting symlinks here closes it in advance.
19
+ */
20
+ export declare function isTrusted(deviceId: string, root?: string): boolean;
21
+ export declare function grant(deviceId: string, root?: string): void;
22
+ /**
23
+ * Returns true only if a real marker was actually removed.
24
+ *
25
+ * M1. This used `existsSync` + `rmSync(p, { force: true, recursive: true })`.
26
+ * `existsSync` is true for a DIRECTORY, so a directory squatting at a marker
27
+ * path was recursively deleted — contents and all — and reported as a
28
+ * successful revoke. Reached in practice through `untrust-host --all`
29
+ * (approve-cli.ts:131), which revokes everything `listTrusted()` returns.
30
+ *
31
+ * Only a regular file is a marker, which is already `isTrusted`'s rule; `lstat`
32
+ * so a symlink is never followed or counted. Anything else was never a grant,
33
+ * so removing it is not this function's job — and refusing keeps `recursive`
34
+ * out of this file entirely.
35
+ *
36
+ * The invariant callers actually depend on is preserved: after `revoke()`,
37
+ * `isTrusted()` is false, because it rejects non-files too.
38
+ */
39
+ export declare function revoke(deviceId: string, root?: string): boolean;
40
+ /**
41
+ * Filtered through `isTrusted` rather than the filename alone, so the listing
42
+ * cannot disagree with the decision. A name-only filter reported directories
43
+ * and symlinks as trusted devices, which both over-stated trust to an operator
44
+ * reading the list and fed non-markers to `revoke()` in the `--all` path.
45
+ */
46
+ export declare function listTrusted(root?: string): string[];
47
+ /**
48
+ * Resolve this agent's device-id from its persisted identity. `trust-host` runs
49
+ * headless with no channel and possibly no running bridge, so it cannot ask the
50
+ * backend who it is — and it must never accept an operator-typed id (F1).
51
+ */
52
+ export declare function readDeviceId(dataDir: string): string;
53
+ //# sourceMappingURL=host-trust.d.ts.map
package/dist/index.js CHANGED
@@ -155,7 +155,7 @@ var init_launcher = __esm({
155
155
  });
156
156
 
157
157
  // src/service/spec.ts
158
- import { join as join7 } from "node:path";
158
+ import { join as join8 } from "node:path";
159
159
  function buildServiceSpec(cfg, opts) {
160
160
  const env = {
161
161
  AV_AGENT_NAME: cfg.agentName,
@@ -168,7 +168,7 @@ function buildServiceSpec(cfg, opts) {
168
168
  if (cfg.armRoom) env.AV_ARM_ROOM = "1";
169
169
  if (cfg.model) env.AV_CLAUDE_MODEL = cfg.model;
170
170
  if (cfg.systemPrompt) env.AV_SYSTEM_PROMPT = cfg.systemPrompt;
171
- const launcherPath = join7(cfg.dataDir, "launch-bridge.sh");
171
+ const launcherPath = join8(cfg.dataDir, "launch-bridge.sh");
172
172
  return {
173
173
  label: `dev.agentvault.bridge-${slugify2(cfg.agentName)}`,
174
174
  programArgs: [launcherPath],
@@ -176,8 +176,8 @@ function buildServiceSpec(cfg, opts) {
176
176
  launcherScript: renderLauncher({ installPath: opts.path, entrypoint: opts.entrypoint }),
177
177
  env,
178
178
  workingDir: cfg.workspaceDir ? cfg.workspaceDir : opts.home,
179
- logOut: join7(cfg.dataDir, "logs", "bridge.log"),
180
- logErr: join7(cfg.dataDir, "logs", "bridge.error.log"),
179
+ logOut: join8(cfg.dataDir, "logs", "bridge.log"),
180
+ logErr: join8(cfg.dataDir, "logs", "bridge.error.log"),
181
181
  keepAliveUnlessCleanExit: true,
182
182
  runAtLoad: true
183
183
  };
@@ -191,7 +191,7 @@ var init_spec = __esm({
191
191
  });
192
192
 
193
193
  // src/service/launchd.ts
194
- import { dirname as dirname4, join as join8 } from "node:path";
194
+ import { dirname as dirname4, join as join9 } from "node:path";
195
195
  function esc3(s10) {
196
196
  return s10.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
197
197
  }
@@ -238,7 +238,7 @@ var init_launchd = __esm({
238
238
  }
239
239
  deps;
240
240
  plistPath(label) {
241
- return join8(this.deps.home, "Library", "LaunchAgents", `${label}.plist`);
241
+ return join9(this.deps.home, "Library", "LaunchAgents", `${label}.plist`);
242
242
  }
243
243
  domain() {
244
244
  return `gui/${this.deps.uid}`;
@@ -281,7 +281,7 @@ var init_launchd = __esm({
281
281
  });
282
282
 
283
283
  // src/service/systemd.ts
284
- import { dirname as dirname5, join as join9 } from "node:path";
284
+ import { dirname as dirname5, join as join10 } from "node:path";
285
285
  function q5(s10) {
286
286
  const esc4 = s10.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/%/g, "%%");
287
287
  return `"${esc4}"`;
@@ -318,7 +318,7 @@ var init_systemd = __esm({
318
318
  }
319
319
  deps;
320
320
  unitPath(label) {
321
- return join9(this.deps.home, ".config", "systemd", "user", `${label}.service`);
321
+ return join10(this.deps.home, ".config", "systemd", "user", `${label}.service`);
322
322
  }
323
323
  install(spec) {
324
324
  const path2 = this.unitPath(spec.label);
@@ -349,7 +349,7 @@ var init_systemd = __esm({
349
349
 
350
350
  // src/service/backend.ts
351
351
  import { spawnSync } from "node:child_process";
352
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, rmSync as rmSync3, existsSync as existsSync4, chmodSync as chmodSync2 } from "node:fs";
352
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3, rmSync as rmSync4, existsSync as existsSync4, chmodSync as chmodSync3 } from "node:fs";
353
353
  import { userInfo } from "node:os";
354
354
  function defaultDeps() {
355
355
  return {
@@ -360,10 +360,10 @@ function defaultDeps() {
360
360
  const r7 = spawnSync(cmd, args, { encoding: "utf8" });
361
361
  return { code: r7.status ?? 1, stdout: r7.stdout ?? "" };
362
362
  },
363
- writeFile: (p2, d10) => writeFileSync2(p2, d10),
364
- mkdir: (p2) => mkdirSync4(p2, { recursive: true }),
365
- chmod: (p2, m6) => chmodSync2(p2, m6),
366
- rm: (p2) => rmSync3(p2, { force: true }),
363
+ writeFile: (p2, d10) => writeFileSync3(p2, d10),
364
+ mkdir: (p2) => mkdirSync5(p2, { recursive: true }),
365
+ chmod: (p2, m6) => chmodSync3(p2, m6),
366
+ rm: (p2) => rmSync4(p2, { force: true }),
367
367
  exists: (p2) => existsSync4(p2)
368
368
  };
369
369
  }
@@ -447,8 +447,8 @@ var init_subcommand = __esm({
447
447
  });
448
448
 
449
449
  // src/index.ts
450
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "node:fs";
451
- import { join as join10 } from "node:path";
450
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync6 } from "node:fs";
451
+ import { join as join11 } from "node:path";
452
452
 
453
453
  // ../plugin/dist/index.js
454
454
  import * as nc from "node:crypto";
@@ -65559,7 +65559,7 @@ var init_channel = __esm2({
65559
65559
  */
65560
65560
  sendActivitySpan(spanData) {
65561
65561
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
65562
- const pluginVersion = true ? "0.23.10" : "0.0.0-dev";
65562
+ const pluginVersion = true ? "0.23.11" : "0.0.0-dev";
65563
65563
  const agentName = this.config.agentName ?? "Agent";
65564
65564
  const resource = {
65565
65565
  "service.name": "agentvault-agent",
@@ -65593,7 +65593,16 @@ var init_channel = __esm2({
65593
65593
  this._ws.send(
65594
65594
  JSON.stringify({
65595
65595
  event: "worker_heartbeat",
65596
- data: { worker_capable: h22.workerCapable, armed_rooms: h22.armedRooms }
65596
+ data: {
65597
+ worker_capable: h22.workerCapable,
65598
+ armed_rooms: h22.armedRooms,
65599
+ // #20: OPTIONAL on the way in so an older bridge against a newer
65600
+ // plugin still emits a shape the backend can read. `=== true` means
65601
+ // absent/malformed becomes false — the fail-closed direction, and it
65602
+ // never emits `undefined` (which would drop the key entirely).
65603
+ host_trusted: h22.hostTrusted === true,
65604
+ gates_effective: h22.gatesEffective === true
65605
+ }
65597
65606
  })
65598
65607
  );
65599
65608
  }
@@ -67289,7 +67298,7 @@ var init_channel = __esm2({
67289
67298
  agentVersion: this.config.agentVersion ?? "0.0.0",
67290
67299
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67291
67300
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67292
- pluginVersion: true ? "0.23.10" : "0.0.0-dev"
67301
+ pluginVersion: true ? "0.23.11" : "0.0.0-dev"
67293
67302
  });
67294
67303
  this._telemetryReporter.startAutoFlush(3e4);
67295
67304
  }
@@ -67607,7 +67616,7 @@ var init_channel = __esm2({
67607
67616
  agentVersion: this.config.agentVersion ?? "0.0.0",
67608
67617
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67609
67618
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67610
- pluginVersion: true ? "0.23.10" : "0.0.0-dev"
67619
+ pluginVersion: true ? "0.23.11" : "0.0.0-dev"
67611
67620
  });
67612
67621
  this._telemetryReporter.startAutoFlush(3e4);
67613
67622
  }
@@ -68481,13 +68490,13 @@ ${messageText}`;
68481
68490
  * Looks for OpenClaw workspace config, falls back to default path.
68482
68491
  */
68483
68492
  _resolveWorkspaceDir() {
68484
- const homedir = osHomedir();
68493
+ const homedir2 = osHomedir();
68485
68494
  const agentName = this.config.agentName;
68486
68495
  if (this._persisted?.agentRole === "lead") {
68487
- return join4(homedir, ".openclaw", "workspace");
68496
+ return join4(homedir2, ".openclaw", "workspace");
68488
68497
  }
68489
68498
  try {
68490
- const configPath = join4(homedir, ".openclaw", "openclaw.json");
68499
+ const configPath = join4(homedir2, ".openclaw", "openclaw.json");
68491
68500
  const raw = readFileSync(configPath, "utf-8");
68492
68501
  const config22 = JSON.parse(raw);
68493
68502
  const agents = config22?.agents?.list;
@@ -68501,9 +68510,9 @@ ${messageText}`;
68501
68510
  } catch {
68502
68511
  }
68503
68512
  if (agentName && agentName !== "CLI Agent" && agentName !== "OpenClaw Agent") {
68504
- return join4(homedir, ".openclaw", `workspace-${agentName}`);
68513
+ return join4(homedir2, ".openclaw", `workspace-${agentName}`);
68505
68514
  }
68506
- return join4(homedir, ".openclaw", "workspace");
68515
+ return join4(homedir2, ".openclaw", "workspace");
68507
68516
  }
68508
68517
  /**
68509
68518
  * Send a structured JSON reply to a specific conversation.
@@ -68702,20 +68711,20 @@ ${messageText}`;
68702
68711
  try {
68703
68712
  const payload = JSON.parse(plaintext);
68704
68713
  if (messageType === "credential_grant") {
68705
- const grant = payload;
68706
- if (grant.nonce && !this._credentialStore.checkNonce(roomId, grant.nonce)) {
68714
+ const grant2 = payload;
68715
+ if (grant2.nonce && !this._credentialStore.checkNonce(roomId, grant2.nonce)) {
68707
68716
  console.warn(`[SecureChannel] Credential grant replay detected for room ${roomId.slice(0, 8)}..., ignoring`);
68708
68717
  return;
68709
68718
  }
68710
68719
  const keys = [];
68711
- for (const cred of grant.credentials || []) {
68720
+ for (const cred of grant2.credentials || []) {
68712
68721
  this._credentialStore.grant(roomId, {
68713
68722
  key: cred.key,
68714
68723
  value: cred.value,
68715
68724
  type: cred.type,
68716
68725
  scope: cred.scope,
68717
- grantedAt: grant.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
68718
- agreementId: grant.agreement_id,
68726
+ grantedAt: grant2.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
68727
+ agreementId: grant2.agreement_id,
68719
68728
  roomId
68720
68729
  });
68721
68730
  keys.push(cred.key);
@@ -68723,21 +68732,21 @@ ${messageText}`;
68723
68732
  console.log(
68724
68733
  `[SecureChannel] Credentials granted for room ${roomId.slice(0, 8)}...: ${keys.join(", ")} (${keys.length} keys)`
68725
68734
  );
68726
- this._sendCredentialAck(roomId, grant.agreement_id, keys, "stored");
68727
- this.emit("credentials_granted", { roomId, keys, agreementId: grant.agreement_id });
68735
+ this._sendCredentialAck(roomId, grant2.agreement_id, keys, "stored");
68736
+ this.emit("credentials_granted", { roomId, keys, agreementId: grant2.agreement_id });
68728
68737
  } else if (messageType === "credential_revoke") {
68729
- const revoke = payload;
68738
+ const revoke2 = payload;
68730
68739
  const revoked = [];
68731
- for (const key of revoke.credential_keys || []) {
68740
+ for (const key of revoke2.credential_keys || []) {
68732
68741
  if (this._credentialStore.revoke(roomId, key)) {
68733
68742
  revoked.push(key);
68734
68743
  }
68735
68744
  }
68736
68745
  console.log(
68737
- `[SecureChannel] Credentials revoked for room ${roomId.slice(0, 8)}...: ${revoked.join(", ")} (reason: ${revoke.reason || "none"})`
68746
+ `[SecureChannel] Credentials revoked for room ${roomId.slice(0, 8)}...: ${revoked.join(", ")} (reason: ${revoke2.reason || "none"})`
68738
68747
  );
68739
- this._sendCredentialAck(roomId, revoke.agreement_id, revoked, "revoked");
68740
- this.emit("credentials_revoked", { roomId, keys: revoked, agreementId: revoke.agreement_id });
68748
+ this._sendCredentialAck(roomId, revoke2.agreement_id, revoked, "revoked");
68749
+ this.emit("credentials_revoked", { roomId, keys: revoked, agreementId: revoke2.agreement_id });
68741
68750
  }
68742
68751
  } catch (err) {
68743
68752
  console.error("[SecureChannel] Error handling credential message:", err);
@@ -97386,7 +97395,7 @@ var init_index = __esm2({
97386
97395
  init_skill_invoker();
97387
97396
  await init_skill_telemetry();
97388
97397
  await init_policy_enforcer();
97389
- VERSION = true ? "0.23.10" : "0.0.0-dev";
97398
+ VERSION = true ? "0.23.11" : "0.0.0-dev";
97390
97399
  }
97391
97400
  });
97392
97401
  await init_index();
@@ -118691,6 +118700,84 @@ function Z_($10, Q4) {
118691
118700
  // src/worker-permission.ts
118692
118701
  import { realpathSync as realpathSync2 } from "node:fs";
118693
118702
  import { resolve as resolve3, dirname as dirname2, basename, sep as sep2, isAbsolute } from "node:path";
118703
+
118704
+ // src/host-trust.ts
118705
+ import { mkdirSync as mkdirSync3, writeFileSync, rmSync as rmSync2, readdirSync as readdirSync2, lstatSync as lstatSync2, readFileSync as readFileSync4, chmodSync as chmodSync2 } from "node:fs";
118706
+ import { join as join6 } from "node:path";
118707
+ import { homedir, hostname as hostname3 } from "node:os";
118708
+ var TRUST_SUBDIR = "host-trust";
118709
+ var DIR_MODE3 = 448;
118710
+ var FILE_MODE3 = 384;
118711
+ var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
118712
+ var IDENTITY_FILES = ["agentvault.json", "secure-channel.json", "agentvault.json.bak"];
118713
+ var HostTrustError = class extends Error {
118714
+ };
118715
+ function trustDir(root3) {
118716
+ return join6(root3 ?? join6(homedir(), ".agentvault"), TRUST_SUBDIR);
118717
+ }
118718
+ function sanitize(deviceId) {
118719
+ if (typeof deviceId !== "string" || !ID_RE.test(deviceId)) {
118720
+ throw new HostTrustError("invalid device-id");
118721
+ }
118722
+ return deviceId;
118723
+ }
118724
+ function isTrusted(deviceId, root3) {
118725
+ try {
118726
+ if (typeof deviceId !== "string" || !ID_RE.test(deviceId)) return false;
118727
+ const p2 = join6(trustDir(root3), deviceId);
118728
+ return lstatSync2(p2).isFile();
118729
+ } catch {
118730
+ return false;
118731
+ }
118732
+ }
118733
+ function grant(deviceId, root3) {
118734
+ const id = sanitize(deviceId);
118735
+ const dir = trustDir(root3);
118736
+ const p2 = join6(dir, id);
118737
+ mkdirSync3(dir, { recursive: true, mode: DIR_MODE3 });
118738
+ writeFileSync(
118739
+ p2,
118740
+ `granted_at=${(/* @__PURE__ */ new Date()).toISOString()} host=${hostname3()}
118741
+ `,
118742
+ { mode: FILE_MODE3 }
118743
+ );
118744
+ chmodSync2(dir, DIR_MODE3);
118745
+ chmodSync2(p2, FILE_MODE3);
118746
+ }
118747
+ function revoke(deviceId, root3) {
118748
+ const id = sanitize(deviceId);
118749
+ const p2 = join6(trustDir(root3), id);
118750
+ try {
118751
+ if (!lstatSync2(p2).isFile()) return false;
118752
+ } catch {
118753
+ return false;
118754
+ }
118755
+ rmSync2(p2, { force: true });
118756
+ return true;
118757
+ }
118758
+ function listTrusted(root3) {
118759
+ try {
118760
+ return readdirSync2(trustDir(root3)).filter(
118761
+ (n10) => ID_RE.test(n10) && isTrusted(n10, root3)
118762
+ );
118763
+ } catch {
118764
+ return [];
118765
+ }
118766
+ }
118767
+ function readDeviceId(dataDir) {
118768
+ for (const f7 of IDENTITY_FILES) {
118769
+ try {
118770
+ const parsed = JSON.parse(readFileSync4(join6(dataDir, f7), "utf-8"));
118771
+ if (parsed?.deviceId && ID_RE.test(parsed.deviceId)) return parsed.deviceId;
118772
+ } catch {
118773
+ }
118774
+ }
118775
+ throw new HostTrustError(
118776
+ "no device identity found in the data dir \u2014 start the bridge once to enrol, then re-run"
118777
+ );
118778
+ }
118779
+
118780
+ // src/worker-permission.ts
118694
118781
  var PATH_FIELDS = ["file_path", "path", "notebook_path"];
118695
118782
  function canonical(p2) {
118696
118783
  const abs = resolve3(p2);
@@ -118734,6 +118821,12 @@ function gateDecision(toolName, input, opts) {
118734
118821
  if (!opts.isToolTurn()) {
118735
118822
  return { deny: true, reason: "tools are disabled on this turn; reply with the say tool only" };
118736
118823
  }
118824
+ if (FILE_TOOLS.has(toolName)) {
118825
+ const trustRoot = canonical(trustDir());
118826
+ if (pathsOf(input).some((p2) => within(p2, trustRoot))) {
118827
+ return { deny: true, reason: "the host-trust directory is not writable by the agent" };
118828
+ }
118829
+ }
118737
118830
  if (opts.gatesRemoved?.()) {
118738
118831
  return { deny: false };
118739
118832
  }
@@ -118973,7 +119066,7 @@ __export(external_exports, {
118973
119066
  guid: () => guid4,
118974
119067
  hash: () => hash2,
118975
119068
  hex: () => hex4,
118976
- hostname: () => hostname4,
119069
+ hostname: () => hostname5,
118977
119070
  httpUrl: () => httpUrl2,
118978
119071
  includes: () => _includes2,
118979
119072
  instanceof: () => _instanceof2,
@@ -120360,7 +120453,7 @@ __export(regexes_exports2, {
120360
120453
  extendedDuration: () => extendedDuration2,
120361
120454
  guid: () => guid3,
120362
120455
  hex: () => hex3,
120363
- hostname: () => hostname3,
120456
+ hostname: () => hostname4,
120364
120457
  html5Email: () => html5Email2,
120365
120458
  idnEmail: () => idnEmail2,
120366
120459
  integer: () => integer2,
@@ -120437,7 +120530,7 @@ var cidrv43 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5
120437
120530
  var cidrv63 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/;
120438
120531
  var base643 = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/;
120439
120532
  var base64url3 = /^[A-Za-z0-9_-]*$/;
120440
- var hostname3 = /^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/;
120533
+ var hostname4 = /^(?=.{1,253}\.?$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[-0-9a-zA-Z]{0,61}[0-9a-zA-Z])?)*\.?$/;
120441
120534
  var domain2 = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
120442
120535
  var e1643 = /^\+[1-9]\d{6,14}$/;
120443
120536
  var dateSource2 = `(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))`;
@@ -130807,7 +130900,7 @@ __export(schemas_exports2, {
130807
130900
  guid: () => guid4,
130808
130901
  hash: () => hash2,
130809
130902
  hex: () => hex4,
130810
- hostname: () => hostname4,
130903
+ hostname: () => hostname5,
130811
130904
  httpUrl: () => httpUrl2,
130812
130905
  instanceof: () => _instanceof2,
130813
130906
  int: () => int2,
@@ -131311,7 +131404,7 @@ var ZodCustomStringFormat2 = /* @__PURE__ */ $constructor2("ZodCustomStringForma
131311
131404
  function stringFormat2(format, fnOrRegex, _params = {}) {
131312
131405
  return _stringFormat2(ZodCustomStringFormat2, format, fnOrRegex, _params);
131313
131406
  }
131314
- function hostname4(_params) {
131407
+ function hostname5(_params) {
131315
131408
  return _stringFormat2(ZodCustomStringFormat2, "hostname", regexes_exports2.hostname, _params);
131316
131409
  }
131317
131410
  function hex4(_params) {
@@ -133146,20 +133239,20 @@ var ArmingState = class {
133146
133239
  };
133147
133240
 
133148
133241
  // src/approve-cli.ts
133149
- import { mkdirSync as mkdirSync3, writeFileSync, readFileSync as readFileSync4, readdirSync as readdirSync2, rmSync as rmSync2, existsSync as existsSync3 } from "node:fs";
133150
- import { join as join6 } from "node:path";
133242
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync as rmSync3, existsSync as existsSync3 } from "node:fs";
133243
+ import { join as join7 } from "node:path";
133151
133244
  var APPROVALS_SUBDIR = "arm-approvals";
133152
- var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
133245
+ var ID_RE2 = /^[A-Za-z0-9_-]{1,128}$/;
133153
133246
  var ApproveArmError = class extends Error {
133154
133247
  };
133155
133248
  function sanitizeRequestId(requestId) {
133156
- if (!ID_RE.test(requestId)) {
133249
+ if (!ID_RE2.test(requestId)) {
133157
133250
  throw new ApproveArmError("invalid request-id");
133158
133251
  }
133159
133252
  return requestId;
133160
133253
  }
133161
133254
  function sanitizeRoomId(roomId) {
133162
- if (!ID_RE.test(roomId)) {
133255
+ if (!ID_RE2.test(roomId)) {
133163
133256
  throw new ApproveArmError("invalid room-id");
133164
133257
  }
133165
133258
  return roomId;
@@ -133167,24 +133260,24 @@ function sanitizeRoomId(roomId) {
133167
133260
  function writeApproval(dataDir, requestId, roomId) {
133168
133261
  const id = sanitizeRequestId(requestId);
133169
133262
  const room = sanitizeRoomId(roomId);
133170
- const dir = join6(dataDir, APPROVALS_SUBDIR);
133171
- mkdirSync3(dir, { recursive: true });
133172
- writeFileSync(join6(dir, id), room);
133263
+ const dir = join7(dataDir, APPROVALS_SUBDIR);
133264
+ mkdirSync4(dir, { recursive: true });
133265
+ writeFileSync2(join7(dir, id), room);
133173
133266
  }
133174
133267
  function drainApprovals(dataDir) {
133175
- const dir = join6(dataDir, APPROVALS_SUBDIR);
133268
+ const dir = join7(dataDir, APPROVALS_SUBDIR);
133176
133269
  if (!existsSync3(dir)) return [];
133177
133270
  const out = [];
133178
- for (const name of readdirSync2(dir)) {
133179
- if (!ID_RE.test(name)) continue;
133271
+ for (const name of readdirSync3(dir)) {
133272
+ if (!ID_RE2.test(name)) continue;
133180
133273
  let roomId = "";
133181
133274
  try {
133182
- roomId = readFileSync4(join6(dir, name), "utf8").trim();
133275
+ roomId = readFileSync5(join7(dir, name), "utf8").trim();
133183
133276
  } catch {
133184
133277
  }
133185
- out.push({ requestId: name, roomId: ID_RE.test(roomId) ? roomId : "" });
133278
+ out.push({ requestId: name, roomId: ID_RE2.test(roomId) ? roomId : "" });
133186
133279
  try {
133187
- rmSync2(join6(dir, name), { force: true, recursive: true });
133280
+ rmSync3(join7(dir, name), { force: true, recursive: true });
133188
133281
  } catch {
133189
133282
  }
133190
133283
  }
@@ -133208,6 +133301,60 @@ function maybeRunApproveArmSubcommand(argv, dataDir) {
133208
133301
  }
133209
133302
  return true;
133210
133303
  }
133304
+ function maybeRunHostTrustSubcommand(argv, dataDir, root3) {
133305
+ const cmd = argv[2];
133306
+ if (cmd !== "trust-host" && cmd !== "untrust-host") return false;
133307
+ const flags = argv.slice(3);
133308
+ if (cmd === "untrust-host" && flags.includes("--all")) {
133309
+ const ids = listTrusted(root3);
133310
+ for (const id of ids) revoke(id, root3);
133311
+ console.log(`[untrust-host] revoked ${ids.length} host-trust grant(s)`);
133312
+ return true;
133313
+ }
133314
+ let deviceId;
133315
+ try {
133316
+ deviceId = readDeviceId(dataDir);
133317
+ } catch (e7) {
133318
+ console.error(`[${cmd}] ${e7.message}`);
133319
+ process.exitCode = 1;
133320
+ return true;
133321
+ }
133322
+ if (cmd === "untrust-host") {
133323
+ const removed = revoke(deviceId, root3);
133324
+ console.log(
133325
+ removed ? `[untrust-host] revoked host trust for ${deviceId}` : `[untrust-host] no host-trust grant existed for ${deviceId}`
133326
+ );
133327
+ return true;
133328
+ }
133329
+ if (!flags.includes("--yes")) {
133330
+ console.log(
133331
+ `This machine will trust web-granted full access for device ${deviceId}.
133332
+ Writes: ${trustDir(root3)}/${deviceId}
133333
+ Revoke: av-bridge untrust-host
133334
+ Re-run with --yes to confirm.`
133335
+ );
133336
+ return true;
133337
+ }
133338
+ grant(deviceId, root3);
133339
+ console.log(`[trust-host] host trust granted for ${deviceId}`);
133340
+ return true;
133341
+ }
133342
+
133343
+ // src/bsafe-arming.ts
133344
+ function decideArming(opts) {
133345
+ const hostTrusted = isTrusted(opts.deviceId, opts.trustRoot);
133346
+ const gatesRemoved = opts.snapshotGatesRemoved && hostTrusted;
133347
+ if (!opts.osIsolated) {
133348
+ return { gatesRemoved, mode: "authoritative-bpure", recordWebArmed: false, hostTrusted };
133349
+ }
133350
+ if (gatesRemoved) {
133351
+ return { gatesRemoved, mode: "authoritative-under-grant", recordWebArmed: true, hostTrusted };
133352
+ }
133353
+ if (hostTrusted) {
133354
+ return { gatesRemoved: false, mode: "authoritative-bsafe", recordWebArmed: true, hostTrusted };
133355
+ }
133356
+ return { gatesRemoved: false, mode: "c2-disarm-only", recordWebArmed: false, hostTrusted };
133357
+ }
133211
133358
 
133212
133359
  // src/bridge.ts
133213
133360
  var APPROVAL_POLL_MS = 1500;
@@ -133314,11 +133461,13 @@ function wireBridge(channel, session, target, opts = {}) {
133314
133461
  let gatesRemoved = false;
133315
133462
  const gatesRemovedGetter = () => gatesRemoved;
133316
133463
  opts.onGatesRemoved?.(gatesRemovedGetter);
133317
- const webArmedUnderGrant = /* @__PURE__ */ new Set();
133464
+ let hostTrusted = false;
133465
+ const webArmedRevocable = /* @__PURE__ */ new Set();
133318
133466
  channel.on("state", (s10) => {
133319
133467
  if (s10 !== "ready") {
133320
133468
  workAllowed = false;
133321
133469
  gatesRemoved = false;
133470
+ hostTrusted = false;
133322
133471
  }
133323
133472
  });
133324
133473
  const arming = new ArmingState();
@@ -133336,7 +133485,14 @@ function wireBridge(channel, session, target, opts = {}) {
133336
133485
  e7.hushedUntil ? `room ${e7.roomId.slice(0, 8)} hushed until ${e7.hushedUntil} \u2014 holding` : `room ${e7.roomId.slice(0, 8)} hush cleared`
133337
133486
  );
133338
133487
  });
133488
+ let warnedMissingSenderIsAgent = false;
133339
133489
  channel.on("room_message", (e7) => {
133490
+ if (typeof e7.senderIsAgent !== "boolean" && !warnedMissingSenderIsAgent) {
133491
+ warnedMissingSenderIsAgent = true;
133492
+ log(
133493
+ "WARNING: inbound room_message has no `senderIsAgent` \u2014 replies to humans in rooms will be DROPPED whenever the model answers in plain text instead of calling say (#630). Most likely cause: a stale bundled plugin dist. Rebuild the bridge (npm run build in packages/claude-room-bridge) and reinstall."
133494
+ );
133495
+ }
133340
133496
  if (opts.roomFilter && e7.roomId !== opts.roomFilter) return;
133341
133497
  if (hush.isHushed(e7.roomId)) {
133342
133498
  log(`inbound from ${e7.senderName} in ${e7.roomId.slice(0, 8)} \u2014 room hushed, holding (not replying)`);
@@ -133360,7 +133516,14 @@ function wireBridge(channel, session, target, opts = {}) {
133360
133516
  const osIsolated = opts.osIsolated !== void 0 ? opts.osIsolated : process.env.AV_WORKER_OS_ISOLATED === "1" || process.env.AV_WORKER_OS_ISOLATED === "true";
133361
133517
  const heartbeat = () => {
133362
133518
  try {
133363
- channel.sendWorkerHeartbeat?.({ workerCapable, armedRooms: arming.armedRooms() });
133519
+ channel.sendWorkerHeartbeat?.({
133520
+ workerCapable,
133521
+ armedRooms: arming.armedRooms(),
133522
+ hostTrusted,
133523
+ // Already the conjunction: `gatesRemoved` is assigned from
133524
+ // decideArming, which ANDs the web grant with host trust.
133525
+ gatesEffective: gatesRemoved
133526
+ });
133364
133527
  } catch (err) {
133365
133528
  log(`worker heartbeat send failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
133366
133529
  }
@@ -133369,40 +133532,56 @@ function wireBridge(channel, session, target, opts = {}) {
133369
133532
  channel.on("ready", () => heartbeat());
133370
133533
  const applyArmingSnapshot = (s10) => {
133371
133534
  workAllowed = s10?.workAllowed === true;
133372
- gatesRemoved = s10?.gatesRemoved === true;
133535
+ const prevHostTrusted = hostTrusted;
133536
+ const prevGatesEffective = gatesRemoved;
133537
+ const decision = decideArming({
133538
+ snapshotGatesRemoved: s10?.gatesRemoved === true,
133539
+ osIsolated,
133540
+ deviceId: channel.deviceId ?? "",
133541
+ trustRoot: opts.trustRoot
133542
+ });
133543
+ gatesRemoved = decision.gatesRemoved;
133544
+ hostTrusted = decision.hostTrusted;
133545
+ const trustChanged = hostTrusted !== prevHostTrusted || gatesRemoved !== prevGatesEffective;
133546
+ if (s10?.gatesRemoved === true && !decision.hostTrusted) {
133547
+ log(
133548
+ `"Remove all gates" is ON for this device but this HOST has not approved it. Full access is NOT in effect. Grant on this machine: av-bridge trust-host`
133549
+ );
133550
+ }
133373
133551
  if (!workerCapable) {
133374
133552
  log("arming_snapshot ignored \u2014 not worker-capable");
133553
+ if (trustChanged) heartbeat();
133375
133554
  return;
133376
133555
  }
133377
133556
  const roomIds = s10?.roomIds ?? [];
133378
133557
  try {
133379
133558
  const before = new Set(arming.armedRooms());
133380
- if (osIsolated && gatesRemoved) {
133381
- arming.applyAuthoritative(roomIds);
133382
- for (const r7 of roomIds) if (!before.has(r7)) webArmedUnderGrant.add(r7);
133383
- } else if (osIsolated) {
133384
- if (webArmedUnderGrant.size > 0) {
133385
- const revoked = [...webArmedUnderGrant].filter((r7) => arming.isArmed(r7));
133559
+ if (decision.mode === "c2-disarm-only") {
133560
+ if (webArmedRevocable.size > 0) {
133561
+ const revoked = [...webArmedRevocable].filter((r7) => arming.isArmed(r7));
133386
133562
  for (const r7 of revoked) arming.disarm(r7);
133387
- webArmedUnderGrant.clear();
133563
+ webArmedRevocable.clear();
133388
133564
  if (revoked.length > 0) {
133389
133565
  log(
133390
- `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(", ")}]`
133566
+ `arming_snapshot: full access is not in effect \u2014 disarming the rooms it had armed on this OS-isolated worker: [${revoked.map((r7) => r7.slice(0, 8)).join(", ")}]`
133391
133567
  );
133392
133568
  }
133393
133569
  }
133394
133570
  const dropped = arming.reconcileDisarm(roomIds);
133395
133571
  if (dropped.length > 0) {
133396
133572
  log(
133397
- `arming_snapshot: OS-isolated worker \u2014 disarm-only until #20 (web cannot arm shell workers); disarmed: [${dropped.map((r7) => r7.slice(0, 8)).join(", ")}]`
133573
+ `arming_snapshot: OS-isolated worker \u2014 disarm-only without host trust (av-bridge trust-host); disarmed: [${dropped.map((r7) => r7.slice(0, 8)).join(", ")}]`
133398
133574
  );
133399
133575
  }
133400
133576
  } else {
133401
133577
  arming.applyAuthoritative(roomIds);
133578
+ if (decision.recordWebArmed) {
133579
+ for (const r7 of roomIds) if (!before.has(r7)) webArmedRevocable.add(r7);
133580
+ }
133402
133581
  }
133403
133582
  const after = arming.armedRooms();
133404
133583
  const changed = after.length !== before.size || after.some((r7) => !before.has(r7));
133405
- if (changed) heartbeat();
133584
+ if (changed || trustChanged) heartbeat();
133406
133585
  } catch (err) {
133407
133586
  log(`arming_snapshot handling failed (ignored): ${err instanceof Error ? err.message : String(err)}`);
133408
133587
  }
@@ -133458,6 +133637,9 @@ async function main() {
133458
133637
  if (maybeRunApproveArmSubcommand(process.argv, dataDirForSubcommand)) {
133459
133638
  process.exit(process.exitCode ?? 0);
133460
133639
  }
133640
+ if (maybeRunHostTrustSubcommand(process.argv, dataDirForSubcommand)) {
133641
+ process.exit(process.exitCode ?? 0);
133642
+ }
133461
133643
  const { maybeRunServiceSubcommand: maybeRunServiceSubcommand2 } = await Promise.resolve().then(() => (init_subcommand(), subcommand_exports));
133462
133644
  if (maybeRunServiceSubcommand2(process.argv, process.env)) {
133463
133645
  process.exit(process.exitCode ?? 0);
@@ -133469,7 +133651,7 @@ async function main() {
133469
133651
  "[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"
133470
133652
  );
133471
133653
  }
133472
- console.error(`[bridge] version: ${true ? "0.6.5" : "dev"}`);
133654
+ console.error(`[bridge] version: ${true ? "0.7.1" : "dev"}`);
133473
133655
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
133474
133656
  console.error(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
133475
133657
  if (cfg.armRoom) {
@@ -133555,9 +133737,9 @@ async function main() {
133555
133737
  `[worker-trap] ${rec.at} ${rec.outcome} after ${rec.ranMs}ms (waited ${rec.waitedMs}ms behind ${rec.queueDepthAtEnqueue}, ${rec.queueDepthAtStart} still queued)` + (rec.session ? ` composed=${rec.session.composedChars} result=${rec.session.sawResult} said=${rec.session.said}` : "")
133556
133738
  );
133557
133739
  try {
133558
- const dir = join10(cfg.dataDir, "logs");
133559
- mkdirSync5(dir, { recursive: true });
133560
- appendFileSync2(join10(dir, "worker-incidents.jsonl"), JSON.stringify(rec) + "\n");
133740
+ const dir = join11(cfg.dataDir, "logs");
133741
+ mkdirSync6(dir, { recursive: true });
133742
+ appendFileSync2(join11(dir, "worker-incidents.jsonl"), JSON.stringify(rec) + "\n");
133561
133743
  } catch (err) {
133562
133744
  console.error(`[worker-trap] could not persist incident: ${err.message}`);
133563
133745
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.6.5",
3
+ "version": "0.7.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",