@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/needs.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +11 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +7 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +8 -2
- 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.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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
package/runtime/runtime.json
CHANGED