@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +13 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +8 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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.
|
|
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
|
|
283
|
-
const
|
|
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
|
|
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
|
|
package/runtime/runtime.json
CHANGED