@agentvault/claude-bridge 0.3.3 → 0.3.4

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/config.d.ts CHANGED
@@ -8,6 +8,9 @@ export interface BridgeConfig {
8
8
  roomFilter?: string;
9
9
  model?: string;
10
10
  systemPrompt?: string;
11
+ worker: boolean;
12
+ workspaceDir?: string;
13
+ permissionMode: "auto" | "acceptEdits" | "bypassPermissions";
11
14
  }
12
15
  export declare function loadConfig(env: NodeJS.ProcessEnv, argv?: string[]): BridgeConfig;
13
16
  //# sourceMappingURL=config.d.ts.map
package/dist/index.js CHANGED
@@ -45248,20 +45248,20 @@ function multibaseToPublicKey(multibase) {
45248
45248
  }
45249
45249
  async function signDocument(document, privateKey) {
45250
45250
  await libsodium_wrappers_default.ready;
45251
- const canonical = canonicalize(document);
45251
+ const canonical2 = canonicalize(document);
45252
45252
  const domainPrefix = libsodium_wrappers_default.from_string(DOMAIN_DID_DOCUMENT);
45253
- const message = new Uint8Array(domainPrefix.length + canonical.length);
45253
+ const message = new Uint8Array(domainPrefix.length + canonical2.length);
45254
45254
  message.set(domainPrefix);
45255
- message.set(canonical, domainPrefix.length);
45255
+ message.set(canonical2, domainPrefix.length);
45256
45256
  return libsodium_wrappers_default.crypto_sign_detached(message, privateKey);
45257
45257
  }
45258
45258
  async function verifyDocumentSignature(document, signature, publicKey) {
45259
45259
  await libsodium_wrappers_default.ready;
45260
- const canonical = canonicalize(document);
45260
+ const canonical2 = canonicalize(document);
45261
45261
  const domainPrefix = libsodium_wrappers_default.from_string(DOMAIN_DID_DOCUMENT);
45262
- const message = new Uint8Array(domainPrefix.length + canonical.length);
45262
+ const message = new Uint8Array(domainPrefix.length + canonical2.length);
45263
45263
  message.set(domainPrefix);
45264
- message.set(canonical, domainPrefix.length);
45264
+ message.set(canonical2, domainPrefix.length);
45265
45265
  try {
45266
45266
  return libsodium_wrappers_default.crypto_sign_verify_detached(signature, message, publicKey);
45267
45267
  } catch {
@@ -96449,6 +96449,20 @@ function resolveDataDir(env) {
96449
96449
  function loadConfig(env, argv = []) {
96450
96450
  const { dataDir, source: dataDirSource } = resolveDataDir(env);
96451
96451
  const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || "";
96452
+ const worker = env.AV_WORKER === "1" || env.AV_WORKER === "true";
96453
+ const workspaceDir = env.AV_WORKSPACE_DIR || void 0;
96454
+ const PERMISSION_MODES = ["auto", "acceptEdits", "bypassPermissions"];
96455
+ const permissionMode = env.AV_PERMISSION_MODE ?? "auto";
96456
+ if (!PERMISSION_MODES.includes(permissionMode)) {
96457
+ throw new Error(
96458
+ `invalid AV_PERMISSION_MODE: ${env.AV_PERMISSION_MODE} (expected one of ${PERMISSION_MODES.join(", ")})`
96459
+ );
96460
+ }
96461
+ if (worker && !workspaceDir) {
96462
+ throw new Error(
96463
+ "worker mode requires AV_WORKSPACE_DIR (the project directory the agent works in)"
96464
+ );
96465
+ }
96452
96466
  if (!inviteToken && !hasPersistedCreds(dataDir)) {
96453
96467
  throw new Error(
96454
96468
  "invite token required: pass it as the first argument or set AV_INVITE_TOKEN (or start from a data dir with existing credentials to reconnect)"
@@ -96465,7 +96479,10 @@ function loadConfig(env, argv = []) {
96465
96479
  agentName: env.AV_AGENT_NAME ?? "Claude",
96466
96480
  roomFilter: env.AV_ROOM_ID || void 0,
96467
96481
  model: env.AV_CLAUDE_MODEL || void 0,
96468
- systemPrompt: env.AV_SYSTEM_PROMPT || void 0
96482
+ systemPrompt: env.AV_SYSTEM_PROMPT || void 0,
96483
+ worker,
96484
+ workspaceDir,
96485
+ permissionMode
96469
96486
  };
96470
96487
  }
96471
96488
 
@@ -117763,6 +117780,81 @@ function Z_($10, Q4) {
117763
117780
  return null;
117764
117781
  }
117765
117782
 
117783
+ // src/worker-permission.ts
117784
+ import { realpathSync as realpathSync2 } from "node:fs";
117785
+ import { resolve as resolve2, dirname, basename, sep } from "node:path";
117786
+ var PATH_FIELDS = ["file_path", "path", "notebook_path"];
117787
+ function canonical(p2) {
117788
+ const abs = resolve2(p2);
117789
+ try {
117790
+ return realpathSync2(abs);
117791
+ } catch {
117792
+ try {
117793
+ return resolve2(realpathSync2(dirname(abs)), basename(abs));
117794
+ } catch {
117795
+ return abs;
117796
+ }
117797
+ }
117798
+ }
117799
+ function targetsDataDir(_toolName, input, dataDir) {
117800
+ const canonDir = canonical(dataDir);
117801
+ for (const f7 of PATH_FIELDS) {
117802
+ const v5 = input[f7];
117803
+ if (typeof v5 === "string") {
117804
+ const canon = canonical(v5);
117805
+ if (canon === canonDir || canon.startsWith(canonDir + sep)) return true;
117806
+ }
117807
+ }
117808
+ const cmd = input.command;
117809
+ if (typeof cmd === "string" && cmd.includes(canonDir)) return true;
117810
+ return false;
117811
+ }
117812
+ function gateDecision(toolName, input, opts) {
117813
+ if (toolName === ROOM_SAY_TOOL_NAME) return { deny: false };
117814
+ if (!opts.isOwnerTurn()) {
117815
+ return { deny: true, reason: "tools are disabled in rooms; reply with the say tool only" };
117816
+ }
117817
+ if (targetsDataDir(toolName, input, opts.dataDir)) {
117818
+ return { deny: true, reason: "access to the AgentVault key directory is not permitted" };
117819
+ }
117820
+ return { deny: false };
117821
+ }
117822
+ function makeWorkerPreToolUseHook(opts) {
117823
+ return {
117824
+ hooks: [
117825
+ async (input) => {
117826
+ const i2 = input;
117827
+ const verdict = gateDecision(
117828
+ i2.tool_name ?? "",
117829
+ i2.tool_input ?? {},
117830
+ opts
117831
+ );
117832
+ if (verdict.deny) {
117833
+ console.error(`[worker-gate] DENY ${i2.tool_name} \u2014 ${verdict.reason}`);
117834
+ return {
117835
+ hookSpecificOutput: {
117836
+ hookEventName: "PreToolUse",
117837
+ permissionDecision: "deny",
117838
+ permissionDecisionReason: verdict.reason
117839
+ }
117840
+ };
117841
+ }
117842
+ return {};
117843
+ }
117844
+ ]
117845
+ };
117846
+ }
117847
+ function makeWorkerPermission(opts) {
117848
+ return async (toolName, input) => {
117849
+ const verdict = gateDecision(toolName, input, opts);
117850
+ if (verdict.deny) {
117851
+ console.error(`[worker-gate] DENY ${toolName} \u2014 ${verdict.reason}`);
117852
+ return { behavior: "deny", message: verdict.reason };
117853
+ }
117854
+ return { behavior: "allow", updatedInput: input };
117855
+ };
117856
+ }
117857
+
117766
117858
  // ../../node_modules/zod/v4/classic/external.js
117767
117859
  var external_exports = {};
117768
117860
  __export(external_exports, {
@@ -131554,10 +131646,21 @@ var PersistentClaudeSession = class {
131554
131646
  /** Reply sink bound to the message currently being processed. Set as each
131555
131647
  * message is handed to the model so the say tool routes to the right place. */
131556
131648
  activeReply;
131557
- /** Per-turn state for the #416 DM auto-reply fallback. */
131649
+ /**
131650
+ * Per-turn state for the #416 DM auto-reply fallback.
131651
+ *
131652
+ * LOAD-BEARING for the worker tool gate: `currentAutoReply` is true ONLY on
131653
+ * owner DM turns (`autoReplyOnText:true` from wireBridge) and false on room
131654
+ * turns (`autoReplyOnText:false`). `makeWorkerPermission` calls
131655
+ * `isOwnerTurn: () => this.currentAutoReply` to decide whether tools are
131656
+ * allowed. A room-origin turn MUST keep this false or untrusted input gains
131657
+ * full tool access. See worker-permission.test.ts for the covering assertion.
131658
+ */
131558
131659
  currentAutoReply = false;
131559
131660
  saidThisTurn = false;
131560
131661
  turnText = "";
131662
+ /** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
131663
+ roomServer;
131561
131664
  /**
131562
131665
  * Queue an inbound message for the model.
131563
131666
  * @param opts.autoReplyOnText — for 1:1 DMs (where the owner always expects a
@@ -131591,8 +131694,8 @@ var PersistentClaudeSession = class {
131591
131694
  }
131592
131695
  async *input() {
131593
131696
  while (true) {
131594
- const item = this.pending.length > 0 ? this.pending.shift() : await new Promise((resolve2) => {
131595
- this.waiting = resolve2;
131697
+ const item = this.pending.length > 0 ? this.pending.shift() : await new Promise((resolve3) => {
131698
+ this.waiting = resolve3;
131596
131699
  });
131597
131700
  this.activeReply = item.reply;
131598
131701
  this.currentAutoReply = item.autoReplyOnText ?? false;
@@ -131601,38 +131704,54 @@ var PersistentClaudeSession = class {
131601
131704
  yield item.msg;
131602
131705
  }
131603
131706
  }
131707
+ /**
131708
+ * Build the SDK options for this session. Locked mode (default) is safe for
131709
+ * untrusted inbound room messages. Worker mode (opts.worker=true) enables full
131710
+ * toolset on owner DMs, project context, workspace cwd, and an AV data-dir
131711
+ * fence via canUseTool.
131712
+ */
131713
+ buildSdkOptions() {
131714
+ const base = {
131715
+ model: this.opts.model,
131716
+ systemPrompt: this.opts.systemPrompt,
131717
+ mcpServers: { room: this.roomServer },
131718
+ allowedTools: [ROOM_SAY_TOOL_NAME]
131719
+ };
131720
+ if (!this.opts.worker) {
131721
+ return {
131722
+ ...base,
131723
+ permissionMode: "dontAsk",
131724
+ tools: [],
131725
+ settingSources: []
131726
+ };
131727
+ }
131728
+ const gateOpts = {
131729
+ dataDir: this.opts.dataDir ?? "",
131730
+ // LOAD-BEARING: this.currentAutoReply is true only on owner DM turns.
131731
+ // A room turn must keep it false or untrusted input gains full tool access.
131732
+ // See worker-permission.test.ts for the covering assertion.
131733
+ isOwnerTurn: () => this.currentAutoReply
131734
+ };
131735
+ return {
131736
+ ...base,
131737
+ permissionMode: this.opts.permissionMode ?? "auto",
131738
+ settingSources: ["user", "project"],
131739
+ cwd: this.opts.workspaceDir,
131740
+ // PRIMARY gate: a PreToolUse hook fires on EVERY tool call regardless of
131741
+ // permissionMode. canUseTool alone is bypassed in "auto" mode (the SDK's
131742
+ // classifier auto-approves without hitting the "ask" path) — verified live.
131743
+ hooks: { PreToolUse: [makeWorkerPreToolUseHook(gateOpts)] },
131744
+ // Secondary (defense-in-depth): covers the "ask" path in default/dontAsk modes.
131745
+ canUseTool: makeWorkerPermission(gateOpts)
131746
+ };
131747
+ }
131604
131748
  async start() {
131605
- const roomServer = _s({
131749
+ this.roomServer = _s({
131606
131750
  name: "room",
131607
131751
  version: "0.2.0",
131608
131752
  tools: [makeRoomSayTool((text) => this.deliver(text))]
131609
131753
  });
131610
- const sdkOptions = {
131611
- model: this.opts.model,
131612
- systemPrompt: this.opts.systemPrompt,
131613
- // SECURITY: this session is driven by UNTRUSTED inbound room messages from
131614
- // other agents. It must NOT be able to run general tools (shell/file/network)
131615
- // — that would be a prompt-injection → RCE / key-exfil path on the host. The
131616
- // ONLY tool it gets is room_say (post text to the active room), which has no
131617
- // such reach. Defense is capability REMOVAL first, permission mode second:
131618
- // - tools [] : remove ALL built-in tools (Bash/Read/Write/...) at the
131619
- // source so an injection can't even reach the permission layer. The
131620
- // room_say MCP tool comes via mcpServers and is unaffected by this.
131621
- // - permissionMode "dontAsk" (NEVER "bypassPermissions"): deny anything not
131622
- // pre-approved — no reliance on a no-TTY-implies-denial assumption.
131623
- // - allowedTools [ROOM_SAY_TOOL_NAME] : pre-approve ONLY room_say.
131624
- // - settingSources [] : do not load CLAUDE.md / project agents / skills
131625
- // from disk into an untrusted-input session.
131626
- // When CClaude later becomes a tool-using coder, additional tools must only
131627
- // be enabled INSIDE an isolation boundary (sandboxed dev droplet, no prod/
131628
- // network access, no access to the device-key dir) AND gated by canUseTool.
131629
- permissionMode: "dontAsk",
131630
- tools: [],
131631
- allowedTools: [ROOM_SAY_TOOL_NAME],
131632
- mcpServers: { room: roomServer },
131633
- settingSources: []
131634
- // maxTurns left unset → persistent multi-turn (NOT maxTurns:1)
131635
- };
131754
+ const sdkOptions = this.buildSdkOptions();
131636
131755
  const q5 = this.opts.queryImpl ? this.opts.queryImpl({
131637
131756
  prompt: this.input(),
131638
131757
  options: sdkOptions
@@ -131779,7 +131898,14 @@ async function main() {
131779
131898
  "[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"
131780
131899
  );
131781
131900
  }
131901
+ console.error(`[bridge] version: ${true ? "0.3.4" : "dev"}`);
131782
131902
  console.error(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
131903
+ if (cfg.worker) {
131904
+ console.error(`[bridge] WORKER MODE \u2014 workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
131905
+ console.error(
131906
+ "[bridge] WORKER MODE WARNING: worker mode mixes untrusted room context with trusted owner DM turns in one persistent session. The turn-scoped gate (isOwnerTurn) prevents tool use ON a room turn, but does NOT prevent a room message from planting an instruction that fires on a later owner DM turn when tools are enabled (cross-turn injection). Until Slice 2: run a worker bridge DM-only (no room traffic) or under OS-user isolation."
131907
+ );
131908
+ }
131783
131909
  const target = new ActiveTarget();
131784
131910
  if (cfg.roomFilter) target.setRoom(cfg.roomFilter);
131785
131911
  const channel = new SecureChannel({
@@ -131798,7 +131924,11 @@ async function main() {
131798
131924
  // per-message binding is what prevents a private 1:1 reply from leaking into a
131799
131925
  // room when room traffic arrives mid-compose.
131800
131926
  // Assistant reasoning that wasn't sent — log a short trace only.
131801
- onObserve: (text) => console.error(`[bridge] observed (${text.length} chars, not sent)`)
131927
+ onObserve: (text) => console.error(`[bridge] observed (${text.length} chars, not sent)`),
131928
+ worker: cfg.worker,
131929
+ workspaceDir: cfg.workspaceDir,
131930
+ permissionMode: cfg.permissionMode,
131931
+ dataDir: cfg.dataDir
131802
131932
  });
131803
131933
  wireBridge(
131804
131934
  channel,
package/dist/session.d.ts CHANGED
@@ -55,6 +55,15 @@ export interface SessionOpts {
55
55
  model?: string;
56
56
  systemPrompt?: string;
57
57
  queryImpl?: QueryFn;
58
+ /** When true, session runs as a worker agent: full toolset on owner DMs,
59
+ * project context loaded, workspace cwd set, AV data dir fenced off. */
60
+ worker?: boolean;
61
+ /** Workspace directory to set as cwd in worker mode. */
62
+ workspaceDir?: string;
63
+ /** Claude permission mode for worker turns (default: "auto"). */
64
+ permissionMode?: "auto" | "acceptEdits" | "bypassPermissions";
65
+ /** AgentVault data directory — fenced off from worker tool access. */
66
+ dataDir?: string;
58
67
  }
59
68
  export declare class PersistentClaudeSession {
60
69
  private opts;
@@ -63,10 +72,21 @@ export declare class PersistentClaudeSession {
63
72
  /** Reply sink bound to the message currently being processed. Set as each
64
73
  * message is handed to the model so the say tool routes to the right place. */
65
74
  private activeReply?;
66
- /** Per-turn state for the #416 DM auto-reply fallback. */
75
+ /**
76
+ * Per-turn state for the #416 DM auto-reply fallback.
77
+ *
78
+ * LOAD-BEARING for the worker tool gate: `currentAutoReply` is true ONLY on
79
+ * owner DM turns (`autoReplyOnText:true` from wireBridge) and false on room
80
+ * turns (`autoReplyOnText:false`). `makeWorkerPermission` calls
81
+ * `isOwnerTurn: () => this.currentAutoReply` to decide whether tools are
82
+ * allowed. A room-origin turn MUST keep this false or untrusted input gains
83
+ * full tool access. See worker-permission.test.ts for the covering assertion.
84
+ */
67
85
  private currentAutoReply;
68
86
  private saidThisTurn;
69
87
  private turnText;
88
+ /** In-process room MCP server — hoisted so buildSdkOptions can reference it. */
89
+ private roomServer;
70
90
  constructor(opts: SessionOpts);
71
91
  /**
72
92
  * Queue an inbound message for the model.
@@ -83,6 +103,13 @@ export declare class PersistentClaudeSession {
83
103
  * the one captured for the message being answered, not a live global target. */
84
104
  deliver(text: string): Promise<void>;
85
105
  private input;
106
+ /**
107
+ * Build the SDK options for this session. Locked mode (default) is safe for
108
+ * untrusted inbound room messages. Worker mode (opts.worker=true) enables full
109
+ * toolset on owner DMs, project context, workspace cwd, and an AV data-dir
110
+ * fence via canUseTool.
111
+ */
112
+ private buildSdkOptions;
86
113
  start(): Promise<void>;
87
114
  }
88
115
  export {};
@@ -0,0 +1,22 @@
1
+ import type { CanUseTool, HookCallbackMatcher } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * PRIMARY enforcement: a PreToolUse hook. Unlike canUseTool (the SDK's "ask"
4
+ * path, which `permissionMode:"auto"` BYPASSES by auto-approving benign tools via
5
+ * its classifier — verified live: the canUseTool gate never fired in auto mode),
6
+ * a PreToolUse `deny` fires on EVERY tool call regardless of permission mode.
7
+ * This is what actually enforces the gate at runtime.
8
+ */
9
+ export declare function makeWorkerPreToolUseHook(opts: {
10
+ dataDir: string;
11
+ isOwnerTurn: () => boolean;
12
+ }): HookCallbackMatcher;
13
+ /**
14
+ * SECONDARY (defense-in-depth): the canUseTool callback covers the SDK's "ask"
15
+ * path (permissionMode `"default"`/`"dontAsk"`). Shares gateDecision with the
16
+ * PreToolUse hook above, so a deny in one is a deny in the other.
17
+ */
18
+ export declare function makeWorkerPermission(opts: {
19
+ dataDir: string;
20
+ isOwnerTurn: () => boolean;
21
+ }): CanUseTool;
22
+ //# 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.3.3",
3
+ "version": "0.3.4",
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",