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

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.194",
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.194",
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",
@@ -1,6 +1,8 @@
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
8
  import {
@@ -68,6 +70,7 @@ export interface AccountApiDeps {
68
70
  station: StationName,
69
71
  accountId: string,
70
72
  allowlist: string[],
73
+ approvers?: string[],
71
74
  ) => Promise<string[]>;
72
75
  setPolicy: SetPolicy;
73
76
  recentSenders: (station: StationName, accountId: string) => RecentSender[];
@@ -279,14 +282,18 @@ async function handleAllowlist(
279
282
  agentId: string,
280
283
  target: { station: StationName; accountId: string },
281
284
  ): Promise<void> {
282
- const wanted = normalizeAllowlist(bodyField(await readJsonBody(req), 'allowlist'));
283
- const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted);
285
+ const body = await readJsonBody(req);
286
+ const wanted = normalizeAllowlist(bodyField(body, 'allowlist'));
287
+ const asked = isRecord(body) && 'approvers' in body ? body.approvers : approversForAccount(target.station, target.accountId);
288
+ const approvers = normalizeApprovers(asked, wanted);
289
+ const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted, approvers);
284
290
  log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
285
291
  sendJson(req, res, 200, {
286
292
  agentId,
287
293
  station: target.station,
288
294
  accountId: target.accountId,
289
295
  allowlist,
296
+ approvers,
290
297
  activated: await applied(deps),
291
298
  });
292
299
  }
@@ -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
  }
@@ -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.194"
3
3
  }