@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.
Files changed (130) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/assets/pugi-mascot.ansi +15 -40
  3. package/bin/run.js +33 -1
  4. package/dist/commands/jobs-watch.js +201 -0
  5. package/dist/commands/jobs.js +15 -0
  6. package/dist/core/agent-progress/cleanup.js +134 -0
  7. package/dist/core/agent-progress/schema.js +144 -0
  8. package/dist/core/agent-progress/writer.js +101 -0
  9. package/dist/core/compact/auto-trigger.js +96 -0
  10. package/dist/core/compact/buffer-rewriter.js +115 -0
  11. package/dist/core/compact/summarizer.js +196 -0
  12. package/dist/core/compact/token-counter.js +108 -0
  13. package/dist/core/consensus/diff-capture.js +73 -0
  14. package/dist/core/context/index.js +7 -0
  15. package/dist/core/context/markdown-traverse.js +255 -0
  16. package/dist/core/cost/rate-card.js +129 -0
  17. package/dist/core/cost/tracker.js +221 -0
  18. package/dist/core/denial-tracking/index.js +8 -0
  19. package/dist/core/denial-tracking/state.js +264 -0
  20. package/dist/core/diagnostics/probe-runner.js +93 -0
  21. package/dist/core/diagnostics/probes/api.js +46 -0
  22. package/dist/core/diagnostics/probes/auth.js +86 -0
  23. package/dist/core/diagnostics/probes/cli-version.js +127 -0
  24. package/dist/core/diagnostics/probes/config.js +72 -0
  25. package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
  26. package/dist/core/diagnostics/probes/disk.js +81 -0
  27. package/dist/core/diagnostics/probes/git.js +65 -0
  28. package/dist/core/diagnostics/probes/mcp.js +75 -0
  29. package/dist/core/diagnostics/probes/node.js +59 -0
  30. package/dist/core/diagnostics/probes/pnpm.js +36 -0
  31. package/dist/core/diagnostics/probes/session.js +74 -0
  32. package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
  33. package/dist/core/diagnostics/probes/workspace.js +63 -0
  34. package/dist/core/diagnostics/types.js +70 -0
  35. package/dist/core/edits/dispatch.js +218 -2
  36. package/dist/core/edits/journal.js +199 -0
  37. package/dist/core/edits/layer-d-ast.js +557 -14
  38. package/dist/core/edits/verify-hook.js +273 -0
  39. package/dist/core/edits/worktree.js +111 -18
  40. package/dist/core/engine/anvil-client.js +115 -5
  41. package/dist/core/engine/budgets.js +89 -0
  42. package/dist/core/engine/context-prefix.js +155 -0
  43. package/dist/core/engine/intent.js +260 -0
  44. package/dist/core/engine/native-pugi.js +744 -210
  45. package/dist/core/engine/prompts.js +61 -6
  46. package/dist/core/engine/strip-internal-fields.js +124 -0
  47. package/dist/core/engine/tool-bridge.js +818 -31
  48. package/dist/core/file-cache.js +113 -1
  49. package/dist/core/init/scaffold.js +195 -0
  50. package/dist/core/lsp/client.js +174 -29
  51. package/dist/core/mcp/client.js +75 -6
  52. package/dist/core/mcp/http-server.js +553 -0
  53. package/dist/core/mcp/permission.js +190 -0
  54. package/dist/core/mcp/registry.js +24 -2
  55. package/dist/core/mcp/server-tools.js +219 -0
  56. package/dist/core/mcp/server.js +397 -0
  57. package/dist/core/permissions/gate.js +187 -0
  58. package/dist/core/permissions/index.js +18 -0
  59. package/dist/core/permissions/mode.js +102 -0
  60. package/dist/core/permissions/state.js +160 -0
  61. package/dist/core/permissions/tool-class.js +93 -0
  62. package/dist/core/repl/codebase-survey.js +308 -0
  63. package/dist/core/repl/history.js +11 -1
  64. package/dist/core/repl/init-interview.js +457 -0
  65. package/dist/core/repl/model-pricing.js +135 -0
  66. package/dist/core/repl/onboarding-state.js +297 -0
  67. package/dist/core/repl/session.js +719 -29
  68. package/dist/core/repl/slash-commands.js +133 -9
  69. package/dist/core/retry-budget/budget.js +284 -0
  70. package/dist/core/retry-budget/index.js +5 -0
  71. package/dist/core/settings.js +71 -0
  72. package/dist/core/skills/defaults.js +457 -0
  73. package/dist/core/subagents/dispatcher-real.js +600 -0
  74. package/dist/core/subagents/dispatcher.js +113 -24
  75. package/dist/core/subagents/index.js +18 -5
  76. package/dist/core/subagents/isolation-matrix.js +213 -0
  77. package/dist/core/subagents/spawn.js +19 -4
  78. package/dist/core/transport/version-interceptor.js +166 -0
  79. package/dist/index.js +28 -0
  80. package/dist/runtime/bootstrap.js +190 -0
  81. package/dist/runtime/cli.js +1588 -266
  82. package/dist/runtime/commands/compact.js +296 -0
  83. package/dist/runtime/commands/cost.js +199 -0
  84. package/dist/runtime/commands/delegate.js +289 -0
  85. package/dist/runtime/commands/doctor.js +369 -0
  86. package/dist/runtime/commands/lsp.js +187 -5
  87. package/dist/runtime/commands/mcp.js +824 -0
  88. package/dist/runtime/commands/patch.js +17 -0
  89. package/dist/runtime/commands/permissions.js +87 -0
  90. package/dist/runtime/commands/report.js +299 -0
  91. package/dist/runtime/commands/review-consensus.js +17 -2
  92. package/dist/runtime/commands/roster.js +117 -0
  93. package/dist/runtime/commands/status.js +178 -0
  94. package/dist/runtime/commands/worktree.js +50 -6
  95. package/dist/runtime/headless.js +543 -0
  96. package/dist/runtime/load-hooks-or-exit.js +71 -0
  97. package/dist/runtime/plan-decompose.js +531 -0
  98. package/dist/runtime/version.js +65 -0
  99. package/dist/tools/agent-tool.js +206 -0
  100. package/dist/tools/apply-patch.js +281 -39
  101. package/dist/tools/ask-user-question.js +213 -0
  102. package/dist/tools/ask-user.js +115 -0
  103. package/dist/tools/file-tools.js +85 -14
  104. package/dist/tools/mcp-tool.js +260 -0
  105. package/dist/tools/multi-edit.js +361 -0
  106. package/dist/tools/registry.js +22 -2
  107. package/dist/tools/skill-tool.js +96 -0
  108. package/dist/tools/tasks.js +208 -0
  109. package/dist/tools/web-fetch.js +147 -2
  110. package/dist/tools/web-search.js +458 -0
  111. package/dist/tui/agent-progress-card.js +111 -0
  112. package/dist/tui/agent-tree.js +10 -0
  113. package/dist/tui/ask-modal.js +2 -2
  114. package/dist/tui/ask-user-question-prompt.js +192 -0
  115. package/dist/tui/compact-banner.js +54 -0
  116. package/dist/tui/conversation-pane.js +69 -8
  117. package/dist/tui/cost-table.js +111 -0
  118. package/dist/tui/doctor-table.js +31 -0
  119. package/dist/tui/input-box.js +1 -1
  120. package/dist/tui/markdown-render.js +4 -4
  121. package/dist/tui/repl-render.js +276 -37
  122. package/dist/tui/repl-splash.js +2 -2
  123. package/dist/tui/repl.js +25 -6
  124. package/dist/tui/splash.js +1 -1
  125. package/dist/tui/status-bar.js +94 -16
  126. package/dist/tui/status-table.js +7 -0
  127. package/dist/tui/tool-stream-pane.js +7 -0
  128. package/dist/tui/update-banner.js +20 -2
  129. package/docs/examples/codegraph.mcp.json +10 -0
  130. 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