@nopeek/agent-bridge 0.1.0 → 0.1.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/bot.d.ts CHANGED
@@ -18,6 +18,9 @@ export declare class BotRunner {
18
18
  private refreshTimer;
19
19
  private seen;
20
20
  private channelCache;
21
+ private allowed;
22
+ private accessLoaded;
23
+ private declined;
21
24
  private log;
22
25
  private logErr;
23
26
  constructor(info: BotInfo, cfg: BridgeConfig);
@@ -25,6 +28,13 @@ export declare class BotRunner {
25
28
  start(): void;
26
29
  stop(): void;
27
30
  private mintSession;
31
+ /** Fetch (or refresh) the set of users allowed to talk to this bot. Called on
32
+ * connect and whenever a grant changes. Best-effort: on failure we keep the
33
+ * last-known set, and if we've never loaded one we FAIL CLOSED (owner-only)
34
+ * rather than open. */
35
+ refreshAccess(): Promise<void>;
36
+ /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
37
+ private isAllowed;
28
38
  private run;
29
39
  private connectOnce;
30
40
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
package/dist/bot.js CHANGED
@@ -22,6 +22,13 @@ export class BotRunner {
22
22
  refreshTimer = null;
23
23
  seen = new Set();
24
24
  channelCache = new Map();
25
+ // Access control: bots are PRIVATE by default. `allowed` is the set of user
26
+ // ids permitted to talk to this bot (owner + workspace members + granted
27
+ // users), fetched from the server. A sender NOT in this set never reaches the
28
+ // brain — so a stranger who finds the @handle can't drive the owner's agent.
29
+ allowed = new Set();
30
+ accessLoaded = false;
31
+ declined = new Set(); // (senderId) already told "not authorized" once
25
32
  log;
26
33
  logErr;
27
34
  constructor(info, cfg) {
@@ -67,6 +74,32 @@ export class BotRunner {
67
74
  }
68
75
  return (await res.json());
69
76
  }
77
+ /** Fetch (or refresh) the set of users allowed to talk to this bot. Called on
78
+ * connect and whenever a grant changes. Best-effort: on failure we keep the
79
+ * last-known set, and if we've never loaded one we FAIL CLOSED (owner-only)
80
+ * rather than open. */
81
+ async refreshAccess() {
82
+ try {
83
+ const res = await fetch(`${this.cfg.apiUrl}/v1/apps/${this.cfg.appId}/bots/${this.info.userId}/access`, { headers: { authorization: `Bearer ${this.cfg.pairingCode}` } });
84
+ if (!res.ok)
85
+ throw new Error(`access HTTP ${res.status}`);
86
+ const j = (await res.json());
87
+ this.allowed = new Set(j.allowedUserIds ?? []);
88
+ this.accessLoaded = true;
89
+ this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s)`);
90
+ }
91
+ catch (err) {
92
+ this.logErr(`access refresh failed: ${err.message} (keeping last-known allow list)`);
93
+ }
94
+ }
95
+ /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
96
+ isAllowed(senderUserId) {
97
+ if (senderUserId === this.info.ownerId)
98
+ return true; // owner always
99
+ if (!this.accessLoaded)
100
+ return false; // fail closed until we know the list
101
+ return this.allowed.has(senderUserId);
102
+ }
70
103
  async run() {
71
104
  this.log(`starting (${this.info.userId}) brain=${this.brainKind}`);
72
105
  let delay = 2_000;
@@ -121,6 +154,7 @@ export class BotRunner {
121
154
  this.logErr(`handler error for ${m.messageId}: ${err.message}`);
122
155
  });
123
156
  }));
157
+ await this.refreshAccess(); // load the allow list before we answer anyone
124
158
  this.scheduleRefresh(session.expiresAt);
125
159
  }
126
160
  /** Runtime sessions expire; reconnect with a fresh one shortly before that. */
@@ -193,9 +227,22 @@ export class BotRunner {
193
227
  }
194
228
  if (m.body?.type !== "text" || typeof m.body.text !== "string" || !m.body.text.trim())
195
229
  return;
196
- // TODO(grants): enforce per-channel grants once bot_grant_changed carries
197
- // enough to build an allowlist. v1 answers everyone in any channel the bot
198
- // is a member of; the control socket already logs grant changes.
230
+ // ACCESS CONTROL: a bot is private. If the sender isn't authorized (owner,
231
+ // workspace member, or explicitly granted), the brain is NEVER invoked so
232
+ // a stranger who found the @handle can't make the owner's agent do anything.
233
+ if (!this.isAllowed(m.senderUserId)) {
234
+ this.log(`${m.channelId} <- ${m.senderUserId}: BLOCKED (not authorized) — brain not invoked`);
235
+ if (this.cfg.declineMessage && !this.declined.has(m.senderUserId)) {
236
+ this.declined.add(m.senderUserId);
237
+ try {
238
+ await (await this.getChannel(m.channelId)).send({ text: this.cfg.declineMessage });
239
+ }
240
+ catch {
241
+ /* best-effort decline notice */
242
+ }
243
+ }
244
+ return;
245
+ }
199
246
  const text = m.body.text;
200
247
  this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
201
248
  const ch = await this.getChannel(m.channelId);
package/dist/bridge.js CHANGED
@@ -41,9 +41,12 @@ export async function runBridge(cfg) {
41
41
  onAdoptBot: (f) => {
42
42
  startBot({ userId: f.botUserId, handle: f.handle, ownerId: f.ownerUserId });
43
43
  },
44
- onGrantChanged: () => {
45
- // v1: logged by ControlSocket. TODO(grants): pass down to the affected
46
- // BotRunner and enforce an allowlist before answering.
44
+ onGrantChanged: (f) => {
45
+ // Access changed for a bot re-pull its allow list so enforcement is live
46
+ // (a revoked user stops being answered within seconds, no restart).
47
+ const runner = bots.get(f.botUserId);
48
+ if (runner)
49
+ void runner.refreshAccess();
47
50
  },
48
51
  });
49
52
  // ---------------------------------------------------------------- health --
package/dist/config.d.ts CHANGED
@@ -20,6 +20,9 @@ export interface BridgeConfig {
20
20
  port: number;
21
21
  /** Where per-bot device identity/key stores live (./data by default). */
22
22
  dataDir: string;
23
+ /** Optional reply sent ONCE to an unauthorized sender. Null (default) =
24
+ * silently ignore them (most private — doesn't reveal the bot exists). */
25
+ declineMessage: string | null;
23
26
  }
24
27
  export declare const DEFAULT_API_URL = "https://d3qweh72vesa98.cloudfront.net";
25
28
  export declare const DEFAULT_PORT = 8790;
package/dist/config.js CHANGED
@@ -149,5 +149,6 @@ export function loadConfig(argv = process.argv.slice(2)) {
149
149
  brainTimeoutMs,
150
150
  port,
151
151
  dataDir: resolve(process.cwd(), get("data-dir") ?? "data"),
152
+ declineMessage: get("decline-message") ?? null,
152
153
  };
153
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with a one-time code, runs every bot you own, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",