@stage-labs/metro 0.1.0-beta.193 → 0.1.0-beta.195

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.193",
3
+ "version": "0.1.0-beta.195",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.193",
3
+ "version": "0.1.0-beta.195",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -0,0 +1,55 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StationName } from '@metro-labs/core/station-names';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
5
+ import { stationByName } from '../stations/registry.js';
6
+ import type { AccountRoute } from './account-routes.js';
7
+ import { handleSenderCards, type SenderCardDeps } from './sender-cards.js';
8
+
9
+ interface Target {
10
+ station: StationName;
11
+ accountId: string;
12
+ }
13
+
14
+ export interface LookupDeps extends SenderCardDeps {
15
+ resolveSender: (station: StationName, accountId: string, query: string) => Promise<unknown>;
16
+ }
17
+
18
+ type Lookup = (req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target) => Promise<void>;
19
+
20
+ function lookupQuery(req: IncomingMessage): string {
21
+ const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
22
+ return (params.get('q') ?? '').trim();
23
+ }
24
+
25
+ async function handleResolve(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
26
+ if (stationByName(target.station)?.resolvesSenders !== true)
27
+ throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
28
+ const query = lookupQuery(req);
29
+ if (query === '') throw new ApiError('a number to look up is required', 400);
30
+ sendJson(req, res, 200, await deps.resolveSender(target.station, target.accountId, query));
31
+ }
32
+
33
+ async function handleName(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
34
+ if (stationByName(target.station)?.claimsName !== true)
35
+ throw new ApiError(`${target.station} accounts have no name to claim`, 400);
36
+ if (req.method === 'GET') {
37
+ sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
38
+ return;
39
+ }
40
+ const label = bodyField(await readJsonBody(req), 'label');
41
+ if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
42
+ sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
43
+ }
44
+
45
+ function handleRecent(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
46
+ sendJson(req, res, 200, { senders: deps.recentSenders(target.station, target.accountId) });
47
+ return Promise.resolve();
48
+ }
49
+
50
+ export const LOOKUPS: Partial<Record<AccountRoute['kind'], Lookup>> = {
51
+ profiles: handleSenderCards,
52
+ resolve: handleResolve,
53
+ name: handleName,
54
+ senders: handleRecent,
55
+ };
@@ -12,6 +12,7 @@ export type AccountRoute =
12
12
  | { kind: 'resolve'; station: StationName; accountId: string }
13
13
  | { kind: 'enabled'; station: StationName; accountId: string }
14
14
  | { kind: 'policy'; station: StationName; accountId: string }
15
+ | { kind: 'profiles'; station: StationName; accountId: string }
15
16
  | { kind: 'name'; station: StationName; accountId: string };
16
17
 
17
18
  const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
@@ -24,10 +25,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
24
25
  resolve: ['GET'],
25
26
  enabled: ['PUT'],
26
27
  policy: ['PUT'],
28
+ profiles: ['GET'],
27
29
  name: ['GET', 'POST'],
28
30
  };
29
31
 
30
- const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy'] as const;
32
+ const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
31
33
 
32
34
  type SubRoute = (typeof SUB_ROUTES)[number];
33
35
 
@@ -1,17 +1,15 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
- import { normalizeAllowlist } from './allowlist.js';
3
+ import { normalizeAllowlist, normalizeApprovers } from './allowlist.js';
4
+ import { approversForAccount } from './map.js';
5
+ import { isRecord } from '@metro-labs/core/is-record';
4
6
  import type { RecentSender } from './senders.js';
5
7
  import { ApiError } from '@metro-labs/http/api-error';
6
- import {
7
- apiFailure,
8
- bodyField,
9
- readJsonBody,
10
- sendJson,
11
- } from '@metro-labs/http/api-http';
8
+ import { apiFailure, bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
12
9
  import { type AccountRef } from './account-attach.js';
13
10
  import { type AccountRoute } from './account-routes.js';
14
11
  import { handlePolicy, type SetPolicy } from './policy-route.js';
12
+ import { LOOKUPS } from './account-lookups.js';
15
13
  import { stationByName } from '../stations/registry.js';
16
14
  import type { StationName } from '@metro-labs/core/station-names';
17
15
  import {
@@ -68,6 +66,7 @@ export interface AccountApiDeps {
68
66
  station: StationName,
69
67
  accountId: string,
70
68
  allowlist: string[],
69
+ approvers?: string[],
71
70
  ) => Promise<string[]>;
72
71
  setPolicy: SetPolicy;
73
72
  recentSenders: (station: StationName, accountId: string) => RecentSender[];
@@ -89,46 +88,7 @@ export interface AccountApiDeps {
89
88
  ) => Promise<boolean>;
90
89
  }
91
90
 
92
- export const ATTACHABLE: string[] = [
93
- ...ATTACHABLE_STATIONS,
94
- ...INTERACTIVE_STATIONS,
95
- ];
96
-
97
- function lookupQuery(req: IncomingMessage): string {
98
- const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
99
- return (params.get('q') ?? '').trim();
100
- }
101
-
102
- async function handleResolve(
103
- req: IncomingMessage,
104
- res: ServerResponse,
105
- deps: AccountApiDeps,
106
- target: { station: StationName; accountId: string },
107
- ): Promise<void> {
108
- if (stationByName(target.station)?.resolvesSenders !== true)
109
- throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
110
- const query = lookupQuery(req);
111
- if (query === '') throw new ApiError('a number to look up is required', 400);
112
- const found = await deps.resolveSender(target.station, target.accountId, query);
113
- sendJson(req, res, 200, found);
114
- }
115
-
116
- async function handleName(
117
- req: IncomingMessage,
118
- res: ServerResponse,
119
- deps: AccountApiDeps,
120
- target: { station: StationName; accountId: string },
121
- ): Promise<void> {
122
- if (stationByName(target.station)?.claimsName !== true)
123
- throw new ApiError(`${target.station} accounts have no name to claim`, 400);
124
- if (req.method === 'GET') {
125
- sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
126
- return;
127
- }
128
- const label = bodyField(await readJsonBody(req), 'label');
129
- if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
130
- sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
131
- }
91
+ export const ATTACHABLE: string[] = [...ATTACHABLE_STATIONS, ...INTERACTIVE_STATIONS];
132
92
 
133
93
  async function activate(
134
94
  deps: AccountApiDeps,
@@ -279,14 +239,18 @@ async function handleAllowlist(
279
239
  agentId: string,
280
240
  target: { station: StationName; accountId: string },
281
241
  ): Promise<void> {
282
- const wanted = normalizeAllowlist(bodyField(await readJsonBody(req), 'allowlist'));
283
- const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted);
242
+ const body = await readJsonBody(req);
243
+ const wanted = normalizeAllowlist(bodyField(body, 'allowlist'));
244
+ const asked = isRecord(body) && 'approvers' in body ? body.approvers : approversForAccount(target.station, target.accountId);
245
+ const approvers = normalizeApprovers(asked, wanted);
246
+ const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted, approvers);
284
247
  log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
285
248
  sendJson(req, res, 200, {
286
249
  agentId,
287
250
  station: target.station,
288
251
  accountId: target.accountId,
289
252
  allowlist,
253
+ approvers,
290
254
  activated: await applied(deps),
291
255
  });
292
256
  }
@@ -346,6 +310,18 @@ async function handleStep(
346
310
  sendJson(req, res, 200, view);
347
311
  }
348
312
 
313
+ async function dispatchAttach(
314
+ req: IncomingMessage,
315
+ res: ServerResponse,
316
+ deps: AccountApiDeps,
317
+ agentId: string,
318
+ route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
319
+ ): Promise<void> {
320
+ if (route.kind === 'start') return handleStart(req, res, deps, agentId);
321
+ if (route.kind === 'session') return handleSession(req, res, deps, { agentId }, route.attachId);
322
+ return handleStep(req, res, deps, { agentId }, route.attachId);
323
+ }
324
+
349
325
  async function dispatchRoute(
350
326
  req: IncomingMessage,
351
327
  res: ServerResponse,
@@ -353,27 +329,12 @@ async function dispatchRoute(
353
329
  agentId: string,
354
330
  route: AccountRoute,
355
331
  ): Promise<void> {
356
- if (route.kind === 'start')
357
- return handleStart(req, res, deps, agentId);
358
- if (route.kind === 'session')
359
- return handleSession(
360
- req,
361
- res,
362
- deps,
363
- { agentId },
364
- route.attachId,
365
- );
366
- if (route.kind === 'step')
367
- return handleStep(req, res, deps, { agentId }, route.attachId);
332
+ if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route);
368
333
  if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
369
334
  if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
370
335
  if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
371
- if (route.kind === 'resolve') return handleResolve(req, res, deps, route);
372
- if (route.kind === 'name') return handleName(req, res, deps, route);
373
- if (route.kind === 'senders') {
374
- sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
375
- return;
376
- }
336
+ const lookup = LOOKUPS[route.kind];
337
+ if (lookup !== undefined && 'station' in route) return lookup(req, res, deps, route);
377
338
  return handleDetach(req, res, deps, agentId, route);
378
339
  }
379
340
 
@@ -29,6 +29,19 @@ function senderId(entry: unknown): string {
29
29
  return value.startsWith('@') ? domainEntry(value) : value;
30
30
  }
31
31
 
32
+ export function normalizeApprovers(raw: unknown, allowlist: string[]): string[] {
33
+ if (raw === undefined) return [];
34
+ if (!Array.isArray(raw)) throw new ApiError('approvers must be a list of sender ids', 400);
35
+ const listed = new Set(allowlist.map((entry) => entry.toLowerCase()));
36
+ const seen = new Set<string>();
37
+ return raw.map(senderId).filter((value) => {
38
+ const key = value.toLowerCase();
39
+ if (!listed.has(key) || seen.has(key)) return false;
40
+ seen.add(key);
41
+ return true;
42
+ });
43
+ }
44
+
32
45
  export function normalizeAllowlist(raw: unknown): string[] {
33
46
  if (!Array.isArray(raw)) throw new ApiError('allowlist must be a list of sender ids', 400);
34
47
  if (raw.length > MAX_ENTRIES) throw new ApiError(`allowlist holds at most ${String(MAX_ENTRIES)} senders`, 400);
@@ -7,6 +7,7 @@ import { isRecord } from '@metro-labs/core/is-record';
7
7
  import { parseId } from '@metro-labs/core/ids';
8
8
  import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../stations/materialize.js';
9
9
  import { STATIONS } from '@metro-labs/core/station-names';
10
+ import { approversOf } from './files.js';
10
11
 
11
12
  const AGENTS = '/api/agents';
12
13
  const RESTORE_PATH = `${AGENTS}/restore`;
@@ -60,6 +61,7 @@ function stationOf(raw: unknown): LoadedAccount {
60
61
  station: raw.station as LoadedAccount['station'],
61
62
  id: raw.id,
62
63
  allowlist,
64
+ ...approversOf(raw.approvers),
63
65
  enabled: raw.enabled !== false,
64
66
  ...(policy === undefined ? {} : { policy }),
65
67
  config: raw.config,
@@ -243,12 +243,15 @@ export async function localSetAllowlist(
243
243
  station: StationName,
244
244
  accountId: string,
245
245
  allowlist: string[],
246
+ approvers: string[] = [],
246
247
  dir = agentsDir(),
247
248
  ): Promise<string[]> {
248
249
  const stored = agentOrThrow(agentId, dir);
249
250
  const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
250
251
  if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
251
252
  account.allowlist = allowlist;
253
+ if (approvers.length === 0) delete account.approvers;
254
+ else account.approvers = approvers;
252
255
  save(stored);
253
256
  return Promise.resolve(allowlist);
254
257
  }
@@ -49,6 +49,12 @@ function allowlistOf(raw: unknown, path: string, where: string): string[] | null
49
49
  return list;
50
50
  }
51
51
 
52
+ export function approversOf(raw: unknown): { approvers?: string[] } {
53
+ if (!Array.isArray(raw)) return {};
54
+ const list = raw.filter((entry): entry is string => typeof entry === 'string' && entry !== '*' && entry.trim() !== '');
55
+ return list.length === 0 ? {} : { approvers: list };
56
+ }
57
+
52
58
  function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
53
59
  const where = `stations[${String(index)}]`;
54
60
  if (!isRecord(raw)) fail(path, `${where} is not an object`);
@@ -62,6 +68,7 @@ function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
62
68
  station,
63
69
  id,
64
70
  allowlist: allowlistOf(raw.allowlist, path, where),
71
+ ...approversOf(raw.approvers),
65
72
  enabled: raw.enabled !== false,
66
73
  ...(policy === undefined ? {} : { policy }),
67
74
  config,
@@ -8,6 +8,7 @@ const mapKey = (station: string, accountId: string): string =>
8
8
  let agentMap: AgentMap = {};
9
9
  let agentNames: AgentNameMap = {};
10
10
  let allowlistMap: AllowlistMap = {};
11
+ let approversMap: AllowlistMap = {};
11
12
  let disabledAccounts = new Set<string>();
12
13
 
13
14
  export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
@@ -19,6 +20,22 @@ export function setAllowlistMap(map: AllowlistMap): void {
19
20
  allowlistMap = map;
20
21
  }
21
22
 
23
+ export function setApproversMap(map: AllowlistMap): void {
24
+ approversMap = map;
25
+ }
26
+
27
+ export const approversForAccount = (station: string, accountId: string): string[] => approversMap[mapKey(station, accountId)] ?? [];
28
+
29
+ export function approversForLine(line: string): string[] {
30
+ const a = accountFromLine(line);
31
+ return a ? approversForAccount(a.station, a.accountId) : [];
32
+ }
33
+
34
+ export const mayApprove = (line: string, from: string): boolean => {
35
+ const approvers = approversForLine(line);
36
+ return approvers.length > 0 && senderMatchesAllowlist(approvers, from);
37
+ };
38
+
22
39
  export function setDisabledAccounts(ids: Set<string>): void {
23
40
  disabledAccounts = ids;
24
41
  }
@@ -0,0 +1,74 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StationName } from '@metro-labs/core/station-names';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { sendJson } from '@metro-labs/http/api-http';
5
+ import { isRecord } from '@metro-labs/core/is-record';
6
+ import { stationByName } from '../stations/registry.js';
7
+ import { allowlistForAccount } from './map.js';
8
+ import type { RecentSender } from './senders.js';
9
+
10
+ export interface SenderCard {
11
+ id: string;
12
+ name?: string;
13
+ handle?: string;
14
+ }
15
+
16
+ export interface SenderCardDeps {
17
+ recentSenders: (station: StationName, accountId: string) => RecentSender[];
18
+ accountCall: (station: StationName, action: string, args: Record<string, unknown>) => Promise<unknown>;
19
+ }
20
+
21
+ const MAX_LOOKUPS = 50;
22
+ const LOOKUP_MS = 8_000;
23
+
24
+ const filled = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
25
+
26
+ function cardOf(id: string, profile: unknown, seenName: string | undefined): SenderCard {
27
+ const p = isRecord(profile) ? profile : {};
28
+ const display = filled(p.display_name);
29
+ const handle = filled(p.name);
30
+ const name = display ?? seenName ?? handle;
31
+ return {
32
+ id,
33
+ ...(name === undefined ? {} : { name }),
34
+ ...(handle === undefined || handle === name ? {} : { handle }),
35
+ };
36
+ }
37
+
38
+ async function lookup(deps: SenderCardDeps, station: StationName, accountId: string, user: string): Promise<unknown> {
39
+ let timer: ReturnType<typeof setTimeout> | undefined;
40
+ const late = new Promise<null>((resolve) => {
41
+ timer = setTimeout(() => {
42
+ resolve(null);
43
+ }, LOOKUP_MS);
44
+ });
45
+ try {
46
+ return await Promise.race([deps.accountCall(station, 'profile', { account: accountId, user }), late]);
47
+ } catch (err) {
48
+ log.info({ station, account: accountId, err: errMsg(err) }, 'account-api: could not read the profile of a listed sender');
49
+ return null;
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ export async function senderCards(deps: SenderCardDeps, station: StationName, accountId: string): Promise<SenderCard[]> {
56
+ const ids = (allowlistForAccount(station, accountId) ?? []).filter((id) => id !== '*' && !id.startsWith('@'));
57
+ const seen = new Map(deps.recentSenders(station, accountId).map((s) => [s.id.toLowerCase(), filled(s.name)]));
58
+ const reads = stationByName(station)?.readsProfiles === true;
59
+ return Promise.all(
60
+ ids.map(async (id, at) => {
61
+ const profile = reads && at < MAX_LOOKUPS ? await lookup(deps, station, accountId, id) : null;
62
+ return cardOf(id, profile, seen.get(id.toLowerCase()));
63
+ }),
64
+ );
65
+ }
66
+
67
+ export async function handleSenderCards(
68
+ req: IncomingMessage,
69
+ res: ServerResponse,
70
+ deps: SenderCardDeps,
71
+ target: { station: StationName; accountId: string },
72
+ ): Promise<void> {
73
+ sendJson(req, res, 200, { senders: await senderCards(deps, target.station, target.accountId) });
74
+ }
@@ -29,7 +29,7 @@ interface InboundDeps {
29
29
  getStations: () => Set<string>;
30
30
  senderAllowed: (from: string, line: string, verified?: boolean) => boolean;
31
31
  approves?: (station: string) => boolean;
32
- answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string) => Promise<boolean>;
32
+ answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string, from: string) => Promise<boolean>;
33
33
  }
34
34
 
35
35
  const ATTACH_TIMEOUT_MS = 15_000;
@@ -253,7 +253,7 @@ export class InboundRelay {
253
253
  if (answer === undefined || base.evType !== 'msg' || !this.approves(base.station) || ev.senderVerified === false) return false;
254
254
  const m = PERMISSION_REPLY_RE.exec(base.text);
255
255
  if (m?.[1] === undefined || m[2] === undefined) return false;
256
- return answer(m[2].toLowerCase(), m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny', base.line);
256
+ return answer(m[2].toLowerCase(), m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny', base.line, base.from);
257
257
  }
258
258
 
259
259
  private approves(station: string): boolean {
@@ -6,7 +6,7 @@ import {
6
6
  } from '../stations/registry.js';
7
7
  import { listEndpoints } from '../stations/webhook-endpoints.js';
8
8
  import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
9
- import { accountEnabled, agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
9
+ import { accountEnabled, agentIdForAccount, allowlistForAccount, approversForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
10
10
 
11
11
  const accountId = (acc: unknown): string | undefined => {
12
12
  const id = (acc as { id?: unknown }).id;
@@ -28,7 +28,9 @@ function withAgentId(station: string, acc: unknown): unknown {
28
28
  const allowlist = allowlistForAccount(station, id);
29
29
  const stored = storedPolicy({ kind: 'channel', station, account: id });
30
30
  const policy = stored === undefined ? {} : { policy: stored };
31
- const tagged = allowlist === undefined ? { ...rec, agentId, ...policy } : { ...rec, agentId, allowlist, ...policy };
31
+ const approvers = approversForAccount(station, id);
32
+ const extra = approvers.length === 0 ? policy : { ...policy, approvers };
33
+ const tagged = allowlist === undefined ? { ...rec, agentId, ...extra } : { ...rec, agentId, allowlist, ...extra };
32
34
  return accountEnabled(station, id) ? tagged : { ...tagged, enabled: false };
33
35
  }
34
36
 
@@ -2,6 +2,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { z } from 'zod';
3
3
  import type { InboundRelay } from '../channels/inbound.js';
4
4
  import { holdPrompt, type Behavior } from '../approvals/pending.js';
5
+ import { approversForLine } from '../agents/map.js';
5
6
  import { metroCall } from './ctx.js';
6
7
  import { promptBody } from './permission-prompt.js';
7
8
 
@@ -40,6 +41,10 @@ function relayLine(deps: PermissionRelayDeps, requestId: string): string | undef
40
41
  deps.log('permission_request: known line is outside the agent scope, held for the page only', requestId);
41
42
  return undefined;
42
43
  }
44
+ if (approversForLine(line).length === 0) {
45
+ deps.log('permission_request: nobody on that chat may approve, held for the page only', requestId);
46
+ return undefined;
47
+ }
43
48
  return line;
44
49
  }
45
50
 
@@ -4,7 +4,7 @@ import { InboundRelay } from '../channels/inbound.js';
4
4
  import { answerPrompt, forgetPromptsOf, promptLine } from '../approvals/pending.js';
5
5
  import { ChannelRelay, type ReplayLedger } from '../channels/relay.js';
6
6
  import { errMsg } from '@metro-labs/core/log';
7
- import { allowlistForLine, senderPermitted } from '../agents/map.js';
7
+ import { allowlistForLine, mayApprove, senderPermitted } from '../agents/map.js';
8
8
  import { accountStationNames, stationByName } from '../stations/registry.js';
9
9
  import { eventInScope } from '../agents/scope.js';
10
10
  import { MCP_INSTRUCTIONS } from './instructions.js';
@@ -24,8 +24,8 @@ const senderAllowed = (from: string, line: string, verified?: boolean): boolean
24
24
 
25
25
  const approves = (station: string): boolean => stationByName(station)?.approvals !== false;
26
26
 
27
- async function answerPermission(requestId: string, behavior: 'allow' | 'deny', line: string): Promise<boolean> {
28
- if (promptLine(requestId) !== line) return false;
27
+ async function answerPermission(requestId: string, behavior: 'allow' | 'deny', line: string, from: string): Promise<boolean> {
28
+ if (promptLine(requestId) !== line || !mayApprove(line, from)) return false;
29
29
  return (await answerPrompt(requestId, behavior, 'chat', line)) !== undefined;
30
30
  }
31
31
 
@@ -14,6 +14,7 @@ import { trainsDir } from '../boot/paths.js';
14
14
  import {
15
15
  setAgentMap,
16
16
  setAllowlistMap,
17
+ setApproversMap,
17
18
  setDisabledAccounts,
18
19
  type AgentMap,
19
20
  type AgentNameMap,
@@ -32,6 +33,7 @@ export interface LoadedAccount {
32
33
  station: StationName;
33
34
  id: string;
34
35
  allowlist: string[] | null;
36
+ approvers?: string[];
35
37
  enabled?: boolean;
36
38
  policy?: ToolPolicy;
37
39
  config: Record<string, unknown>;
@@ -137,6 +139,11 @@ const channelPolicies = (list: LoadedAgent[]): [PolicyTarget, ToolPolicy][] =>
137
139
  ),
138
140
  );
139
141
 
142
+ const approversOf = (list: LoadedAgent[]): Record<string, string[]> =>
143
+ Object.fromEntries(
144
+ list.flatMap((agent) => agent.accounts.flatMap((a) => (a.approvers === undefined ? [] : [[`${a.station}/${a.id}`, a.approvers]]))),
145
+ );
146
+
140
147
  function writeStations(list: LoadedAgent[]): WrittenStations {
141
148
  mkdirSync(METRO_DIR, { recursive: true });
142
149
  mkdirSync(trainsDir(), { recursive: true });
@@ -169,6 +176,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
169
176
  }
170
177
  setAgentMap(map, names);
171
178
  setAllowlistMap(allow);
179
+ setApproversMap(approversOf(list));
172
180
  setDisabledAccounts(disabled);
173
181
  setPolicies('channel', channelPolicies(list));
174
182
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.193"
2
+ "version": "0.1.0-beta.195"
3
3
  }