@stage-labs/metro 0.1.0-beta.180 → 0.1.0-beta.182

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.180",
3
+ "version": "0.1.0-beta.182",
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.180",
3
+ "version": "0.1.0-beta.182",
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",
@@ -27,7 +27,7 @@ function parseAccountScoped(
27
27
  const isSnowflake = (s: string): boolean => /^\d+$/.test(s);
28
28
  const isThreemaResource = (s: string): boolean =>
29
29
  /^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})(?:-[0-9a-f]{16})?$/i.test(s);
30
- const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_%.-]+$/.test(s);
30
+ const isOutlookResource = (s: string): boolean => /^[A-Za-z0-9=_%.@+-]+$/.test(s);
31
31
  const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
32
32
 
33
33
  function splitTelegramAccount(path: string[]): { accountId: string; rest: string[] } {
@@ -0,0 +1,2 @@
1
+ export const NEEDS_APPROVAL = (where: string, tool: string): string =>
2
+ `Needs the owner's approval for ${where} (${tool}). Make this exact call from a background worker so Claude Code asks the owner in chat, and wait for the answer. An approval given only in the terminal does not count.`;
@@ -27,6 +27,27 @@ const HOUR_MS = 3_600_000;
27
27
 
28
28
  const held = new Map<string, Held>();
29
29
 
30
+ interface Grant {
31
+ tool: string;
32
+ preview: string;
33
+ at: number;
34
+ }
35
+
36
+ const GRANT_TTL_MS = 10 * 60_000;
37
+ const GRANTS_MAX = 200;
38
+ let grants: Grant[] = [];
39
+
40
+ function grant(entry: Held, now = Date.now()): void {
41
+ grants = [...grants.filter((g) => now - g.at < GRANT_TTL_MS), { tool: entry.tool, preview: entry.preview, at: now }].slice(-GRANTS_MAX);
42
+ }
43
+
44
+ export function takeGrant(toolMatches: (tool: string) => boolean, args: Record<string, unknown>, now = Date.now()): boolean {
45
+ const at = grants.findIndex((g) => now - g.at < GRANT_TTL_MS && toolMatches(g.tool) && previewMatches(g.preview, args));
46
+ if (at < 0) return false;
47
+ grants = grants.filter((_, i) => i !== at);
48
+ return true;
49
+ }
50
+
30
51
  export function approvalTtlMs(env: NodeJS.ProcessEnv = process.env): number {
31
52
  const hours = Number(env.METRO_APPROVAL_TTL_H);
32
53
  return (Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_TTL_H) * HOUR_MS;
@@ -82,6 +103,7 @@ export async function answerPrompt(
82
103
  const entry = held.get(requestId);
83
104
  if (entry === undefined) return undefined;
84
105
  held.delete(requestId);
106
+ if (behavior === 'allow' && via !== 'expiry') grant(entry);
85
107
  log.info({ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` }, 'approvals: a Claude Code permission prompt answered');
86
108
  await entry.send(behavior);
87
109
  await tellChat(entry, via, behavior);
@@ -123,4 +145,5 @@ export function startPromptExpiry(): () => void {
123
145
 
124
146
  export function forgetAllPrompts(): void {
125
147
  held.clear();
148
+ grants = [];
126
149
  }
@@ -1,6 +1,8 @@
1
1
  import type { ToolGroup } from '@metro-labs/core/stations/types';
2
2
  import { policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
3
3
  import { serverKeysOf } from './plugin-sync.js';
4
+ import { takeGrant } from '../approvals/pending.js';
5
+ import { NEEDS_APPROVAL } from '../approvals/needs.js';
4
6
 
5
7
  interface GateRow {
6
8
  id: string;
@@ -39,11 +41,18 @@ export const connectorGates = (): readonly ConnectorGate[] => gates;
39
41
 
40
42
  export const toolGroupOf = (gate: ConnectorGate, tool: string): ToolGroup => (gate.tools[tool] === 'read' ? 'read' : 'write');
41
43
 
42
- export function blockedReason(connectorId: string, tool: string): string | null {
44
+ const promptFor = (connectorId: string, tool: string) => (asked: string): boolean => {
45
+ const found = connectorToolOf(asked);
46
+ return found?.gate.id === connectorId && found.tool === tool;
47
+ };
48
+
49
+ export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
43
50
  const gate = gates.find((g) => g.id === connectorId);
44
51
  if (gate === undefined) return null;
45
52
  const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
46
- return access === 'deny' ? `Blocked by the owner's policy for ${gate.name} (${tool}).` : null;
53
+ if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
54
+ if (access === 'allow' || takeGrant(promptFor(connectorId, tool), args)) return null;
55
+ return NEEDS_APPROVAL(gate.name, tool);
47
56
  }
48
57
 
49
58
  function prefixesOf(gate: ConnectorGate): string[] {
@@ -2,7 +2,7 @@ import type { ServerResponse } from 'node:http';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { log } from '@metro-labs/core/log';
4
4
 
5
- export type BlockedReason = (connectorId: string, tool: string) => string | null;
5
+ export type BlockedReason = (connectorId: string, tool: string, args: Record<string, unknown>) => string | null;
6
6
 
7
7
  export interface Screened {
8
8
  forward: Uint8Array<ArrayBuffer> | null;
@@ -31,9 +31,14 @@ function blockedAnswer(message: Record<string, unknown>, reason: string): Record
31
31
  return { jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: reason }] } };
32
32
  }
33
33
 
34
+ function argsOf(message: unknown): Record<string, unknown> {
35
+ if (!isRecord(message) || !isRecord(message.params)) return {};
36
+ return isRecord(message.params.arguments) ? message.params.arguments : {};
37
+ }
38
+
34
39
  function reasonFor(message: unknown, connectorId: string, blocked: BlockedReason): string | null {
35
40
  const tool = toolOf(message);
36
- return tool === null ? null : blocked(connectorId, tool);
41
+ return tool === null ? null : blocked(connectorId, tool, argsOf(message));
37
42
  }
38
43
 
39
44
  export function screenCalls(body: Uint8Array<ArrayBuffer>, connectorId: string, blocked: BlockedReason): Screened | null {
@@ -32,8 +32,14 @@ const withId = (text: string, messageId: string | undefined): string =>
32
32
  interface Sent {
33
33
  labels: string[];
34
34
  messageId?: string;
35
+ thread?: string;
35
36
  }
36
37
 
38
+ const threadOf = (response: { result: unknown }, asked: string): string | undefined => {
39
+ const line = (response.result as { line?: unknown } | null)?.line;
40
+ return typeof line === 'string' && line !== '' && line !== asked ? line : undefined;
41
+ };
42
+
37
43
  const deliveredLabels = (response: { result: unknown }): string[] => {
38
44
  const list = (response.result as { attachments?: unknown } | null)
39
45
  ?.attachments;
@@ -89,17 +95,20 @@ async function sendForwarded(
89
95
  const { line, ctx, station } = m;
90
96
  const args: Record<string, unknown> = { line };
91
97
  if (text) args.text = text;
98
+ if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
92
99
  if (replyTo) args.replyTo = replyTo;
93
100
  if (atts.length) args.attachments = atts.map(toCanonical);
94
101
  const response = await ctx.call('send', args);
95
102
  const messageId = messageIdOf(response);
103
+ const thread = threadOf(response, line);
96
104
  const labels: string[] = [];
97
105
  if (text) labels.push('text');
98
- if (!atts.length) return { labels, messageId };
99
- const delivered = deliveredLabels(response);
100
- assertDelivered(station, delivered, atts);
101
- labels.push(...delivered);
102
- return { labels, messageId };
106
+ if (atts.length) {
107
+ const delivered = deliveredLabels(response);
108
+ assertDelivered(station, delivered, atts);
109
+ labels.push(...delivered);
110
+ }
111
+ return thread === undefined ? { labels, messageId } : { labels, messageId, thread };
103
112
  }
104
113
 
105
114
  const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
@@ -132,7 +141,8 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
132
141
  if (!sent.labels.length)
133
142
  return errResult('send requires `text` or `attachments`');
134
143
  noteSent(m, sent.messageId);
135
- return ok(withId(`sent: ${sent.labels.join(', ')}`, sent.messageId));
144
+ const done = withId(`sent: ${sent.labels.join(', ')}`, sent.messageId);
145
+ return ok(sent.thread === undefined ? done : `${done} — new thread line: ${sent.thread}`);
136
146
  } finally {
137
147
  await cleanupAttachments(atts);
138
148
  }
@@ -3,6 +3,9 @@ import type { ToolResult } from '@metro-labs/core/stations/types';
3
3
  import { accountFromLine, knownAccounts } from '../agents/map.js';
4
4
  import { policyFor, storedPolicy, strictest, type Access, type PolicyTarget } from '../policy/policy.js';
5
5
  import { errResult } from './ctx.js';
6
+ import { takeGrant } from '../approvals/pending.js';
7
+ import { NEEDS_APPROVAL } from '../approvals/needs.js';
8
+ import { connectorToolOf } from '../connectors/gates.js';
6
9
  import { profileScopeLine } from './profile-lookup.js';
7
10
  import { stationOfAccount } from './read-tool.js';
8
11
  import { stationForTool, toolGroupOf } from './tool-catalog.js';
@@ -48,10 +51,13 @@ export function channelDecision(name: string, a: Record<string, unknown>): Polic
48
51
  return verdict;
49
52
  }
50
53
 
54
+ const isChannelTool = (name: string) => (tool: string): boolean => tool.endsWith(`__${name}`) && connectorToolOf(tool) === undefined;
55
+
51
56
  export function policyGate(name: string, a: Record<string, unknown>): ToolResult | undefined {
52
57
  const { access, target } = channelDecision(name, a);
53
- if (access !== 'deny' || target === undefined) return undefined;
54
- return errResult(`Blocked by the owner's policy for ${target.station} (${name}).`);
58
+ if (access === 'allow' || target === undefined) return undefined;
59
+ if (access === 'deny') return errResult(`Blocked by the owner's policy for ${target.station} (${name}).`);
60
+ return takeGrant(isChannelTool(name), a) ? undefined : errResult(NEEDS_APPROVAL(target.station, name));
55
61
  }
56
62
 
57
63
  export interface EffectivePolicy {
@@ -34,7 +34,9 @@ export const COMMON_TOOLS: ToolDef[] = [
34
34
  group: 'write',
35
35
  description:
36
36
  'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
37
- 'attachments?. The station is derived from the line. Each attachment names EXACTLY ONE ' +
37
+ 'subject?, attachments?. The station is derived from the line. Outlook: send to ' +
38
+ 'metro://outlook/<account>/<email address> to start a new email (subject defaults to the ' +
39
+ 'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
38
40
  'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
39
41
  'route for any real file on your own machine, confidential or not, up to 64 MiB); ' +
40
42
  '`data` (base64 inline -- TINY FILES ONLY, a few KB, because the base64 has to be ' +
@@ -55,6 +57,10 @@ export const COMMON_TOOLS: ToolDef[] = [
55
57
  type: 'string',
56
58
  description: 'Optional message_id to quote/reply to.',
57
59
  },
60
+ subject: {
61
+ type: 'string',
62
+ description: 'Outlook only: the subject of a new email.',
63
+ },
58
64
  attachments: {
59
65
  type: 'array',
60
66
  description: 'Optional files to attach.',
@@ -2,7 +2,7 @@ import { respond } from '@metro-labs/core/stations/station-runtime';
2
2
  import { TrainError } from '@metro-labs/core/train-error';
3
3
  import { accountOf, type Account } from './accounts.js';
4
4
  import { assertSendable, attachFile, filesOf, type OutgoingFile } from './attachments.js';
5
- import { conversationOfLine } from './format.js';
5
+ import { conversationOfLine, lineOf } from './format.js';
6
6
  import { graph, graphJson, jsonInit, messagePath, odataQuote, queryOf } from './graph.js';
7
7
  import { escapeHtml } from './html.js';
8
8
 
@@ -43,7 +43,46 @@ async function withFiles(acct: Account, target: string, verb: 'reply' | 'replyAl
43
43
  return labels;
44
44
  }
45
45
 
46
- async function answer(id: string, args: Args, verb: 'reply' | 'replyAll'): Promise<void> {
46
+ const ADDRESS_RE = /^[^@\s/]+@[^@\s/]+\.[^@\s/]+$/;
47
+ const SUBJECT_MAX = 255;
48
+
49
+ export const isAddress = (target: string): boolean => target.includes('@');
50
+
51
+ export function subjectOf(subject: string, text: string): string {
52
+ const chosen = subject !== '' ? subject : (text.split('\n').find((l) => l.trim() !== '') ?? '').trim();
53
+ if (chosen === '') throw new TrainError('outlook_subject_required', 'a new email needs a subject or some text', { retryable: false });
54
+ return chosen.length > SUBJECT_MAX ? `${chosen.slice(0, SUBJECT_MAX - 3)}...` : chosen;
55
+ }
56
+
57
+ async function compose(id: string, acct: Account, to: string, args: Args, text: string, files: OutgoingFile[]): Promise<void> {
58
+ if (!ADDRESS_RE.test(to)) throw new TrainError('outlook_bad_address', `not an email address: ${to}`, { retryable: false });
59
+ const draft = await graphJson<{ id?: string; conversationId?: string }>(acct, '/me/messages', jsonInit('POST', {
60
+ subject: subjectOf(str(args.subject), text),
61
+ body: { contentType: 'Text', content: text },
62
+ toRecipients: [{ emailAddress: { address: to } }],
63
+ }));
64
+ if (draft.id === undefined) throw new TrainError('outlook_graph_error', 'Outlook did not open a draft', { retryable: true });
65
+ const labels: string[] = [];
66
+ try {
67
+ for (const file of files) labels.push(await attachFile(acct, draft.id, file));
68
+ await graph(acct, `${messagePath(draft.id)}/send`, { method: 'POST' });
69
+ } catch (err) {
70
+ await discard(acct, draft.id);
71
+ throw err;
72
+ }
73
+ const line = draft.conversationId === undefined ? undefined : lineOf(acct.id, draft.conversationId);
74
+ respond(id, { result: { account: acct.id, messageId: draft.id, ...(line === undefined ? {} : { line }), ...(labels.length > 0 ? { attachments: labels } : {}) } });
75
+ }
76
+
77
+ interface Outgoing {
78
+ target: string;
79
+ acct: Account;
80
+ files: OutgoingFile[];
81
+ text: string;
82
+ replyTo: string;
83
+ }
84
+
85
+ async function outgoing(args: Args): Promise<Outgoing> {
47
86
  const line = str(args.line);
48
87
  const parsed = conversationOfLine(line);
49
88
  if (parsed === null) throw new TrainError('outlook_bad_line', `not an outlook line: ${line}`, { retryable: false });
@@ -53,8 +92,18 @@ async function answer(id: string, args: Args, verb: 'reply' | 'replyAll'): Promi
53
92
  if (text.trim() === '' && files.length === 0)
54
93
  throw new TrainError('outlook_text_required', 'give some text or a file to send', { retryable: false });
55
94
  await assertSendable(files);
56
- const replyTo = str(args.replyTo);
57
- const target = replyTo === '' ? await latestInConversation(acct, parsed.conversationId) : replyTo;
95
+ return { target: parsed.conversationId, acct, files, text, replyTo: str(args.replyTo) };
96
+ }
97
+
98
+ async function answer(id: string, args: Args, verb: 'reply' | 'replyAll'): Promise<void> {
99
+ const { target: conversation, acct, files, text, replyTo } = await outgoing(args);
100
+ if (isAddress(conversation)) {
101
+ if (verb === 'reply' || replyTo !== '')
102
+ throw new TrainError('outlook_reply_needs_thread', 'reply on the thread\'s own line; an address line starts a new email', { retryable: false });
103
+ await compose(id, acct, conversation.toLowerCase(), args, text, files);
104
+ return;
105
+ }
106
+ const target = replyTo === '' ? await latestInConversation(acct, conversation) : replyTo;
58
107
  const comment = escapeHtml(text);
59
108
  let labels: string[] = [];
60
109
  if (files.length === 0) await graph(acct, `${messagePath(target)}/${verb}`, jsonInit('POST', { comment }));
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.180"
2
+ "version": "0.1.0-beta.182"
3
3
  }