@pugi/cli 0.1.0-alpha.10

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,316 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Minimal JSON-RPC 2.0 over stdio MCP client for Pugi CLI M1.
5
+ *
6
+ * Spec reference: https://modelcontextprotocol.io/specification
7
+ *
8
+ * Scope deliberately narrowed to what the CLI needs for α5.6:
9
+ * - initialize handshake (protocol version + capabilities exchange)
10
+ * - tools/list
11
+ * - tools/call
12
+ * - graceful shutdown (SIGTERM, 5s grace, SIGKILL)
13
+ *
14
+ * NOT implemented for M1: resources, prompts, sampling, notifications, SSE
15
+ * transport, batching, progress tokens. Those layer onto this surface later
16
+ * without a wire-format break — the request/response correlation table is
17
+ * id-based and the transport is line-delimited JSON, so additions only
18
+ * extend `RpcMethod` and the response parsers.
19
+ *
20
+ * Why hand-roll instead of `@modelcontextprotocol/sdk`:
21
+ * - The official SDK is ~3 MB compressed and pulls in 30+ transitive deps
22
+ * to support every transport. The CLI ships as `npm i -g pugi` and every
23
+ * dep is a maintenance + supply-chain risk. Hand-rolled JSON-RPC over
24
+ * stdio is <200 LOC; that is the right tradeoff for an alpha that ships.
25
+ * - We retain wire-level control to add Pugi-specific extensions later
26
+ * (per-call permission gates, trust-aware tool filtering at the
27
+ * transport layer) without forking the SDK.
28
+ *
29
+ * Strict typing notes:
30
+ * - All wire messages parse through Zod before reaching consumer code.
31
+ * - `unknown` is preferred over `any` in every public surface.
32
+ * - Timeouts are mandatory; a stuck child process never blocks the CLI
33
+ * forever.
34
+ */
35
+ export const mcpServerConfigSchema = z.object({
36
+ command: z.string().min(1),
37
+ args: z.array(z.string()).default([]),
38
+ env: z.record(z.string()).default({}),
39
+ /**
40
+ * Per-server trust state. Mirrors `trust.ts` ledger semantics:
41
+ * - `pending` — declared but not yet approved. Connections refused.
42
+ * - `trusted` — operator has approved this server's command line.
43
+ * - `denied` — operator explicitly blocked it; never auto-connect.
44
+ *
45
+ * Note this is the runtime trust state stored INSIDE `.pugi/mcp.json` /
46
+ * `~/.pugi/mcp.json`. The trust LEDGER at `~/.pugi/trust-mcp.json` is the
47
+ * source of truth and overrides the file-level value at load time.
48
+ */
49
+ trust: z.enum(['pending', 'trusted', 'denied']).default('pending'),
50
+ });
51
+ export const mcpToolSchema = z.object({
52
+ name: z.string().min(1),
53
+ description: z.string().optional(),
54
+ inputSchema: z.unknown().optional(),
55
+ });
56
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
57
+ const SHUTDOWN_GRACE_MS = 5_000;
58
+ /**
59
+ * Spawn an MCP server child process and complete the initialize handshake.
60
+ *
61
+ * Throws if:
62
+ * - the child fails to spawn (ENOENT, permission denied, etc),
63
+ * - the initialize response does not arrive within the timeout,
64
+ * - the server returns a JSON-RPC error in response to initialize.
65
+ *
66
+ * The caller is responsible for calling `disconnect` to free the child.
67
+ */
68
+ export async function connect(serverName, config, options = {}) {
69
+ const child = spawn(config.command, config.args, {
70
+ env: { ...process.env, ...config.env },
71
+ stdio: ['pipe', 'pipe', 'pipe'],
72
+ });
73
+ const connection = {
74
+ serverName,
75
+ child,
76
+ capabilities: {},
77
+ pending: new Map(),
78
+ nextId: 1,
79
+ closed: false,
80
+ };
81
+ // Attach the spawn-error handler BEFORE the reader: ENOENT and similar
82
+ // are emitted asynchronously on the next tick, and without this
83
+ // listener Node converts them to uncaughtException which crashes the
84
+ // test runner even when our caller already awaited the rejection.
85
+ let spawnError = null;
86
+ child.on('error', (error) => {
87
+ spawnError = error;
88
+ connection.closed = true;
89
+ for (const call of connection.pending.values()) {
90
+ clearTimeout(call.timeoutHandle);
91
+ call.reject(error);
92
+ }
93
+ connection.pending.clear();
94
+ });
95
+ attachReader(connection);
96
+ // Give the spawn `error` event a tick to fire if the binary cannot be
97
+ // started. Without this `child.pid` reads as undefined and we'd throw
98
+ // a different error message before the actual ENOENT surfaces.
99
+ await new Promise((tickResolve) => setImmediate(tickResolve));
100
+ if (spawnError) {
101
+ throw new Error(`mcp server "${serverName}" failed to spawn: ${spawnError.message}`);
102
+ }
103
+ // Surface spawn failures synchronously when possible. `child.pid` is
104
+ // undefined when the runtime could not start the binary at all.
105
+ if (!child.pid) {
106
+ throw new Error(`mcp server "${serverName}" failed to spawn: ${config.command}`);
107
+ }
108
+ child.on('exit', (code) => {
109
+ connection.closed = true;
110
+ for (const call of connection.pending.values()) {
111
+ clearTimeout(call.timeoutHandle);
112
+ call.reject(new Error(`mcp server "${serverName}" exited (code ${code ?? 'null'}) before responding to ${call.method}`));
113
+ }
114
+ connection.pending.clear();
115
+ });
116
+ const initResponse = await send(connection, 'initialize', {
117
+ protocolVersion: '2024-11-05',
118
+ capabilities: {
119
+ tools: {},
120
+ },
121
+ clientInfo: {
122
+ name: 'pugi-cli',
123
+ version: '0.1',
124
+ },
125
+ }, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
126
+ if (initResponse && typeof initResponse === 'object' && !Array.isArray(initResponse)) {
127
+ const caps = initResponse.capabilities;
128
+ if (caps && typeof caps === 'object' && !Array.isArray(caps)) {
129
+ Object.assign(connection.capabilities, caps);
130
+ }
131
+ }
132
+ // MCP requires a `notifications/initialized` notification after the
133
+ // handshake response. We fire and forget — notifications have no id and
134
+ // no response to await.
135
+ writeFrame(connection, {
136
+ jsonrpc: '2.0',
137
+ method: 'notifications/initialized',
138
+ params: {},
139
+ });
140
+ return connection;
141
+ }
142
+ /**
143
+ * List tools exposed by the connected MCP server.
144
+ */
145
+ export async function listTools(connection) {
146
+ const response = await send(connection, 'tools/list', {}, DEFAULT_REQUEST_TIMEOUT_MS);
147
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
148
+ throw new Error(`mcp server "${connection.serverName}" returned malformed tools/list response`);
149
+ }
150
+ const tools = response.tools;
151
+ if (!Array.isArray(tools)) {
152
+ throw new Error(`mcp server "${connection.serverName}" tools/list missing tools array`);
153
+ }
154
+ const parsed = [];
155
+ for (const candidate of tools) {
156
+ const result = mcpToolSchema.safeParse(candidate);
157
+ if (result.success) {
158
+ parsed.push(result.data);
159
+ }
160
+ }
161
+ return parsed;
162
+ }
163
+ /**
164
+ * Invoke a tool exposed by the connected MCP server. Returns the raw
165
+ * `content` payload from the server plus `isError` flag.
166
+ */
167
+ export async function callTool(connection, name, args, options = {}) {
168
+ const response = await send(connection, 'tools/call', { name, arguments: args }, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
169
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
170
+ return {
171
+ content: response ?? null,
172
+ isError: true,
173
+ };
174
+ }
175
+ const obj = response;
176
+ const isError = obj.isError === true;
177
+ return {
178
+ content: obj.content ?? null,
179
+ isError,
180
+ };
181
+ }
182
+ /**
183
+ * Tear down the connection: SIGTERM, wait up to SHUTDOWN_GRACE_MS, then
184
+ * SIGKILL if the child has not exited. Safe to call multiple times.
185
+ */
186
+ export async function disconnect(connection) {
187
+ if (connection.closed)
188
+ return;
189
+ const { child } = connection;
190
+ if (child.exitCode !== null || child.killed) {
191
+ connection.closed = true;
192
+ return;
193
+ }
194
+ return new Promise((resolveDone) => {
195
+ let settled = false;
196
+ const settle = () => {
197
+ if (settled)
198
+ return;
199
+ settled = true;
200
+ connection.closed = true;
201
+ resolveDone();
202
+ };
203
+ child.once('exit', settle);
204
+ try {
205
+ child.kill('SIGTERM');
206
+ }
207
+ catch {
208
+ // Already dead. Still wait for the exit event so the caller does
209
+ // not see a partially-torn-down connection.
210
+ }
211
+ setTimeout(() => {
212
+ if (settled)
213
+ return;
214
+ try {
215
+ child.kill('SIGKILL');
216
+ }
217
+ catch {
218
+ // ignore — process is already gone
219
+ }
220
+ // Force-resolve even if the exit event never fires (extremely
221
+ // rare on a SIGKILL'd child, but defend against it).
222
+ setTimeout(settle, 200);
223
+ }, SHUTDOWN_GRACE_MS);
224
+ });
225
+ }
226
+ function writeFrame(connection, message) {
227
+ if (connection.closed) {
228
+ throw new Error(`mcp server "${connection.serverName}" connection is closed`);
229
+ }
230
+ const line = `${JSON.stringify(message)}\n`;
231
+ connection.child.stdin.write(line);
232
+ }
233
+ async function send(connection, method, params, timeoutMs) {
234
+ const id = connection.nextId++;
235
+ const request = {
236
+ jsonrpc: '2.0',
237
+ id,
238
+ method,
239
+ params,
240
+ };
241
+ return new Promise((resolveCall, rejectCall) => {
242
+ const timeoutHandle = setTimeout(() => {
243
+ connection.pending.delete(id);
244
+ rejectCall(new Error(`mcp server "${connection.serverName}" timed out after ${timeoutMs}ms on ${method}`));
245
+ }, timeoutMs);
246
+ connection.pending.set(id, {
247
+ resolve: resolveCall,
248
+ reject: rejectCall,
249
+ method,
250
+ timeoutHandle,
251
+ });
252
+ try {
253
+ writeFrame(connection, request);
254
+ }
255
+ catch (error) {
256
+ clearTimeout(timeoutHandle);
257
+ connection.pending.delete(id);
258
+ rejectCall(error instanceof Error ? error : new Error(String(error)));
259
+ }
260
+ });
261
+ }
262
+ function attachReader(connection) {
263
+ let buffer = '';
264
+ connection.child.stdout.setEncoding('utf8');
265
+ connection.child.stdout.on('data', (chunk) => {
266
+ buffer += chunk;
267
+ let newlineIndex = buffer.indexOf('\n');
268
+ while (newlineIndex !== -1) {
269
+ const line = buffer.slice(0, newlineIndex).trim();
270
+ buffer = buffer.slice(newlineIndex + 1);
271
+ if (line.length > 0) {
272
+ handleLine(connection, line);
273
+ }
274
+ newlineIndex = buffer.indexOf('\n');
275
+ }
276
+ });
277
+ // stderr is captured but never thrown. Some MCP servers log diagnostics
278
+ // there even on success; we route it to nowhere so the CLI stdout stays
279
+ // pure JSON envelope output. A future iteration may forward this to the
280
+ // audit log.
281
+ connection.child.stderr.on('data', () => { });
282
+ }
283
+ function handleLine(connection, line) {
284
+ let parsed;
285
+ try {
286
+ parsed = JSON.parse(line);
287
+ }
288
+ catch {
289
+ // Malformed line from the server. Drop it and continue — a partial
290
+ // frame at the start of the stream is the common cause.
291
+ return;
292
+ }
293
+ if (!parsed || typeof parsed !== 'object')
294
+ return;
295
+ const message = parsed;
296
+ // Notifications (no `id`) are dropped at M1 — we do not subscribe to
297
+ // server-pushed events yet.
298
+ if (typeof message.id !== 'number')
299
+ return;
300
+ const pending = connection.pending.get(message.id);
301
+ if (!pending)
302
+ return;
303
+ clearTimeout(pending.timeoutHandle);
304
+ connection.pending.delete(message.id);
305
+ if ('error' in message && message.error) {
306
+ const err = message.error;
307
+ pending.reject(new Error(`mcp server "${connection.serverName}" returned error on ${pending.method}: ${err.message} (code ${err.code})`));
308
+ return;
309
+ }
310
+ if ('result' in message) {
311
+ pending.resolve(message.result);
312
+ return;
313
+ }
314
+ pending.reject(new Error(`mcp server "${connection.serverName}" sent response without result or error for ${pending.method}`));
315
+ }
316
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,171 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { connect, disconnect, listTools, mcpServerConfigSchema, } from './client.js';
6
+ import { getMcpTrust } from './trust.js';
7
+ /**
8
+ * MCP server registry — loads `.pugi/mcp.json` (workspace-scoped) and
9
+ * `~/.pugi/mcp.json` (user-scoped), merges with the user-level trust
10
+ * ledger, and surfaces approved tools into the toolRegistry shape.
11
+ *
12
+ * Load order:
13
+ * 1. User config (`~/.pugi/mcp.json`) — always loaded.
14
+ * 2. Workspace config (`<workspaceRoot>/.pugi/mcp.json`) — loaded if
15
+ * present; workspace entries override user entries by name.
16
+ *
17
+ * Trust resolution:
18
+ * - The trust state stored in `~/.pugi/trust-mcp.json` always wins.
19
+ * - If no ledger entry exists, the file-level `trust` field acts as
20
+ * the seed value (so a `~/.pugi/mcp.json` declaring `trust: trusted`
21
+ * auto-approves servers the user already trusts).
22
+ *
23
+ * Surfaced tool shape (M1 minimum):
24
+ * - `name`: `mcp.<server>.<tool>` (avoids collision with built-ins).
25
+ * - `permission`: `mcp` (the permission engine's MCP route).
26
+ * - `risk`: `medium` if server is trusted, `high` if pending/denied
27
+ * (pending/denied tools are filtered before reaching surfaceTools,
28
+ * so risk-high is a defensive backstop, not an exposed surface).
29
+ * - `concurrencySafe`: false (MCP tools may have side effects; the
30
+ * permission engine serializes them).
31
+ * - `m1`: true (everything here ships in M1).
32
+ *
33
+ * The registry does NOT auto-connect to pending or denied servers. Tools
34
+ * surface only for `trusted` entries; everything else returns a state
35
+ * record with `connection: undefined` so the user can see the wiring
36
+ * intent without exposing pending servers to the engine loop.
37
+ */
38
+ const mcpFileSchema = z.object({
39
+ servers: z.record(mcpServerConfigSchema).default({}),
40
+ });
41
+ /**
42
+ * Load and (optionally) connect every approved MCP server defined in the
43
+ * workspace + user configs. Pending and denied servers stay in the
44
+ * `servers` map but are NOT spawned.
45
+ */
46
+ export async function loadMcpRegistry(workspaceRoot, options = {}) {
47
+ const shouldConnect = options.connect !== false;
48
+ const userConfig = readMcpFile(resolve(userHomeDir(), 'mcp.json'));
49
+ const workspaceConfig = readMcpFile(resolve(workspaceRoot, '.pugi/mcp.json'));
50
+ const merged = new Map();
51
+ for (const [name, config] of Object.entries(userConfig))
52
+ merged.set(name, config);
53
+ for (const [name, config] of Object.entries(workspaceConfig))
54
+ merged.set(name, config);
55
+ const servers = new Map();
56
+ for (const [name, config] of merged) {
57
+ const ledgerTrust = await getMcpTrust(name);
58
+ // Treat missing-ledger-entry (pending in the ledger) PLUS a trusted
59
+ // file-level seed as trusted. This lets a user pre-approve servers
60
+ // declared in their own user config without manually running the
61
+ // trust command for each one. Workspace-declared `trust: trusted`
62
+ // is NOT honoured this way — the workspace cannot opt itself in,
63
+ // which is the whole point of the gate.
64
+ const trust = await resolveTrust(name, config, ledgerTrust, userConfig);
65
+ const state = {
66
+ name,
67
+ config,
68
+ trust,
69
+ surfacedTools: [],
70
+ };
71
+ if (shouldConnect && trust === 'trusted') {
72
+ try {
73
+ const connection = await connect(name, config);
74
+ state.connection = connection;
75
+ state.surfacedTools = await listTools(connection);
76
+ }
77
+ catch (error) {
78
+ state.lastError = error instanceof Error ? error.message : String(error);
79
+ // Defensive: even if listTools failed mid-handshake, we still
80
+ // own the connection lifecycle. Tear it down so we do not leak.
81
+ if (state.connection) {
82
+ await disconnect(state.connection).catch(() => { });
83
+ delete state.connection;
84
+ }
85
+ }
86
+ }
87
+ servers.set(name, state);
88
+ }
89
+ return {
90
+ servers,
91
+ surfaceTools: () => surfaceToolDefinitions(servers),
92
+ shutdown: async () => {
93
+ await Promise.all(Array.from(servers.values()).map(async (state) => {
94
+ if (state.connection) {
95
+ await disconnect(state.connection).catch(() => { });
96
+ }
97
+ }));
98
+ },
99
+ };
100
+ }
101
+ function userHomeDir() {
102
+ return process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
103
+ }
104
+ function readMcpFile(path) {
105
+ if (!existsSync(path))
106
+ return {};
107
+ let raw;
108
+ try {
109
+ raw = readFileSync(path, 'utf8');
110
+ }
111
+ catch {
112
+ return {};
113
+ }
114
+ if (raw.trim() === '')
115
+ return {};
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(raw);
119
+ }
120
+ catch (error) {
121
+ throw new Error(`Failed to parse MCP config at ${path}: ${error instanceof Error ? error.message : String(error)}. ` +
122
+ `Run \`pugi config mcp list\` to see the loaded servers.`);
123
+ }
124
+ const result = mcpFileSchema.safeParse(parsed);
125
+ if (!result.success) {
126
+ const issues = result.error.issues
127
+ .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
128
+ .join('; ');
129
+ throw new Error(`MCP config at ${path} failed validation: ${issues}. ` +
130
+ `Expected shape: { "servers": { "<name>": { "command": "...", "args": [...], "env": {...}, "trust": "pending|trusted|denied" } } }`);
131
+ }
132
+ return result.data.servers;
133
+ }
134
+ async function resolveTrust(name, config, ledgerTrust, userConfig) {
135
+ // If the operator explicitly recorded a state, that wins.
136
+ // The ledger default (`pending`) only acts as the fallback when no
137
+ // entry exists. We cannot distinguish "no entry" from "entry says
138
+ // pending" via the public API by design — both are non-decisions and
139
+ // both should respect the seed value if it is `trusted` and the seed
140
+ // came from the user-level file.
141
+ const declaredInUserConfig = Object.prototype.hasOwnProperty.call(userConfig, name);
142
+ if (ledgerTrust !== 'pending')
143
+ return ledgerTrust;
144
+ if (declaredInUserConfig && config.trust === 'trusted')
145
+ return 'trusted';
146
+ if (declaredInUserConfig && config.trust === 'denied')
147
+ return 'denied';
148
+ return 'pending';
149
+ }
150
+ function surfaceToolDefinitions(servers) {
151
+ const out = [];
152
+ for (const state of servers.values()) {
153
+ if (state.trust !== 'trusted')
154
+ continue;
155
+ for (const tool of state.surfacedTools) {
156
+ out.push({
157
+ name: `mcp.${state.name}.${tool.name}`,
158
+ permission: 'mcp',
159
+ // Trusted MCP tools default to medium risk. Higher-risk
160
+ // classification (network egress, destructive ops) is a future
161
+ // iteration that requires per-tool metadata MCP does not yet
162
+ // standardise.
163
+ risk: 'medium',
164
+ concurrencySafe: false,
165
+ m1: true,
166
+ });
167
+ }
168
+ }
169
+ return out.sort((a, b) => a.name.localeCompare(b.name));
170
+ }
171
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { z } from 'zod';
5
+ /**
6
+ * MCP server trust ledger.
7
+ *
8
+ * Lives at `~/.pugi/trust-mcp.json` and is keyed by server name (the key
9
+ * used in `.pugi/mcp.json` / `~/.pugi/mcp.json` `servers` map).
10
+ *
11
+ * Trust states:
12
+ * - `pending` — declared but not yet approved. Connection refused,
13
+ * tools NOT surfaced to the agent.
14
+ * - `trusted` — operator approved this server. Tools surface as
15
+ * `mcp.<server>.<tool>` and the registry will spawn the child.
16
+ * - `denied` — operator blocked this server. Connection refused.
17
+ *
18
+ * Why a separate ledger instead of mutating `.pugi/mcp.json` in place:
19
+ * - `.pugi/mcp.json` is committed in some teams' workspaces. The trust
20
+ * state is per-operator and must not bleed across users via git.
21
+ * - `~/.pugi/trust-mcp.json` is the user-level source of truth and
22
+ * overrides whatever trust value is declared in the workspace
23
+ * `mcp.json`. A repo cannot opt itself in.
24
+ *
25
+ * The PUGI_HOME env var redirects the ledger path for tests.
26
+ */
27
+ export const mcpTrustStateSchema = z.enum(['pending', 'trusted', 'denied']);
28
+ const trustLedgerSchema = z.object({
29
+ schema: z.number().int().positive().default(1),
30
+ entries: z
31
+ .record(z.object({
32
+ state: mcpTrustStateSchema,
33
+ decidedAt: z.string().datetime(),
34
+ decidedBy: z.string().min(1).optional(),
35
+ }))
36
+ .default({}),
37
+ });
38
+ const TRUST_LEDGER_FILENAME = 'trust-mcp.json';
39
+ function ledgerPath() {
40
+ const home = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
41
+ return resolve(home, TRUST_LEDGER_FILENAME);
42
+ }
43
+ function readLedger() {
44
+ const path = ledgerPath();
45
+ if (!existsSync(path)) {
46
+ return { schema: 1, entries: {} };
47
+ }
48
+ const raw = readFileSync(path, 'utf8');
49
+ if (raw.trim() === '') {
50
+ return { schema: 1, entries: {} };
51
+ }
52
+ const parsed = JSON.parse(raw);
53
+ return trustLedgerSchema.parse(parsed);
54
+ }
55
+ function writeLedger(ledger) {
56
+ const path = ledgerPath();
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ // 0o600 — trust ledger reveals which MCP servers the operator has
59
+ // approved on this machine. Not secret per se, but no reason to make
60
+ // it world-readable either.
61
+ writeFileSync(path, `${JSON.stringify(ledger, null, 2)}\n`, {
62
+ encoding: 'utf8',
63
+ mode: 0o600,
64
+ });
65
+ }
66
+ export async function getMcpTrust(serverName) {
67
+ const ledger = readLedger();
68
+ const entry = ledger.entries[serverName];
69
+ return entry ? entry.state : 'pending';
70
+ }
71
+ export async function setMcpTrust(serverName, state, decidedBy) {
72
+ const ledger = readLedger();
73
+ ledger.entries[serverName] = {
74
+ state,
75
+ decidedAt: new Date().toISOString(),
76
+ ...(decidedBy ? { decidedBy } : {}),
77
+ };
78
+ writeLedger(ledger);
79
+ }
80
+ export async function listMcpTrust() {
81
+ const ledger = readLedger();
82
+ return Object.entries(ledger.entries)
83
+ .map(([name, entry]) => ({
84
+ name,
85
+ state: entry.state,
86
+ decidedAt: entry.decidedAt,
87
+ ...(entry.decidedBy ? { decidedBy: entry.decidedBy } : {}),
88
+ }))
89
+ .sort((a, b) => a.name.localeCompare(b.name));
90
+ }
91
+ //# sourceMappingURL=trust.js.map
@@ -0,0 +1,63 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { basename, relative, resolve } from 'node:path';
3
+ /**
4
+ * Resolve and validate that an inputPath stays within the workspace.
5
+ *
6
+ * Defends against:
7
+ * 1. relative-path traversal (`../etc/passwd`)
8
+ * 2. URL-encoded traversal (`..%2Fetc%2Fpasswd`)
9
+ * 3. symlink escapes at the target itself (`alias-link -> /etc/passwd`)
10
+ *
11
+ * The previous implementation also resolved the parent's realpath and
12
+ * compared to the workspace root. That broke `pugi explain .` on macOS
13
+ * where the workspace is under a symlinked prefix (`/tmp` → `/private/tmp`)
14
+ * because the parent's realpath legitimately sits one level above the
15
+ * workspace. The fix: compute the target's realpath (or the literal
16
+ * resolved path when the target does not yet exist) and require that to
17
+ * stay inside the workspace's realpath. Parent-chain symlinks that
18
+ * actually escape will fail this check; benign system symlinks above
19
+ * the workspace will not.
20
+ *
21
+ * Throws when the resolved path is outside the workspace. Returns the
22
+ * literal absolute path on disk (suitable for `readFileSync` /
23
+ * `writeFileSync`).
24
+ */
25
+ export function resolveWorkspacePath(root, inputPath) {
26
+ const decoded = decodeURIComponent(inputPath);
27
+ const target = resolve(root, decoded);
28
+ const realRoot = realpathSync.native(root);
29
+ let realTarget;
30
+ try {
31
+ realTarget = realpathSync.native(target);
32
+ }
33
+ catch (error) {
34
+ const code = error.code;
35
+ if (code !== 'ENOENT' && code !== 'ENOTDIR')
36
+ throw error;
37
+ // Target does not exist yet (write/create). Anchor against the
38
+ // parent's realpath so the new file we are about to create stays
39
+ // inside the workspace.
40
+ let realParent;
41
+ try {
42
+ realParent = realpathSync.native(resolve(target, '..'));
43
+ }
44
+ catch (parentError) {
45
+ const parentCode = parentError.code;
46
+ if (parentCode !== 'ENOENT' && parentCode !== 'ENOTDIR')
47
+ throw parentError;
48
+ throw new Error(`Path escapes workspace (missing parent): ${inputPath}`);
49
+ }
50
+ realTarget = resolve(realParent, basename(target));
51
+ }
52
+ if (!isInsideWorkspace(realTarget, realRoot)) {
53
+ throw new Error(`Path escapes workspace: ${inputPath}`);
54
+ }
55
+ return target;
56
+ }
57
+ function isInsideWorkspace(child, workspaceRoot) {
58
+ if (child === workspaceRoot)
59
+ return true;
60
+ const rel = relative(workspaceRoot, child);
61
+ return Boolean(rel) && !rel.startsWith('..') && rel !== '..';
62
+ }
63
+ //# sourceMappingURL=path-security.js.map