@stage-labs/metro 0.1.0-beta.181 → 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.181",
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.181",
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",
@@ -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 {
@@ -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 {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.181"
2
+ "version": "0.1.0-beta.182"
3
3
  }