@stage-labs/metro 0.1.0-beta.177 → 0.1.0-beta.179
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/dist/onboarding.js +4 -3
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/guard.mjs +22 -6
- package/runtime/marketplace/plugin/bin/policy.mjs +97 -0
- package/runtime/marketplace/plugin/orchestrator.md +2 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/policy-route.ts +27 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/api.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +126 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +66 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +11 -38
- package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +13 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +34 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +63 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +9 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +46 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/attachment-schema.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +18 -21
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +87 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +42 -33
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +77 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +79 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-tool.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +88 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-def.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +20 -31
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +20 -91
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +118 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +10 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +6 -0
- package/runtime/runtime.json +1 -1
package/dist/onboarding.js
CHANGED
|
@@ -28,16 +28,17 @@ export function markOnboardingDone(path = claudeConfigPath()) {
|
|
|
28
28
|
return 'marked';
|
|
29
29
|
}
|
|
30
30
|
const FEATURES = 'cachedGrowthBookFeatures';
|
|
31
|
-
const
|
|
31
|
+
const CHANNEL_FLAGS = ['tengu_harbor', 'tengu_harbor_permissions'];
|
|
32
32
|
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
33
33
|
export function seedChannels(path = claudeConfigPath()) {
|
|
34
34
|
const config = readConfig(path);
|
|
35
35
|
if (config === null)
|
|
36
36
|
return 'unreadable';
|
|
37
37
|
const features = isRecord(config[FEATURES]) ? config[FEATURES] : {};
|
|
38
|
-
if (features[
|
|
38
|
+
if (CHANNEL_FLAGS.every((flag) => features[flag] === true))
|
|
39
39
|
return 'already';
|
|
40
|
-
const
|
|
40
|
+
const seeded = Object.fromEntries(CHANNEL_FLAGS.map((flag) => [flag, true]));
|
|
41
|
+
const next = { ...config, [FEATURES]: { ...features, ...seeded } };
|
|
41
42
|
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
42
43
|
return 'marked';
|
|
43
44
|
}
|
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.179",
|
|
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.179",
|
|
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,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { policyVerdict } from './policy.mjs';
|
|
3
4
|
|
|
4
5
|
const BLOCKING = new Set(['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode']);
|
|
5
6
|
const ORCHESTRATION = new Set([
|
|
@@ -23,15 +24,26 @@ const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
|
23
24
|
const MCP = /^mcp__/;
|
|
24
25
|
const METRO_MCP = /^mcp__metro__/;
|
|
25
26
|
|
|
26
|
-
function
|
|
27
|
+
function answer(decision, reason) {
|
|
27
28
|
process.stdout.write(
|
|
28
|
-
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision:
|
|
29
|
+
`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: reason } })}\n`,
|
|
29
30
|
);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function
|
|
33
|
+
function ownerPolicy(tool, input, subagent) {
|
|
34
|
+
const verdict = policyVerdict(tool, input);
|
|
35
|
+
if (verdict === null) return null;
|
|
36
|
+
const { where } = verdict;
|
|
37
|
+
if (verdict.access === 'deny') return { decision: 'deny', reason: `Blocked by the owner's policy for ${verdict.owner} (${verdict.tool}).` };
|
|
38
|
+
if (subagent) return { decision: 'ask', reason: `The owner asked to approve ${where}.` };
|
|
39
|
+
return {
|
|
40
|
+
decision: 'deny',
|
|
41
|
+
reason: `${where} needs the owner's approval, and waiting for it here would stop the whole session. Run this exact call from a background worker (Agent, run_in_background: true): the worker waits for the owner's answer while you keep answering.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function verdict(payload, input) {
|
|
33
46
|
const tool = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
34
|
-
const input = payload.tool_input !== null && typeof payload.tool_input === 'object' ? payload.tool_input : {};
|
|
35
47
|
if (BLOCKING.has(tool))
|
|
36
48
|
return `${tool} blocks the session waiting on the terminal, and nobody is watching it. Decide with your best judgement and state the assumption, or ask over chat with the metro tools and keep working in the meantime.`;
|
|
37
49
|
if (typeof payload.agent_id === 'string' && payload.agent_id !== '') return null;
|
|
@@ -51,5 +63,9 @@ try {
|
|
|
51
63
|
} catch {
|
|
52
64
|
payload = {};
|
|
53
65
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
66
|
+
const shaped = payload !== null && typeof payload === 'object' ? payload : {};
|
|
67
|
+
const input = shaped.tool_input !== null && typeof shaped.tool_input === 'object' ? shaped.tool_input : {};
|
|
68
|
+
const reason = verdict(shaped, input);
|
|
69
|
+
const policy = reason === null && typeof shaped.tool_name === 'string' ? ownerPolicy(shaped.tool_name, input, typeof shaped.agent_id === 'string' && shaped.agent_id !== '') : null;
|
|
70
|
+
if (reason !== null) answer('deny', reason);
|
|
71
|
+
else if (policy !== null) answer(policy.decision, policy.reason);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const METRO_TOOL = /^mcp__metro__(.+)$/;
|
|
6
|
+
const RANK = { allow: 0, ask: 1, deny: 2 };
|
|
7
|
+
const LINE_FIELD = { get_profile: 'from' };
|
|
8
|
+
const STATION_ARG = new Set(['create_group', 'set_profile']);
|
|
9
|
+
|
|
10
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
11
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
12
|
+
const agentsDir = () => process.env.METRO_AGENTS_DIR?.trim() || join(homedir(), '.metro', 'agents');
|
|
13
|
+
|
|
14
|
+
function readSnapshot() {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(join(agentsDir(), 'policy.json'), 'utf8'));
|
|
17
|
+
return isRecord(parsed) && parsed.version === 1 ? parsed : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fromLine = (line, override) => {
|
|
24
|
+
const parts = line.split('/');
|
|
25
|
+
return line.startsWith('metro://') && parts[2] && parts[3] ? [{ station: parts[2], account: override || parts[3] }] : [];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function stationOfAccount(snap, account) {
|
|
29
|
+
const hits = Object.entries(isRecord(snap.stations) ? snap.stations : {}).filter(
|
|
30
|
+
([, ids]) => Array.isArray(ids) && ids.includes(account),
|
|
31
|
+
);
|
|
32
|
+
return hits.length === 1 ? hits[0][0] : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function targets(snap, name, input) {
|
|
36
|
+
if (Array.isArray(snap.ungated) && snap.ungated.includes(name)) return [];
|
|
37
|
+
const account = text(input.account);
|
|
38
|
+
const line = text(input[LINE_FIELD[name] ?? 'line']);
|
|
39
|
+
if (line) return fromLine(line, name in LINE_FIELD ? '' : account);
|
|
40
|
+
const owners = isRecord(snap.owners) ? snap.owners : {};
|
|
41
|
+
const station = (STATION_ARG.has(name) ? text(input.station) : text(owners[name])) || (account ? stationOfAccount(snap, account) : undefined);
|
|
42
|
+
if (!station) return [];
|
|
43
|
+
if (account) return [{ station, account }];
|
|
44
|
+
const ids = isRecord(snap.stations) && Array.isArray(snap.stations[station]) ? snap.stations[station] : [];
|
|
45
|
+
return ids.map((id) => ({ station, account: id }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function decide(policy, name, group) {
|
|
49
|
+
if (!isRecord(policy)) return 'allow';
|
|
50
|
+
const byTool = isRecord(policy.tools) ? policy.tools[name] : undefined;
|
|
51
|
+
const access = byTool ?? policy[group] ?? 'allow';
|
|
52
|
+
return access in RANK ? access : 'allow';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
56
|
+
|
|
57
|
+
function connectorCall(snap, toolName) {
|
|
58
|
+
for (const [server, entry] of Object.entries(isRecord(snap.connectors) ? snap.connectors : {})) {
|
|
59
|
+
if (!isRecord(entry)) continue;
|
|
60
|
+
const name = text(entry.name);
|
|
61
|
+
const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`, `mcp__${normalized(`metro.box ${name}`)}__`];
|
|
62
|
+
const prefix = prefixes.find((p) => toolName.startsWith(p) && toolName.length > p.length);
|
|
63
|
+
if (prefix === undefined) continue;
|
|
64
|
+
const called = toolName.slice(prefix.length);
|
|
65
|
+
const tools = isRecord(entry.tools) ? entry.tools : {};
|
|
66
|
+
const policy = isRecord(entry.policy) ? entry.policy : {};
|
|
67
|
+
const known = [...Object.keys(tools), ...Object.keys(isRecord(policy.tools) ? policy.tools : {})];
|
|
68
|
+
const tool = known.find((k) => k === called) ?? known.find((k) => normalized(k) === called) ?? called;
|
|
69
|
+
return { name: name || server, tool, group: tools[tool] === 'read' ? 'read' : 'write', policy };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function connectorVerdict(snap, toolName) {
|
|
75
|
+
const call = connectorCall(snap, toolName);
|
|
76
|
+
if (call === null) return null;
|
|
77
|
+
const access = decide(call.policy, call.tool, call.group);
|
|
78
|
+
return access === 'allow' ? null : { access, tool: call.tool, owner: call.name, where: `${call.tool} on ${call.name}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function policyVerdict(toolName, input) {
|
|
82
|
+
const name = METRO_TOOL.exec(toolName)?.[1];
|
|
83
|
+
if (name === undefined && !toolName.startsWith('mcp__')) return null;
|
|
84
|
+
const snap = readSnapshot();
|
|
85
|
+
if (snap === null) return null;
|
|
86
|
+
if (name === undefined) return connectorVerdict(snap, toolName);
|
|
87
|
+
const tools = isRecord(snap.tools) ? snap.tools : {};
|
|
88
|
+
const group = tools[name] === 'read' ? 'read' : 'write';
|
|
89
|
+
const accounts = isRecord(snap.accounts) ? snap.accounts : {};
|
|
90
|
+
let verdict = null;
|
|
91
|
+
for (const target of targets(snap, name, input)) {
|
|
92
|
+
const access = decide(accounts[`${target.station}/${target.account}`], name, group);
|
|
93
|
+
if (access !== 'allow' && (verdict === null || RANK[access] > RANK[verdict.access]))
|
|
94
|
+
verdict = { access, tool: name, owner: target.station, where: `${name} on ${target.station} (${target.account})` };
|
|
95
|
+
}
|
|
96
|
+
return verdict;
|
|
97
|
+
}
|
|
@@ -11,6 +11,8 @@ You are the main thread of a Claude Code session that is connected to chat throu
|
|
|
11
11
|
|
|
12
12
|
**Never wait on the terminal.** `AskUserQuestion`, plan mode and anything that blocks for a person at the keyboard are denied everywhere, main thread and subagents alike. When something is ambiguous, do what does not depend on it, choose the most reasonable reading, say so in your reply, and if a real decision is needed ask over chat with `send` and carry on. Tool-approval prompts reach the chat too; the person answers `yes <id>` or `no <id>` there.
|
|
13
13
|
|
|
14
|
+
**Calls that need the owner's approval run in a worker.** The owner can make a metro tool on a channel wait for approval. On the main thread such a call is refused, because waiting for the answer would stop the whole session: hand that exact call to a background worker (`run_in_background: true`) and keep answering. The worker waits for the owner's answer on its own.
|
|
15
|
+
|
|
14
16
|
**Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
|
|
15
17
|
|
|
16
18
|
The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
|
|
@@ -34,10 +34,14 @@ export interface ToolContext {
|
|
|
34
34
|
readFile(path: string): Promise<Buffer>;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export type ToolGroup = 'read' | 'write';
|
|
38
|
+
|
|
37
39
|
export interface StationTool {
|
|
38
40
|
name: string;
|
|
39
41
|
description: string;
|
|
40
42
|
inputSchema: Record<string, unknown>;
|
|
43
|
+
group?: ToolGroup;
|
|
44
|
+
destructive?: boolean;
|
|
41
45
|
handle(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
|
|
42
46
|
}
|
|
43
47
|
|
|
@@ -11,6 +11,7 @@ export type AccountRoute =
|
|
|
11
11
|
| { kind: 'senders'; station: StationName; accountId: string }
|
|
12
12
|
| { kind: 'resolve'; station: StationName; accountId: string }
|
|
13
13
|
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
|
+
| { kind: 'policy'; station: StationName; accountId: string }
|
|
14
15
|
| { kind: 'name'; station: StationName; accountId: string };
|
|
15
16
|
|
|
16
17
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
@@ -22,10 +23,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
22
23
|
senders: ['GET'],
|
|
23
24
|
resolve: ['GET'],
|
|
24
25
|
enabled: ['PUT'],
|
|
26
|
+
policy: ['PUT'],
|
|
25
27
|
name: ['GET', 'POST'],
|
|
26
28
|
};
|
|
27
29
|
|
|
28
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name'] as const;
|
|
30
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy'] as const;
|
|
29
31
|
|
|
30
32
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
31
33
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@metro-labs/http/api-http';
|
|
12
12
|
import { type AccountRef } from './account-attach.js';
|
|
13
13
|
import { type AccountRoute } from './account-routes.js';
|
|
14
|
+
import { handlePolicy, type SetPolicy } from './policy-route.js';
|
|
14
15
|
import { stationByName } from '../stations/registry.js';
|
|
15
16
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
16
17
|
import {
|
|
@@ -68,6 +69,7 @@ export interface AccountApiDeps {
|
|
|
68
69
|
accountId: string,
|
|
69
70
|
allowlist: string[],
|
|
70
71
|
) => Promise<string[]>;
|
|
72
|
+
setPolicy: SetPolicy;
|
|
71
73
|
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
72
74
|
resolveSender: (
|
|
73
75
|
station: StationName,
|
|
@@ -175,7 +177,7 @@ async function storeAccount(
|
|
|
175
177
|
|
|
176
178
|
function asInput(body: unknown): Record<string, unknown> {
|
|
177
179
|
const out: Record<string, unknown> = {};
|
|
178
|
-
for (const key of ['apiId', 'apiHash', 'phone'])
|
|
180
|
+
for (const key of ['apiId', 'apiHash', 'phone', 'mailbox'])
|
|
179
181
|
out[key] = bodyField(body, key);
|
|
180
182
|
return out;
|
|
181
183
|
}
|
|
@@ -365,6 +367,7 @@ async function dispatchRoute(
|
|
|
365
367
|
return handleStep(req, res, deps, { agentId }, route.attachId);
|
|
366
368
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
367
369
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
370
|
+
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
368
371
|
if (route.kind === 'resolve') return handleResolve(req, res, deps, route);
|
|
369
372
|
if (route.kind === 'name') return handleName(req, res, deps, route);
|
|
370
373
|
if (route.kind === 'senders') {
|
|
@@ -23,6 +23,7 @@ export interface AgentApiDeps extends AccountApiDeps {
|
|
|
23
23
|
unavailable: string[];
|
|
24
24
|
}>;
|
|
25
25
|
capabilities: () => Record<string, string[]>;
|
|
26
|
+
toolGroups?: () => Record<string, { name: string; group: string }[]>;
|
|
26
27
|
attachable?: string[];
|
|
27
28
|
connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
|
|
28
29
|
}
|
|
@@ -69,6 +70,7 @@ async function handleList(
|
|
|
69
70
|
agents: list.map((a) => agentPayload(a, connectors)),
|
|
70
71
|
capabilities: deps.capabilities(),
|
|
71
72
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
73
|
+
...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
|
|
72
74
|
};
|
|
73
75
|
if (!wantsAccounts(req)) {
|
|
74
76
|
sendJson(req, res, 200, base);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parsePolicy } from '../policy/policy.js';
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
4
|
import { apiFailure, apiSession, cors, readJsonBody, requireAdmin, sendJson } from '@metro-labs/http/api-http';
|
|
@@ -54,7 +55,15 @@ function stationOf(raw: unknown): LoadedAccount {
|
|
|
54
55
|
const allowlist = Array.isArray(raw.allowlist)
|
|
55
56
|
? raw.allowlist.filter((s): s is string => typeof s === 'string')
|
|
56
57
|
: null;
|
|
57
|
-
|
|
58
|
+
const policy = parsePolicy(raw.policy, `bundle ${raw.station}/${raw.id}`);
|
|
59
|
+
return {
|
|
60
|
+
station: raw.station as LoadedAccount['station'],
|
|
61
|
+
id: raw.id,
|
|
62
|
+
allowlist,
|
|
63
|
+
enabled: raw.enabled !== false,
|
|
64
|
+
...(policy === undefined ? {} : { policy }),
|
|
65
|
+
config: raw.config,
|
|
66
|
+
};
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
function connectorOf(raw: unknown): LoadedConnector {
|
|
@@ -24,6 +24,7 @@ import { newId } from '@metro-labs/core/ids';
|
|
|
24
24
|
import { registerKey } from './keys.js';
|
|
25
25
|
import { MOVABLE_STATIONS, type LoadedAgent } from '../stations/materialize.js';
|
|
26
26
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
27
|
+
import type { ToolPolicy } from '../policy/policy.js';
|
|
27
28
|
|
|
28
29
|
const OWNER_FILE = '.owner';
|
|
29
30
|
|
|
@@ -252,6 +253,22 @@ export async function localSetAllowlist(
|
|
|
252
253
|
return Promise.resolve(allowlist);
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
export async function localSetPolicy(
|
|
257
|
+
agentId: string,
|
|
258
|
+
station: StationName,
|
|
259
|
+
accountId: string,
|
|
260
|
+
policy: ToolPolicy,
|
|
261
|
+
dir = agentsDir(),
|
|
262
|
+
): Promise<ToolPolicy> {
|
|
263
|
+
const stored = agentOrThrow(agentId, dir);
|
|
264
|
+
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
265
|
+
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
266
|
+
if (policy.read === undefined && policy.write === undefined && policy.tools === undefined) delete account.policy;
|
|
267
|
+
else account.policy = policy;
|
|
268
|
+
save(stored);
|
|
269
|
+
return Promise.resolve(policy);
|
|
270
|
+
}
|
|
271
|
+
|
|
255
272
|
export async function localSetAccountEnabled(
|
|
256
273
|
agentId: string,
|
|
257
274
|
station: StationName,
|
|
@@ -6,6 +6,7 @@ import { AGENT_NAME_RE, ID_RE } from '@metro-labs/core/ids';
|
|
|
6
6
|
import { STATIONS, type StationName } from '@metro-labs/core/station-names';
|
|
7
7
|
import type { LoadedAccount, LoadedAgent, StationSource } from '../stations/materialize.js';
|
|
8
8
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
9
|
+
import { parsePolicy } from '../policy/policy.js';
|
|
9
10
|
|
|
10
11
|
export const AGENT_FILE = 'agent.json';
|
|
11
12
|
|
|
@@ -56,7 +57,15 @@ function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
|
|
|
56
57
|
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
57
58
|
fail(path, `${where}.id is not an 11-character id`);
|
|
58
59
|
if (!isRecord(config)) fail(path, `${where}.config is not an object`);
|
|
59
|
-
|
|
60
|
+
const policy = parsePolicy(raw.policy, `${path} ${where}`);
|
|
61
|
+
return {
|
|
62
|
+
station,
|
|
63
|
+
id,
|
|
64
|
+
allowlist: allowlistOf(raw.allowlist, path, where),
|
|
65
|
+
enabled: raw.enabled !== false,
|
|
66
|
+
...(policy === undefined ? {} : { policy }),
|
|
67
|
+
config,
|
|
68
|
+
};
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
function optionalMatch(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
4
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
5
|
+
import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
|
|
6
|
+
|
|
7
|
+
export type SetPolicy = (agentId: string, station: StationName, accountId: string, policy: ToolPolicy) => Promise<ToolPolicy>;
|
|
8
|
+
|
|
9
|
+
export async function handlePolicy(
|
|
10
|
+
req: IncomingMessage,
|
|
11
|
+
res: ServerResponse,
|
|
12
|
+
deps: { setPolicy: SetPolicy; reloadAgents: () => Promise<void> },
|
|
13
|
+
agentId: string,
|
|
14
|
+
target: { station: StationName; accountId: string },
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const wanted = normalizePolicy(bodyField(await readJsonBody(req), 'policy'));
|
|
17
|
+
const policy = await deps.setPolicy(agentId, target.station, target.accountId, wanted);
|
|
18
|
+
log.info({ agentId, station: target.station, account: target.accountId, policy }, 'account-api: tool policy set');
|
|
19
|
+
const activated = await deps.reloadAgents().then(
|
|
20
|
+
() => true,
|
|
21
|
+
(err: unknown) => {
|
|
22
|
+
log.warn({ err: errMsg(err) }, 'account-api: policy reload failed, the change lands at the next boot');
|
|
23
|
+
return false;
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
sendJson(req, res, 200, { agentId, station: target.station, accountId: target.accountId, policy, activated });
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import { apiFailure, apiSession, bodyField, cors, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
4
|
+
import { answerPrompt, pendingPrompts, type Behavior, type PendingPrompt } from './pending.js';
|
|
5
|
+
|
|
6
|
+
const PREFIX = '/api/approvals';
|
|
7
|
+
const ID_RE = /^[a-km-z]{5}$/;
|
|
8
|
+
|
|
9
|
+
const approvalView = (p: PendingPrompt): Record<string, unknown> => ({
|
|
10
|
+
id: p.requestId,
|
|
11
|
+
tool: p.tool,
|
|
12
|
+
description: p.description,
|
|
13
|
+
preview: p.preview,
|
|
14
|
+
line: p.line ?? null,
|
|
15
|
+
requestedAt: new Date(p.at).toISOString(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function decisionOf(body: unknown): Behavior {
|
|
19
|
+
const value = bodyField(body, 'decision');
|
|
20
|
+
if (value !== 'allow' && value !== 'deny') throw new ApiError("decision is 'allow' or 'deny'", 400);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function answer(req: IncomingMessage, id: string | undefined, subject: string): Promise<unknown> {
|
|
25
|
+
if (id === undefined) {
|
|
26
|
+
if (req.method !== 'GET') throw new ApiError('method not allowed', 405);
|
|
27
|
+
return { approvals: pendingPrompts().map(approvalView) };
|
|
28
|
+
}
|
|
29
|
+
if (req.method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
30
|
+
if (!ID_RE.test(id)) throw new ApiError('no such approval', 404);
|
|
31
|
+
const decision = decisionOf(await readJsonBody(req));
|
|
32
|
+
const answered = await answerPrompt(id, decision, 'page', subject);
|
|
33
|
+
if (answered === undefined) throw new ApiError('no such approval', 404);
|
|
34
|
+
return { approval: approvalView(answered), decision };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleApprovalsRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
|
38
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
39
|
+
if (path !== PREFIX && !path.startsWith(`${PREFIX}/`)) return false;
|
|
40
|
+
const rest = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
41
|
+
if (rest.length > 1) return false;
|
|
42
|
+
if (req.method === 'OPTIONS') {
|
|
43
|
+
res.writeHead(204, cors(req)).end();
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
apiSession(req)
|
|
47
|
+
.then(async (session) => {
|
|
48
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
49
|
+
sendJson(req, res, 200, await answer(req, rest[0], session.subject));
|
|
50
|
+
})
|
|
51
|
+
.catch((err: unknown) => {
|
|
52
|
+
apiFailure(req, res, err, 'approvals-api');
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { previewMatches } from './preview.js';
|
|
3
|
+
|
|
4
|
+
export type Behavior = 'allow' | 'deny';
|
|
5
|
+
|
|
6
|
+
export type AnsweredVia = 'chat' | 'page' | 'expiry';
|
|
7
|
+
|
|
8
|
+
export interface PendingPrompt {
|
|
9
|
+
requestId: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
description: string;
|
|
12
|
+
preview: string;
|
|
13
|
+
line: string | undefined;
|
|
14
|
+
at: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Held extends PendingPrompt {
|
|
18
|
+
owner: object;
|
|
19
|
+
send: (behavior: Behavior) => Promise<void>;
|
|
20
|
+
tell: ((text: string) => Promise<void>) | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PENDING_MAX = 500;
|
|
24
|
+
const SWEEP_MS = 60_000;
|
|
25
|
+
const DEFAULT_TTL_H = 24;
|
|
26
|
+
const HOUR_MS = 3_600_000;
|
|
27
|
+
|
|
28
|
+
const held = new Map<string, Held>();
|
|
29
|
+
|
|
30
|
+
export function approvalTtlMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
31
|
+
const hours = Number(env.METRO_APPROVAL_TTL_H);
|
|
32
|
+
return (Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_TTL_H) * HOUR_MS;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const shown = ({ requestId, tool, description, preview, line, at }: Held): PendingPrompt => ({
|
|
36
|
+
requestId,
|
|
37
|
+
tool,
|
|
38
|
+
description,
|
|
39
|
+
preview,
|
|
40
|
+
line,
|
|
41
|
+
at,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function holdPrompt(
|
|
45
|
+
prompt: PendingPrompt,
|
|
46
|
+
owner: object,
|
|
47
|
+
send: (behavior: Behavior) => Promise<void>,
|
|
48
|
+
tell?: (text: string) => Promise<void>,
|
|
49
|
+
): void {
|
|
50
|
+
held.set(prompt.requestId, { ...prompt, owner, send, tell });
|
|
51
|
+
while (held.size > PENDING_MAX) {
|
|
52
|
+
const oldest = held.keys().next();
|
|
53
|
+
if (oldest.done) break;
|
|
54
|
+
held.delete(oldest.value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const pendingPrompts = (): PendingPrompt[] => [...held.values()].map(shown).sort((a, b) => b.at - a.at);
|
|
59
|
+
|
|
60
|
+
export const promptLine = (requestId: string): string | undefined => held.get(requestId)?.line;
|
|
61
|
+
|
|
62
|
+
function chatNotice(via: AnsweredVia, behavior: Behavior): string | undefined {
|
|
63
|
+
if (via === 'expiry') return 'Expired, denied.';
|
|
64
|
+
if (via === 'page') return behavior === 'allow' ? 'Approved on the page.' : 'Denied on the page.';
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function tellChat(entry: Held, via: AnsweredVia, behavior: Behavior): Promise<void> {
|
|
69
|
+
const text = chatNotice(via, behavior);
|
|
70
|
+
if (text === undefined || entry.tell === undefined) return;
|
|
71
|
+
await entry.tell(text).catch((err: unknown) => {
|
|
72
|
+
log.warn({ requestId: entry.requestId, err: errMsg(err) }, 'approvals: the chat could not be told the answer');
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function answerPrompt(
|
|
77
|
+
requestId: string,
|
|
78
|
+
behavior: Behavior,
|
|
79
|
+
via: AnsweredVia,
|
|
80
|
+
who: string = via,
|
|
81
|
+
): Promise<PendingPrompt | undefined> {
|
|
82
|
+
const entry = held.get(requestId);
|
|
83
|
+
if (entry === undefined) return undefined;
|
|
84
|
+
held.delete(requestId);
|
|
85
|
+
log.info({ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` }, 'approvals: a Claude Code permission prompt answered');
|
|
86
|
+
await entry.send(behavior);
|
|
87
|
+
await tellChat(entry, via, behavior);
|
|
88
|
+
return shown(entry);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function forgetPromptsOf(owner: object): void {
|
|
92
|
+
for (const [id, entry] of held) if (entry.owner === owner) held.delete(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function settlePromptsFor(name: string, args: Record<string, unknown>): void {
|
|
96
|
+
for (const [id, entry] of held)
|
|
97
|
+
if (entry.tool.endsWith(`__${name}`) && previewMatches(entry.preview, args)) {
|
|
98
|
+
held.delete(id);
|
|
99
|
+
log.info({ requestId: id, tool: entry.tool }, 'approvals: the call ran, so the prompt was answered in Claude Code');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function expirePrompts(now = Date.now(), ttl = approvalTtlMs()): Promise<number> {
|
|
104
|
+
const overdue = [...held.values()].filter((entry) => now - entry.at >= ttl);
|
|
105
|
+
for (const entry of overdue)
|
|
106
|
+
await answerPrompt(entry.requestId, 'deny', 'expiry').catch((err: unknown) => {
|
|
107
|
+
log.warn({ requestId: entry.requestId, err: errMsg(err) }, 'approvals: the expiry answer could not be sent');
|
|
108
|
+
});
|
|
109
|
+
return overdue.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function startPromptExpiry(): () => void {
|
|
113
|
+
const timer = setInterval(() => {
|
|
114
|
+
expirePrompts().catch((err: unknown) => {
|
|
115
|
+
log.warn({ err: errMsg(err) }, 'approvals: the expiry sweep failed');
|
|
116
|
+
});
|
|
117
|
+
}, SWEEP_MS);
|
|
118
|
+
timer.unref();
|
|
119
|
+
return () => {
|
|
120
|
+
clearInterval(timer);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function forgetAllPrompts(): void {
|
|
125
|
+
held.clear();
|
|
126
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
|
|
4
|
+
export const ELIDED = '\u0000';
|
|
5
|
+
|
|
6
|
+
const CODE_POINTS = /(?:\\n|\n)⋯ \d+ code points elided ⋯(?:\\n|\n)/g;
|
|
7
|
+
const FIELD_BLOCK = /[\s,]*(?:\\n|\n)?\s*⋯[^]*$/;
|
|
8
|
+
|
|
9
|
+
export interface ReadPreview {
|
|
10
|
+
input: Record<string, unknown> | undefined;
|
|
11
|
+
partial: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parsedRecord(text: string): Record<string, unknown> | undefined {
|
|
15
|
+
try {
|
|
16
|
+
const parsed: unknown = JSON.parse(text);
|
|
17
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readPreview(preview: string): ReadPreview {
|
|
24
|
+
const text = preview.replace(CODE_POINTS, '\\u0000');
|
|
25
|
+
const whole = parsedRecord(text);
|
|
26
|
+
if (whole !== undefined) return { input: whole, partial: false };
|
|
27
|
+
if (!text.includes('⋯')) return { input: undefined, partial: false };
|
|
28
|
+
const head = text.replace(FIELD_BLOCK, '').replace(/[\s,]*$/, '');
|
|
29
|
+
const input = parsedRecord(`${head}}`) ?? parsedRecord(head);
|
|
30
|
+
return { input, partial: input !== undefined };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const shownValue = (text: string): string => text.split(ELIDED).join('…');
|
|
34
|
+
|
|
35
|
+
function stringMatches(shown: string, real: string): boolean {
|
|
36
|
+
if (!shown.includes(ELIDED)) return shown === real;
|
|
37
|
+
const [first = '', ...rest] = shown.split(ELIDED);
|
|
38
|
+
const last = rest.pop() ?? '';
|
|
39
|
+
if (!real.startsWith(first) || !real.endsWith(last)) return false;
|
|
40
|
+
let at = first.length;
|
|
41
|
+
for (const part of rest) {
|
|
42
|
+
const found = real.indexOf(part, at);
|
|
43
|
+
if (found < 0) return false;
|
|
44
|
+
at = found + part.length;
|
|
45
|
+
}
|
|
46
|
+
return at <= real.length - last.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function valueMatches(shown: unknown, real: unknown): boolean {
|
|
50
|
+
if (typeof shown === 'string') return typeof real === 'string' && stringMatches(shown, real);
|
|
51
|
+
if (Array.isArray(shown))
|
|
52
|
+
return Array.isArray(real) && shown.length === real.length && shown.every((v, i) => valueMatches(v, real[i]));
|
|
53
|
+
if (isRecord(shown)) return isRecord(real) && recordMatches(shown, real, false);
|
|
54
|
+
return isDeepStrictEqual(shown, real);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function recordMatches(shown: Record<string, unknown>, real: Record<string, unknown>, partial: boolean): boolean {
|
|
58
|
+
const keys = Object.keys(shown);
|
|
59
|
+
if (!partial && keys.length !== Object.keys(real).length) return false;
|
|
60
|
+
return keys.every((key) => key in real && valueMatches(shown[key], real[key]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function previewMatches(preview: string, args: Record<string, unknown>): boolean {
|
|
64
|
+
const { input, partial } = readPreview(preview);
|
|
65
|
+
return input !== undefined && recordMatches(input, args, partial);
|
|
66
|
+
}
|