@pugi/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -40
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +73 -0
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +111 -18
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/budgets.js +89 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +744 -210
- package/dist/core/engine/prompts.js +61 -6
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +818 -31
- package/dist/core/file-cache.js +113 -1
- package/dist/core/init/scaffold.js +195 -0
- package/dist/core/lsp/client.js +174 -29
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/codebase-survey.js +308 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/init-interview.js +457 -0
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/onboarding-state.js +297 -0
- package/dist/core/repl/session.js +719 -29
- package/dist/core/repl/slash-commands.js +133 -9
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/settings.js +71 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +1588 -266
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +289 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/lsp.js +187 -5
- package/dist/runtime/commands/mcp.js +824 -0
- package/dist/runtime/commands/patch.js +17 -0
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/roster.js +117 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/commands/worktree.js +50 -6
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +206 -0
- package/dist/tools/apply-patch.js +281 -39
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/registry.js +22 -2
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +69 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/input-box.js +1 -1
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/repl-render.js +276 -37
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +25 -6
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/tool-stream-pane.js +7 -0
- package/dist/tui/update-banner.js +20 -2
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +9 -6
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
/**
|
|
3
|
+
* Pugi MCP server (β4 M2) — exposes Pugi's native tool surface to other
|
|
4
|
+
* agents (Claude Code, OpenCode, Codex CLI, any client that speaks
|
|
5
|
+
* MCP).
|
|
6
|
+
*
|
|
7
|
+
* Transport-agnostic core. The stdio entry-point lives at the bottom of
|
|
8
|
+
* this module (`serveStdio`); the HTTP+SSE wrapper lives in
|
|
9
|
+
* `./http-server.ts` and feeds the same core via the synchronous
|
|
10
|
+
* `handleMessage` entry-point.
|
|
11
|
+
*
|
|
12
|
+
* Spec: https://modelcontextprotocol.io/specification (2024-11-05).
|
|
13
|
+
*
|
|
14
|
+
* Methods implemented:
|
|
15
|
+
* - `initialize` -> protocol handshake, server capabilities
|
|
16
|
+
* - `notifications/initialized` -> ack, no-op
|
|
17
|
+
* - `tools/list` -> Pugi tool schemas
|
|
18
|
+
* - `tools/call` -> dispatch to the underlying executor
|
|
19
|
+
* - `ping` -> liveness, returns `{}` (some MCP clients
|
|
20
|
+
* poll this on a HTTP transport)
|
|
21
|
+
*
|
|
22
|
+
* NOT yet implemented (deferred — these are not on the β4 acceptance
|
|
23
|
+
* surface):
|
|
24
|
+
* - `resources/*` — file resource browser
|
|
25
|
+
* - `prompts/*` — server-supplied prompts
|
|
26
|
+
* - `sampling/*` — agent sampling callbacks
|
|
27
|
+
* - `notifications/cancelled` — client-side cancellation (we honour
|
|
28
|
+
* the AbortSignal passed at construction
|
|
29
|
+
* instead)
|
|
30
|
+
*
|
|
31
|
+
* Why hand-rolled instead of `@modelcontextprotocol/sdk`:
|
|
32
|
+
* - The SDK ships every transport (stdio, HTTP, WebSocket, SSE) and
|
|
33
|
+
* drags in a 3 MB compressed dependency footprint. Pugi-CLI ships as
|
|
34
|
+
* `npm i -g pugi` and every transitive dep is supply-chain risk.
|
|
35
|
+
* - The core JSON-RPC envelope is <500 LOC and we already maintain the
|
|
36
|
+
* client-side variant in `./client.ts` — keeping the server matching
|
|
37
|
+
* hand-roll lets a code reviewer hold both sides of the wire in one
|
|
38
|
+
* head.
|
|
39
|
+
* - Pugi-specific extensions (permission FSM hooks on `tools/call`,
|
|
40
|
+
* bearer-auth attestation on HTTP, scope-limited tool filtering for
|
|
41
|
+
* paired-agent worktrees) drop in cleanly because we own the
|
|
42
|
+
* dispatch table.
|
|
43
|
+
*/
|
|
44
|
+
/* ---------- protocol types (mirror client.ts conventions) ----------------- */
|
|
45
|
+
export const PUGI_MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
46
|
+
export const PUGI_MCP_SERVER_NAME = 'pugi';
|
|
47
|
+
export const PUGI_MCP_SERVER_VERSION = '0.1.0';
|
|
48
|
+
/** JSON-RPC standard error codes used by the server. */
|
|
49
|
+
export const MCP_ERROR_CODES = Object.freeze({
|
|
50
|
+
PARSE_ERROR: -32700,
|
|
51
|
+
INVALID_REQUEST: -32600,
|
|
52
|
+
METHOD_NOT_FOUND: -32601,
|
|
53
|
+
INVALID_PARAMS: -32602,
|
|
54
|
+
INTERNAL_ERROR: -32603,
|
|
55
|
+
/**
|
|
56
|
+
* Pugi-specific: operator-side permission gate refused this tool call.
|
|
57
|
+
* Distinct from generic INTERNAL_ERROR so MCP clients can surface a
|
|
58
|
+
* "permission denied" status to their user.
|
|
59
|
+
*/
|
|
60
|
+
PERMISSION_REFUSED: -32001,
|
|
61
|
+
/**
|
|
62
|
+
* Pugi-specific: HTTP transport saw an invalid or missing bearer
|
|
63
|
+
* token. Stdio transport never emits this code (no auth).
|
|
64
|
+
*/
|
|
65
|
+
AUTH_REQUIRED: -32002,
|
|
66
|
+
});
|
|
67
|
+
export class McpServerToolError extends Error {
|
|
68
|
+
code;
|
|
69
|
+
constructor(message, code = MCP_ERROR_CODES.INTERNAL_ERROR) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = 'McpServerToolError';
|
|
72
|
+
this.code = code;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Build a Pugi MCP server bound to a fixed tool surface. Stateless
|
|
77
|
+
* between requests — the same instance can drive many concurrent stdio
|
|
78
|
+
* connections (one per child process) without cross-talk.
|
|
79
|
+
*/
|
|
80
|
+
export function createPugiMcpServer(options) {
|
|
81
|
+
const events = new EventEmitter();
|
|
82
|
+
// Build a lookup by name once at construction. Duplicate names are a
|
|
83
|
+
// programmer error and we throw eagerly so the bug surfaces in the
|
|
84
|
+
// boot path, not at the first `tools/call`.
|
|
85
|
+
const byName = new Map();
|
|
86
|
+
for (const tool of options.tools) {
|
|
87
|
+
// Reject names that would re-encode as MCP `mcp__<server>__<tool>`
|
|
88
|
+
// false-positively on the consumer side — a server name containing
|
|
89
|
+
// `__` makes parseMcpToolName misalign the split. We surface the
|
|
90
|
+
// bug here, not at first dispatch. β4 r1 P2.
|
|
91
|
+
if (tool.name.includes('__')) {
|
|
92
|
+
throw new Error(`pugi mcp server: tool name "${tool.name}" must not contain "__" (collides with mcp__<server>__<tool> naming)`);
|
|
93
|
+
}
|
|
94
|
+
if (byName.has(tool.name)) {
|
|
95
|
+
throw new Error(`pugi mcp server: duplicate tool name "${tool.name}" — every tool in the surface must be unique`);
|
|
96
|
+
}
|
|
97
|
+
byName.set(tool.name, tool);
|
|
98
|
+
}
|
|
99
|
+
const tools = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
const log = options.log ?? (() => { });
|
|
101
|
+
if (typeof options.permissionGate !== 'function') {
|
|
102
|
+
throw new Error('pugi mcp server: options.permissionGate is required (β4 r1 P1 #2 — no implicit allow-all default)');
|
|
103
|
+
}
|
|
104
|
+
const permissionGate = options.permissionGate;
|
|
105
|
+
const requireInitialized = options.requireInitialized !== false;
|
|
106
|
+
let initialized = false;
|
|
107
|
+
async function dispatch(method, params, clientId) {
|
|
108
|
+
switch (method) {
|
|
109
|
+
case 'initialize': {
|
|
110
|
+
// The MCP spec permits multiple `initialize` calls before
|
|
111
|
+
// `notifications/initialized` settles the handshake — we just
|
|
112
|
+
// re-affirm. After init, repeated `initialize` is harmless on
|
|
113
|
+
// our side and lets the client recover from a state desync.
|
|
114
|
+
return {
|
|
115
|
+
protocolVersion: PUGI_MCP_PROTOCOL_VERSION,
|
|
116
|
+
capabilities: {
|
|
117
|
+
tools: { listChanged: false },
|
|
118
|
+
},
|
|
119
|
+
serverInfo: {
|
|
120
|
+
name: PUGI_MCP_SERVER_NAME,
|
|
121
|
+
version: PUGI_MCP_SERVER_VERSION,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
case 'ping': {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
case 'tools/list': {
|
|
129
|
+
return {
|
|
130
|
+
tools: tools.map((tool) => ({
|
|
131
|
+
name: tool.name,
|
|
132
|
+
description: tool.description,
|
|
133
|
+
inputSchema: tool.inputSchema,
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case 'tools/call': {
|
|
138
|
+
if (options.signal?.aborted) {
|
|
139
|
+
throw new McpServerToolError('server shutting down', MCP_ERROR_CODES.INTERNAL_ERROR);
|
|
140
|
+
}
|
|
141
|
+
// β4 r1 P2 — require `initialize` + `notifications/initialized`
|
|
142
|
+
// handshake first. Without this, an attacker with the bearer
|
|
143
|
+
// token can dispatch a `tools/call` without ever advertising
|
|
144
|
+
// client capabilities; the MCP spec mandates the handshake.
|
|
145
|
+
if (requireInitialized && !initialized) {
|
|
146
|
+
throw new McpServerToolError('tools/call: MCP handshake not complete — send `initialize` + `notifications/initialized` first', MCP_ERROR_CODES.INVALID_REQUEST);
|
|
147
|
+
}
|
|
148
|
+
const p = (params ?? {});
|
|
149
|
+
const rawName = p['name'];
|
|
150
|
+
if (typeof rawName !== 'string') {
|
|
151
|
+
throw new McpServerToolError('tools/call: params.name (string) is required', MCP_ERROR_CODES.INVALID_PARAMS);
|
|
152
|
+
}
|
|
153
|
+
// Trim whitespace-only names so the lookup fails fast with
|
|
154
|
+
// METHOD_NOT_FOUND instead of silently matching an entry that
|
|
155
|
+
// happens to share leading/trailing whitespace. β4 r1 P2.
|
|
156
|
+
const toolName = rawName.trim();
|
|
157
|
+
if (toolName.length === 0) {
|
|
158
|
+
throw new McpServerToolError('tools/call: params.name (string) is required', MCP_ERROR_CODES.INVALID_PARAMS);
|
|
159
|
+
}
|
|
160
|
+
const tool = byName.get(toolName);
|
|
161
|
+
if (!tool) {
|
|
162
|
+
throw new McpServerToolError(`tools/call: tool "${toolName}" is not registered`, MCP_ERROR_CODES.METHOD_NOT_FOUND);
|
|
163
|
+
}
|
|
164
|
+
const rawArgs = p['arguments'];
|
|
165
|
+
let args;
|
|
166
|
+
if (rawArgs === undefined || rawArgs === null) {
|
|
167
|
+
args = {};
|
|
168
|
+
}
|
|
169
|
+
else if (typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
|
170
|
+
args = rawArgs;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
throw new McpServerToolError('tools/call: params.arguments must be a JSON object', MCP_ERROR_CODES.INVALID_PARAMS);
|
|
174
|
+
}
|
|
175
|
+
const allowed = await permissionGate({
|
|
176
|
+
tool,
|
|
177
|
+
arguments: args,
|
|
178
|
+
...(clientId ? { clientId } : {}),
|
|
179
|
+
});
|
|
180
|
+
if (!allowed) {
|
|
181
|
+
log('warn', `permission refused: ${tool.name}`);
|
|
182
|
+
throw new McpServerToolError(`permission refused by operator: ${tool.name}`, MCP_ERROR_CODES.PERMISSION_REFUSED);
|
|
183
|
+
}
|
|
184
|
+
// Stamp clientId on emitted events so the HTTP transport can
|
|
185
|
+
// route them to the originating SSE listener instead of
|
|
186
|
+
// broadcasting to every bearer-holder (β4 r1 P1 #5).
|
|
187
|
+
events.emit('tool_call', {
|
|
188
|
+
name: tool.name,
|
|
189
|
+
args,
|
|
190
|
+
...(clientId ? { clientId } : {}),
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
const text = await tool.execute(args);
|
|
194
|
+
events.emit('tool_result', {
|
|
195
|
+
name: tool.name,
|
|
196
|
+
ok: true,
|
|
197
|
+
summary: text.slice(0, 200),
|
|
198
|
+
...(clientId ? { clientId } : {}),
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: 'text', text }],
|
|
202
|
+
isError: false,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
207
|
+
events.emit('tool_result', {
|
|
208
|
+
name: tool.name,
|
|
209
|
+
ok: false,
|
|
210
|
+
summary: message.slice(0, 200),
|
|
211
|
+
...(clientId ? { clientId } : {}),
|
|
212
|
+
});
|
|
213
|
+
// Tool-level failures surface as MCP `isError: true` content —
|
|
214
|
+
// the client knows the call ran but failed, distinct from a
|
|
215
|
+
// protocol-level error (bad tool name, malformed params).
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: 'text', text: message }],
|
|
218
|
+
isError: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
case 'notifications/initialized': {
|
|
223
|
+
initialized = true;
|
|
224
|
+
events.emit('initialized');
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
default: {
|
|
228
|
+
if (method.startsWith('notifications/')) {
|
|
229
|
+
// Silently accept unknown notifications — the spec requires
|
|
230
|
+
// they be ignored, not errored.
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
throw new McpServerToolError(`method not found: ${method}`, MCP_ERROR_CODES.METHOD_NOT_FOUND);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async function handleMessage(request) {
|
|
238
|
+
const clientId = request.meta?.clientId;
|
|
239
|
+
// Notifications never get a response.
|
|
240
|
+
const isNotification = request.id === undefined || request.id === null;
|
|
241
|
+
if (isNotification) {
|
|
242
|
+
try {
|
|
243
|
+
await dispatch(request.method, request.params, clientId);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
log('error', `notification handler threw: ${error.message}`);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const id = request.id;
|
|
251
|
+
try {
|
|
252
|
+
const result = await dispatch(request.method, request.params, clientId);
|
|
253
|
+
// dispatch may return null for void responses (notifications) —
|
|
254
|
+
// but we already filtered those above. Treat null here as `{}`.
|
|
255
|
+
return {
|
|
256
|
+
jsonrpc: '2.0',
|
|
257
|
+
id,
|
|
258
|
+
result: result ?? {},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const isToolError = error instanceof McpServerToolError;
|
|
263
|
+
const code = isToolError ? error.code : MCP_ERROR_CODES.INTERNAL_ERROR;
|
|
264
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
265
|
+
log('error', `dispatch ${request.method} (id=${id}) failed: ${message}`);
|
|
266
|
+
return {
|
|
267
|
+
jsonrpc: '2.0',
|
|
268
|
+
id,
|
|
269
|
+
error: { code, message },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
handleMessage,
|
|
275
|
+
events,
|
|
276
|
+
listToolsSync() {
|
|
277
|
+
return tools.slice();
|
|
278
|
+
},
|
|
279
|
+
// Internal: exposed to tests via type assertion when they want to
|
|
280
|
+
// verify the initialized flag advanced. Not part of the public
|
|
281
|
+
// interface intentionally — production callers should listen on
|
|
282
|
+
// `events` instead.
|
|
283
|
+
// @ts-expect-error — debug accessor
|
|
284
|
+
_isInitialized() {
|
|
285
|
+
return initialized;
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Run the server on stdio. Reads one JSON-RPC line per request from
|
|
291
|
+
* stdin, writes the response (or nothing for notifications) as one line
|
|
292
|
+
* to stdout. Lines are `\n`-terminated UTF-8 JSON.
|
|
293
|
+
*
|
|
294
|
+
* Returns a promise that resolves when stdin closes. The caller can race
|
|
295
|
+
* it against `signal` to force shutdown — the stdio reader is shielded
|
|
296
|
+
* by the same signal, so an abort terminates the line loop cleanly.
|
|
297
|
+
*/
|
|
298
|
+
export async function serveStdio(options) {
|
|
299
|
+
const stdin = options.stdin ?? process.stdin;
|
|
300
|
+
const stdout = options.stdout ?? process.stdout;
|
|
301
|
+
const { server, signal } = options;
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
let buffer = '';
|
|
304
|
+
let closed = false;
|
|
305
|
+
const finish = () => {
|
|
306
|
+
if (closed)
|
|
307
|
+
return;
|
|
308
|
+
closed = true;
|
|
309
|
+
stdin.off('data', onData);
|
|
310
|
+
stdin.off('end', finish);
|
|
311
|
+
stdin.off('close', finish);
|
|
312
|
+
if (signal) {
|
|
313
|
+
signal.removeEventListener('abort', finish);
|
|
314
|
+
}
|
|
315
|
+
resolve();
|
|
316
|
+
};
|
|
317
|
+
const writeFrame = (response) => {
|
|
318
|
+
stdout.write(`${JSON.stringify(response)}\n`);
|
|
319
|
+
};
|
|
320
|
+
const writeParseError = (id, message) => {
|
|
321
|
+
writeFrame({
|
|
322
|
+
jsonrpc: '2.0',
|
|
323
|
+
id,
|
|
324
|
+
error: { code: MCP_ERROR_CODES.PARSE_ERROR, message },
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
const processLine = async (line) => {
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (trimmed.length === 0)
|
|
330
|
+
return;
|
|
331
|
+
let parsed;
|
|
332
|
+
try {
|
|
333
|
+
parsed = JSON.parse(trimmed);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
writeParseError(null, `invalid JSON: ${error.message}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
340
|
+
writeParseError(null, 'request must be a JSON object');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const candidate = parsed;
|
|
344
|
+
if (candidate.jsonrpc !== '2.0' || typeof candidate.method !== 'string') {
|
|
345
|
+
writeParseError(typeof candidate.id === 'number' || typeof candidate.id === 'string'
|
|
346
|
+
? candidate.id
|
|
347
|
+
: null, 'invalid JSON-RPC envelope: jsonrpc=2.0 + string method required');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// Stdio is a single-tenant transport — the parent process owns
|
|
351
|
+
// both ends — so we intentionally drop any inbound `meta.clientId`.
|
|
352
|
+
// Per-connection scoping only makes sense for HTTP+SSE.
|
|
353
|
+
const request = {
|
|
354
|
+
jsonrpc: '2.0',
|
|
355
|
+
method: candidate.method,
|
|
356
|
+
...(candidate.id !== undefined ? { id: candidate.id } : {}),
|
|
357
|
+
...(candidate.params !== undefined ? { params: candidate.params } : {}),
|
|
358
|
+
};
|
|
359
|
+
const response = await server.handleMessage(request);
|
|
360
|
+
if (response)
|
|
361
|
+
writeFrame(response);
|
|
362
|
+
};
|
|
363
|
+
const onData = (chunk) => {
|
|
364
|
+
buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
365
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
366
|
+
while (newlineIndex !== -1) {
|
|
367
|
+
const line = buffer.slice(0, newlineIndex);
|
|
368
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
369
|
+
// Fire-and-forget the line handler; ordering of responses
|
|
370
|
+
// matches request ordering at the protocol level because the
|
|
371
|
+
// dispatch is microtask-serialized via the await above for
|
|
372
|
+
// each line — Node iterates `data` callbacks synchronously,
|
|
373
|
+
// and processLine awaits the dispatcher before returning so
|
|
374
|
+
// the next iteration of the loop sees the prior one settled.
|
|
375
|
+
void processLine(line);
|
|
376
|
+
newlineIndex = buffer.indexOf('\n');
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
if (signal) {
|
|
380
|
+
if (signal.aborted) {
|
|
381
|
+
finish();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
385
|
+
}
|
|
386
|
+
if (!stdin.readable) {
|
|
387
|
+
// Stream already closed (e.g. parent piped EOF immediately).
|
|
388
|
+
finish();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
stdin.setEncoding('utf8');
|
|
392
|
+
stdin.on('data', onData);
|
|
393
|
+
stdin.on('end', finish);
|
|
394
|
+
stdin.on('close', finish);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate — Leak L6 canonical 4-mode enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Single dispatch entry point. Every tool call goes through `gate()`
|
|
5
|
+
* before the executor runs the tool body; the executor surfaces the
|
|
6
|
+
* `PermissionDenied` error as a model-readable sentinel so the model
|
|
7
|
+
* can either reformulate the request or wait for the operator to
|
|
8
|
+
* change the mode.
|
|
9
|
+
*
|
|
10
|
+
* Routing matrix (mode × class):
|
|
11
|
+
*
|
|
12
|
+
* | read | write | dispatch
|
|
13
|
+
* plan | allow | deny | deny
|
|
14
|
+
* ask | ask | ask | ask
|
|
15
|
+
* allow | allow | allow | allow
|
|
16
|
+
* bypass | allow | allow | allow (plus: hooks bypassed)
|
|
17
|
+
*
|
|
18
|
+
* In ask mode the gate consults a session-scoped `always-allow` cache
|
|
19
|
+
* keyed by tool name (set when the operator picks "always-allow-tool"
|
|
20
|
+
* in the prompt). The cache is in-memory only — restarting the session
|
|
21
|
+
* resets it, by design (every-session-fresh ask consent).
|
|
22
|
+
*
|
|
23
|
+
* Bypass mode does NOT take a different code path in this module — the
|
|
24
|
+
* `hooksBypassed` flag in the decision payload signals the executor /
|
|
25
|
+
* hook layer to skip policy hooks. The classification logic is the
|
|
26
|
+
* same as `allow` because the gate doesn't own hook execution; the
|
|
27
|
+
* caller decides what to do with the bypass signal.
|
|
28
|
+
*/
|
|
29
|
+
import { getToolClass } from './tool-class.js';
|
|
30
|
+
export const ASK_OPTIONS = Object.freeze([
|
|
31
|
+
'allow-once',
|
|
32
|
+
'always-this-tool',
|
|
33
|
+
'deny-once',
|
|
34
|
+
'always-deny-this-tool',
|
|
35
|
+
]);
|
|
36
|
+
export function createAskAlwaysCache() {
|
|
37
|
+
return {
|
|
38
|
+
alwaysAllowed: new Set(),
|
|
39
|
+
alwaysDenied: new Set(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Apply the operator's answer to an `ask` decision. Caller invokes this
|
|
44
|
+
* after the operator picks an option so the cache stays in sync.
|
|
45
|
+
* Returns the effective decision: `allow-once` / `always-this-tool`
|
|
46
|
+
* become `allow`; `deny-once` / `always-deny-this-tool` become `deny`.
|
|
47
|
+
*
|
|
48
|
+
* `always-*` answers persist to the cache and short-circuit the next
|
|
49
|
+
* gate call for the same tool name within the same session.
|
|
50
|
+
*/
|
|
51
|
+
export function applyAskAnswer(cache, toolName, answer) {
|
|
52
|
+
switch (answer) {
|
|
53
|
+
case 'allow-once':
|
|
54
|
+
return { decision: 'allow', reason: `Allowed once for ${toolName}` };
|
|
55
|
+
case 'always-this-tool':
|
|
56
|
+
cache.alwaysAllowed.add(toolName);
|
|
57
|
+
cache.alwaysDenied.delete(toolName);
|
|
58
|
+
return { decision: 'allow', reason: `Allowed for ${toolName} this session` };
|
|
59
|
+
case 'deny-once':
|
|
60
|
+
return { decision: 'deny', reason: `Denied once for ${toolName}` };
|
|
61
|
+
case 'always-deny-this-tool':
|
|
62
|
+
cache.alwaysDenied.add(toolName);
|
|
63
|
+
cache.alwaysAllowed.delete(toolName);
|
|
64
|
+
return { decision: 'deny', reason: `Denied for ${toolName} this session` };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Permission-denied sentinel. Distinguishable from other tool errors
|
|
69
|
+
* (parse errors, IO failures) so the caller can route the message back
|
|
70
|
+
* to the model with the canonical recovery hint.
|
|
71
|
+
*/
|
|
72
|
+
export class PermissionDenied extends Error {
|
|
73
|
+
name = 'PermissionDenied';
|
|
74
|
+
mode;
|
|
75
|
+
toolName;
|
|
76
|
+
toolClass;
|
|
77
|
+
/**
|
|
78
|
+
* Human-friendly reason surfaced in logs / hook payloads. Distinct
|
|
79
|
+
* from `message` so the spec layer can pattern-match the canonical
|
|
80
|
+
* `PERMISSION_DENIED:` sentinel verbatim while operators see the
|
|
81
|
+
* full explanation in console output.
|
|
82
|
+
*/
|
|
83
|
+
reason;
|
|
84
|
+
constructor(toolName, toolClass, mode, reason) {
|
|
85
|
+
// The base Error.message is the canonical sentinel so default
|
|
86
|
+
// toString() / re-throw paths preserve the format the model and
|
|
87
|
+
// the spec layer pattern-match against.
|
|
88
|
+
super(`PERMISSION_DENIED: ${toolName} blocked in ${mode} mode. Operator can switch with /permissions <mode>.`);
|
|
89
|
+
this.mode = mode;
|
|
90
|
+
this.toolName = toolName;
|
|
91
|
+
this.toolClass = toolClass;
|
|
92
|
+
this.reason = reason;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Render the sentinel message the executor surfaces to the model.
|
|
96
|
+
* The string format is stable so a parent agent / E2E spec can
|
|
97
|
+
* pattern-match `PERMISSION_DENIED: <tool> blocked in <mode> mode.`
|
|
98
|
+
* verbatim. Equivalent to `this.message`; kept as a method so
|
|
99
|
+
* downstream callers can use whichever spelling reads better at the
|
|
100
|
+
* site.
|
|
101
|
+
*/
|
|
102
|
+
toModelMessage() {
|
|
103
|
+
return this.message;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Core dispatch gate. Pure function — no IO, no side effects beyond
|
|
108
|
+
* mutating the caller-supplied `alwaysCache`. Safe to call from any
|
|
109
|
+
* layer (engine adapter, agent-as-tool bridge, doctor command).
|
|
110
|
+
*
|
|
111
|
+
* Argument bag mirrors the executor entry shape:
|
|
112
|
+
* - `toolName` is the registered tool key (e.g. `read`, `write`,
|
|
113
|
+
* `mcp__github__list_issues`).
|
|
114
|
+
* - `args` is the raw arg payload. Currently unused in the routing
|
|
115
|
+
* decision — the matrix only cares about class. Plumbed in
|
|
116
|
+
* because future "always-allow-this-pattern" rules (e.g.
|
|
117
|
+
* `git status` auto-allow) will consume it without changing the
|
|
118
|
+
* callsite contract.
|
|
119
|
+
* - `ctx` carries mode + session-scoped state.
|
|
120
|
+
*/
|
|
121
|
+
export function gate(toolName,
|
|
122
|
+
// Reserved for future pattern-based rules (always-allow `git status`).
|
|
123
|
+
// Suppress unused-argument lint — the contract is stable on purpose.
|
|
124
|
+
_args, ctx) {
|
|
125
|
+
const toolClass = getToolClass(toolName);
|
|
126
|
+
const cache = ctx.alwaysCache;
|
|
127
|
+
// Ask-mode session memory: an explicit "always-deny" beats any other
|
|
128
|
+
// routing because the operator has actively refused this tool.
|
|
129
|
+
if (cache?.alwaysDenied.has(toolName)) {
|
|
130
|
+
return {
|
|
131
|
+
decision: 'deny',
|
|
132
|
+
reason: `Tool ${toolName} denied for the session via /permissions ask`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// "Always-allow" in ask mode skips the prompt for subsequent calls.
|
|
136
|
+
// Plan mode IGNORES the always-allow cache because plan mode's
|
|
137
|
+
// contract is structural (read-only), not consent-based.
|
|
138
|
+
if (cache?.alwaysAllowed.has(toolName) && ctx.permissionMode === 'ask') {
|
|
139
|
+
return {
|
|
140
|
+
decision: 'allow',
|
|
141
|
+
reason: `Tool ${toolName} always-allowed for this session`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
switch (ctx.permissionMode) {
|
|
145
|
+
case 'plan': {
|
|
146
|
+
if (toolClass === 'read') {
|
|
147
|
+
return { decision: 'allow', reason: `Plan mode: read tools allowed (${toolName})` };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
decision: 'deny',
|
|
151
|
+
reason: `Plan mode: ${toolClass} tools blocked. Switch with /permissions allow.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
case 'ask': {
|
|
155
|
+
return {
|
|
156
|
+
decision: 'ask',
|
|
157
|
+
reason: `Ask mode: prompt before ${toolName}`,
|
|
158
|
+
question: buildAskQuestion(toolName, toolClass, ctx.target),
|
|
159
|
+
options: ASK_OPTIONS,
|
|
160
|
+
toolClass,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
case 'allow': {
|
|
164
|
+
return {
|
|
165
|
+
decision: 'allow',
|
|
166
|
+
reason: `Allow mode: ${toolName} executed`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
case 'bypass': {
|
|
170
|
+
return {
|
|
171
|
+
decision: 'allow',
|
|
172
|
+
reason: `Bypass mode: ${toolName} executed (policy hooks skipped)`,
|
|
173
|
+
hooksBypassed: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Build the operator-facing question string for an ask-mode prompt.
|
|
180
|
+
* Kept in one place so the wording stays consistent across the REPL
|
|
181
|
+
* Ink modal and the simpler stdin fallback.
|
|
182
|
+
*/
|
|
183
|
+
function buildAskQuestion(toolName, toolClass, target) {
|
|
184
|
+
const suffix = target ? ` on ${target}` : '';
|
|
185
|
+
return `Allow ${toolName} (${toolClass})${suffix}?`;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission gate (Leak L6) public surface.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the canonical 4-mode types, the tool-class classifier,
|
|
5
|
+
* the dispatch gate, and the workspace + global session-state helpers
|
|
6
|
+
* so callers import from one place:
|
|
7
|
+
*
|
|
8
|
+
* import { gate, resolveMode, PermissionDenied } from '<...>/permissions/index.js';
|
|
9
|
+
*
|
|
10
|
+
* Keeps the internal file split (mode / tool-class / gate / state)
|
|
11
|
+
* invisible to consumers — those files are an implementation detail
|
|
12
|
+
* the engine adapter does not need to know about.
|
|
13
|
+
*/
|
|
14
|
+
export { DEFAULT_PERMISSION_MODE, PERMISSION_MODE_GLOSS, PERMISSION_MODES, isPermissionMode, parsePermissionMode, toLegacyMode, } from './mode.js';
|
|
15
|
+
export { getToolClass, listBuiltInToolClasses, } from './tool-class.js';
|
|
16
|
+
export { ASK_OPTIONS, PermissionDenied, applyAskAnswer, createAskAlwaysCache, gate, } from './gate.js';
|
|
17
|
+
export { getCurrentMode, getGlobalDefaultMode, globalConfigPath, resolveMode, sessionStatePath, setCurrentMode, setGlobalDefaultMode, } from './state.js';
|
|
18
|
+
//# sourceMappingURL=index.js.map
|