s-app-scoder-agent 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) client for stdio servers.
3
+ *
4
+ * Implements JSON-RPC 2.0 over stdin/stdout with line-delimited messages.
5
+ * Connects to configured stdio servers, exposes their tools as
6
+ * `mcp__<server>__<tool>` prefixed names in the agent's tool registry.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { CLIENT_NAME, getVersion } from "./version.js";
10
+ export const MCP_TOOL_PREFIX = "mcp__";
11
+ export function mcpToolKey(serverName, toolName) {
12
+ return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
13
+ }
14
+ /** Expand {env:VAR} references in config env values. */
15
+ function expandEnv(env) {
16
+ const out = {};
17
+ for (const [k, v] of Object.entries(env ?? {})) {
18
+ out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => process.env[n] ?? "");
19
+ }
20
+ return out;
21
+ }
22
+ // ── JSON-RPC stdio connection ─────────────────────────────────────────────────
23
+ export class McpConnection {
24
+ name;
25
+ cfg;
26
+ proc = null;
27
+ nextId = 1;
28
+ pending = new Map();
29
+ buf = "";
30
+ constructor(name, cfg) {
31
+ this.name = name;
32
+ this.cfg = cfg;
33
+ }
34
+ get alive() {
35
+ return this.proc !== null && this.proc.exitCode === null;
36
+ }
37
+ async connect(timeoutMs = 15_000) {
38
+ const p = spawn(this.cfg.command, this.cfg.args ?? [], {
39
+ stdio: ["pipe", "pipe", "pipe"],
40
+ env: { ...process.env, ...expandEnv(this.cfg.env) },
41
+ });
42
+ this.proc = p;
43
+ p.stderr?.on("data", () => { });
44
+ p.on("error", (e) => this.drain(new Error(`spawn: ${e.message}`)));
45
+ p.on("exit", () => this.drain(new Error("process exited")));
46
+ p.stdout?.on("data", (c) => this.onChunk(c));
47
+ // MCP handshake: initialize + notifications/initialized
48
+ await this.rpc("initialize", {
49
+ protocolVersion: "2024-11-05",
50
+ capabilities: {},
51
+ clientInfo: { name: CLIENT_NAME, version: getVersion() },
52
+ }, timeoutMs);
53
+ this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
54
+ }
55
+ async listTools(timeoutMs = 120_000) {
56
+ const res = await this.rpc("tools/list", {}, timeoutMs);
57
+ return (res.tools ?? []).filter((t) => typeof t.name === "string");
58
+ }
59
+ async callTool(name, args, timeoutMs = 120_000) {
60
+ const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
61
+ const isErr = res.isError === true;
62
+ const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
63
+ return { ok: !isErr, output: parts.join("\n") || "(empty result)" };
64
+ }
65
+ kill() {
66
+ this.drain(new Error("disconnected"));
67
+ if (this.proc) {
68
+ try {
69
+ this.proc.kill();
70
+ }
71
+ catch { /* already dead */ }
72
+ this.proc = null;
73
+ }
74
+ }
75
+ // ── internals ─────────────────────────────────────────────────────────────
76
+ send(msg) {
77
+ if (!this.proc?.stdin?.writable)
78
+ return;
79
+ try {
80
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
81
+ }
82
+ catch { /* ignore */ }
83
+ }
84
+ async rpc(method, params, timeoutMs) {
85
+ if (!this.alive)
86
+ throw new Error(`MCP server "${this.name}" not connected`);
87
+ const id = this.nextId++;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => {
90
+ this.pending.delete(id);
91
+ reject(new Error(`MCP "${method}" timed out after ${(timeoutMs / 1000).toFixed(0)}s`));
92
+ }, timeoutMs);
93
+ this.pending.set(id, {
94
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
95
+ reject: (e) => { clearTimeout(timer); reject(e); },
96
+ });
97
+ this.send({ jsonrpc: "2.0", id, method, params });
98
+ });
99
+ }
100
+ onChunk(chunk) {
101
+ this.buf += chunk.toString("utf8");
102
+ let i;
103
+ while ((i = this.buf.indexOf("\n")) !== -1) {
104
+ const line = this.buf.slice(0, i).trim();
105
+ this.buf = this.buf.slice(i + 1);
106
+ if (!line)
107
+ continue;
108
+ let msg;
109
+ try {
110
+ msg = JSON.parse(line);
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ if (msg.id === undefined)
116
+ continue;
117
+ const pending = this.pending.get(msg.id);
118
+ if (!pending)
119
+ continue;
120
+ this.pending.delete(msg.id);
121
+ if (msg.error)
122
+ pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
123
+ else
124
+ pending.resolve(msg.result ?? {});
125
+ }
126
+ }
127
+ drain(err) {
128
+ for (const [, p] of this.pending)
129
+ p.reject(err);
130
+ this.pending.clear();
131
+ }
132
+ }
133
+ export class McpManager {
134
+ configs;
135
+ conns = new Map();
136
+ tools = new Map();
137
+ constructor(configs) {
138
+ this.configs = configs;
139
+ }
140
+ /** Connect all servers; skip failures with a warning. Returns discovered tool count. */
141
+ async connectAll() {
142
+ await Promise.allSettled(Object.entries(this.configs).map(async ([name, cfg]) => {
143
+ const conn = new McpConnection(name, cfg);
144
+ await conn.connect();
145
+ const defs = await conn.listTools();
146
+ for (const def of defs) {
147
+ const key = mcpToolKey(name, def.name);
148
+ if (this.tools.has(key))
149
+ throw new Error(`Duplicate MCP tool: ${key}`);
150
+ this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
151
+ }
152
+ this.conns.set(name, conn);
153
+ })).then((results) => {
154
+ results.forEach((r, i) => {
155
+ const name = Object.keys(this.configs)[i];
156
+ if (r.status === "rejected") {
157
+ console.error(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
158
+ }
159
+ else {
160
+ const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
161
+ console.error(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"}`);
162
+ }
163
+ });
164
+ });
165
+ return this.tools.size;
166
+ }
167
+ hasTool(name) {
168
+ return this.tools.has(name);
169
+ }
170
+ getTool(name) {
171
+ return this.tools.get(name);
172
+ }
173
+ /** Convert all discovered MCP tools to OpenAI function-calling specs. */
174
+ toToolSpecs() {
175
+ return [...this.tools.values()].map(({ conn: _, serverName, toolName, def }) => ({
176
+ type: "function",
177
+ function: {
178
+ name: mcpToolKey(serverName, toolName),
179
+ description: def.description ?? `MCP tool "${toolName}" from server "${serverName}"`,
180
+ parameters: def.inputSchema ?? { type: "object", properties: {} },
181
+ },
182
+ }));
183
+ }
184
+ killAll() {
185
+ for (const c of this.conns.values())
186
+ c.kill();
187
+ this.conns.clear();
188
+ }
189
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Permission & Safety System
3
+ *
4
+ * Layer giữa LLM tool call và actual execution.
5
+ * Cung cấp 3 mode: auto (run everything), ask (prompt for shell commands), readonly.
6
+ * Luôn deny catastrophic commands bất kể mode nào.
7
+ */
8
+ import { createInterface } from "node:readline";
9
+ // ---------------------------------------------------------------------------
10
+ // Catastrophic command patterns — always denied, no exceptions
11
+ // ---------------------------------------------------------------------------
12
+ const CATASTROPHIC_PATTERNS = [
13
+ // rm -rf (original) — kept for backwards compatibility
14
+ /rm\s+-rf\s+[/~"]|(?:rm\s+-rf\s+\/\*)/i,
15
+ // rm with whitespace/flag variants: "rm -rf /", "rm -fr /", "rm -r -f /",
16
+ // "rm --recursive --force /", "rm -rvf /" … i.e. any rm followed by one or
17
+ // more option flags and then a target at filesystem root or home.
18
+ // Does NOT match common safe commands: "rm -rf node_modules", "rm file.txt",
19
+ // "rm ./build" (target must START with / or ~).
20
+ /\brm\s+(?:-{1,2}[\w-]+\s+)+["']?[/~](?:\s|$|\/)/,
21
+ // rm with a single flag token containing both r/R and f (e.g. -fr, -Rf, -rvf)
22
+ // targeting / or ~ — covered above, but also catch "rm -fr --no-preserve-root".
23
+ /\brm\s+[^|;&>]*--no-preserve-root/i,
24
+ // base64/sh -c wrappers used to smuggle destructive commands past filters:
25
+ // echo cm0gLXJmIC8= | base64 -d | sh
26
+ // sh -c "rm -rf /"
27
+ /base64\s+(?:-d|-D|--decode)\b[^|;&]*\|\s*(?:ba|z|da)?sh\b/i,
28
+ /\b(?:ba|z|da)?sh\s+-c\s+["'][^"']*\brm\s+[^"']*\s-[a-zA-Z]*[rf]/i,
29
+ // Filesystem destruction
30
+ /\bmkfs(?:\.[a-z0-9]+)?\b/i,
31
+ /dd\s+.*of=\//i,
32
+ /chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
33
+ // chmod -R 777 on / or ~ (any flag spelling: -R, -r, -Rf, --recursive)
34
+ /\bchmod\s+(?:-{1,2}[\w-]+\s+)+[0-7]{3,4}\s+["']?[/~](?:\s|$|\/)/,
35
+ // Redirect raw writes to block devices: > /dev/sda, > /dev/nvme0n1, dd > disk...
36
+ />\s*\/dev\/(?:sd[a-z]+|hd[a-z]+|nvme\d+n\d+(?:p\d+)?|disk\d|rdisk\d)/i,
37
+ /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/i,
38
+ // curl/wget pipe-to-shell: match any content between command and '| sh'/'| bash'
39
+ // Handles -qO-, -X POST, etc. flags; URL-like patterns must contain '/' or '.'
40
+ /curl\s+.*[/.].*\s*\|\s*sh\b/i,
41
+ /curl\s+.*[/.].*\s*\|\s*bash\b/i,
42
+ /wget\s+.*[/.].*\s*\|\s*sh\b/i,
43
+ /wget\s+.*[/.].*\s*\|\s*bash\b/i,
44
+ // sudo rm with any recursive/force flag combo: "sudo rm -rf x", "sudo rm -fr /usr"
45
+ /sudo\s+rm\s+[^|;&]*-\w*[rR]/i,
46
+ /\bshutdown\b|\breboot\b|\binit\s+[06]\b/i,
47
+ />\s*\/dev\/sda/i,
48
+ /\|\s*nc\s+-e\s+/i,
49
+ /\|\s*bash\s+-i/i,
50
+ /sysctl\s+-w\s+kernel\.panic=/i,
51
+ /echo\s+[^|]*>\s*\/etc\/sudoers/i,
52
+ ];
53
+ function isCatastrophic(command) {
54
+ return CATASTROPHIC_PATTERNS.some((p) => p.test(command));
55
+ }
56
+ export function isDangerousCommand(command) {
57
+ return isCatastrophic(command);
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Session allowlist (runtime, in-memory)
61
+ // ---------------------------------------------------------------------------
62
+ export class SessionAllowlist {
63
+ prefixes = new Set();
64
+ addPrefix(prefix) {
65
+ // Normalize to a word-bounded prefix: "git" -> "git " so that allowing
66
+ // "git" never matches a different binary like "gitx" or "github-cli".
67
+ const t = prefix.trim().replace(/\s+$/, "");
68
+ if (t.length === 0)
69
+ return;
70
+ this.prefixes.add(`${t} `);
71
+ }
72
+ hasMatch(command) {
73
+ return [...this.prefixes].some((p) => command.startsWith(p));
74
+ }
75
+ clear() {
76
+ this.prefixes.clear();
77
+ }
78
+ }
79
+ function extractFirstWord(command) {
80
+ return command.trim().split(/\s+/)[0] ?? "";
81
+ }
82
+ /**
83
+ * Classify a command given current permission config and session allowlist.
84
+ */
85
+ export function classifyCommand(command, mode, configAllow, configDeny, sessionAllow) {
86
+ const safeFirst = extractFirstWord(command);
87
+ // 1. Catastrophic — always deny
88
+ if (isCatastrophic(command)) {
89
+ return { decision: "deny", reason: "matches catastrophic command pattern" };
90
+ }
91
+ // 2. Config deny list
92
+ if (configDeny.some((p) => command.startsWith(p.trim()))) {
93
+ return { decision: "deny", reason: "matches denyCommands pattern in config" };
94
+ }
95
+ // 3. Session allowlist
96
+ if (sessionAllow.hasMatch(command)) {
97
+ return { decision: "allow", reason: "allowed by session allowlist" };
98
+ }
99
+ // 4. Config allow list
100
+ if (configAllow.some((p) => command.startsWith(p.trim()))) {
101
+ return { decision: "allow", reason: "allowed by config allowCommands" };
102
+ }
103
+ // 5. Mode-dependent
104
+ switch (mode) {
105
+ case "auto":
106
+ return { decision: "allow", reason: "auto mode — run without confirmation" };
107
+ case "readonly":
108
+ return {
109
+ decision: "deny",
110
+ reason: "readonly mode — shell commands are disabled",
111
+ };
112
+ case "ask":
113
+ return {
114
+ decision: "ask",
115
+ reason: "requires user confirmation",
116
+ alwaysAllowPrefix: safeFirst + " ",
117
+ };
118
+ }
119
+ }
120
+ /**
121
+ * Prompt user for permission. Returns true/false. "always" is signaled via
122
+ * the alwaysAllowPrefix callback.
123
+ * Uses AbortController so timeoutSecs is actually enforced (deny on timeout).
124
+ */
125
+ export async function promptForPermission(opts, onAlwaysAllow) {
126
+ const cmd = opts.command.length > 80 ? opts.command.slice(0, 80) + "..." : opts.command;
127
+ const timeoutMs = (opts.timeoutSecs ?? 60) * 1000;
128
+ const ac = new AbortController();
129
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
130
+ const rl = createInterface({ input: process.stdin, output: process.stdout, signal: ac.signal });
131
+ // NOTE: Node's rl.question() callback is NOT invoked (and the promise never
132
+ // settles) when the interface is closed by an aborted signal — so we must
133
+ // race it against an abort promise or promptForPermission would hang forever.
134
+ const question = (q) => new Promise((res, _rej) => {
135
+ rl.question(q, (ans) => res(ans));
136
+ });
137
+ let rejectAborted = null;
138
+ const aborted = new Promise((_res, rej) => {
139
+ rejectAborted = rej;
140
+ });
141
+ ac.signal.addEventListener("abort", () => {
142
+ const e = new Error("permission prompt timed out");
143
+ e.name = "AbortError";
144
+ rejectAborted?.(e);
145
+ }, { once: true });
146
+ try {
147
+ let answer = "";
148
+ while (true) {
149
+ const timeoutStr = Math.ceil((opts.timeoutSecs ?? 60)) + "s";
150
+ let prompt = `⚠ run_command requires approval: ${cmd} (timeout in ${timeoutStr})\n`;
151
+ if (opts.alwaysAllowPrefix) {
152
+ prompt += `[y] yes [a] always allow "${opts.alwaysAllowPrefix}" [n] no [q] quit: `;
153
+ }
154
+ else {
155
+ prompt += `[y] yes [n] no [q] quit: `;
156
+ }
157
+ try {
158
+ answer = (await Promise.race([question(prompt), aborted])).trim().toLowerCase();
159
+ }
160
+ catch {
161
+ // Timeout — AbortController triggered
162
+ clearTimeout(timer);
163
+ rl.close();
164
+ return false; // safe default: deny on timeout
165
+ }
166
+ if (answer === "q") {
167
+ clearTimeout(timer);
168
+ rl.close();
169
+ return false; // caller will abort
170
+ }
171
+ if (answer === "y") {
172
+ clearTimeout(timer);
173
+ rl.close();
174
+ return true;
175
+ }
176
+ if (answer === "n") {
177
+ clearTimeout(timer);
178
+ rl.close();
179
+ return false;
180
+ }
181
+ if (answer === "a" && opts.alwaysAllowPrefix) {
182
+ clearTimeout(timer);
183
+ rl.close();
184
+ onAlwaysAllow?.(opts.alwaysAllowPrefix);
185
+ return true;
186
+ }
187
+ // invalid answer — loop again (timeout still ticking)
188
+ }
189
+ }
190
+ finally {
191
+ clearTimeout(timer);
192
+ try {
193
+ rl.close();
194
+ }
195
+ catch { /* already closed */ }
196
+ }
197
+ }
198
+ export class PermissionChecker {
199
+ config;
200
+ sessionAllow = new SessionAllowlist();
201
+ allowAllEdits = false;
202
+ constructor(config) {
203
+ this.config = config;
204
+ }
205
+ /**
206
+ * Check if a command should run. Returns null = allowed immediately,
207
+ * or a PermissionResult if user needs to be consulted.
208
+ */
209
+ classify(command) {
210
+ return classifyCommand(command, this.config.mode, this.config.allowCommands, this.config.denyCommands, this.sessionAllow);
211
+ }
212
+ /**
213
+ * Ask user interactively. Returns true = allowed, false = denied.
214
+ * In non-interactive mode, always returns false for "ask" decisions.
215
+ */
216
+ async confirm(command) {
217
+ const result = this.classify(command);
218
+ if (result.decision === "allow")
219
+ return true;
220
+ if (result.decision === "deny")
221
+ return false;
222
+ // Decision is "ask"
223
+ if (this.config.nonInteractive) {
224
+ // In non-interactive (CI), deny commands that need asking
225
+ return false;
226
+ }
227
+ return promptForPermission({
228
+ command,
229
+ alwaysAllowPrefix: result.alwaysAllowPrefix,
230
+ }, (prefix) => this.sessionAllow.addPrefix(prefix));
231
+ }
232
+ get sessionAllowlist() {
233
+ return this.sessionAllow;
234
+ }
235
+ /**
236
+ * Ask user to approve a file write/edit, showing the diff first.
237
+ * Returns true = approved, false = rejected.
238
+ * Enforced timeout (default 120s) — deny on timeout (safe default).
239
+ */
240
+ async confirmEdit(path, diffText, timeoutSecs = 120) {
241
+ if (this.allowAllEdits)
242
+ return true;
243
+ if (this.config.nonInteractive) {
244
+ return false;
245
+ }
246
+ const ac = new AbortController();
247
+ const timer = setTimeout(() => ac.abort(), timeoutSecs * 1000);
248
+ const rl = createInterface({ input: process.stdin, output: process.stdout, signal: ac.signal });
249
+ const question = (q) => new Promise((res) => rl.question(q, (ans) => res(ans)));
250
+ let rejectAborted = null;
251
+ const aborted = new Promise((_res, rej) => {
252
+ rejectAborted = rej;
253
+ });
254
+ ac.signal.addEventListener("abort", () => {
255
+ const e = new Error("edit confirmation timed out");
256
+ e.name = "AbortError";
257
+ rejectAborted?.(e);
258
+ }, { once: true });
259
+ try {
260
+ for (;;) {
261
+ let answer;
262
+ try {
263
+ answer = (await Promise.race([
264
+ question(`\nApply this change to ${path}? [y]es [a] always (this session) [n]o [q]uit: `),
265
+ aborted,
266
+ ])).trim().toLowerCase();
267
+ }
268
+ catch {
269
+ // timeout — deny (safe default)
270
+ return false;
271
+ }
272
+ if (answer === "y")
273
+ return true;
274
+ if (answer === "a") {
275
+ this.allowAllEdits = true;
276
+ return true;
277
+ }
278
+ if (answer === "n")
279
+ return false;
280
+ if (answer === "q") {
281
+ // Graceful abort signal instead of process.exit from library code
282
+ this.abortRequested = true;
283
+ return false;
284
+ }
285
+ // invalid answer — loop again (timeout still ticking)
286
+ }
287
+ }
288
+ finally {
289
+ clearTimeout(timer);
290
+ try {
291
+ rl.close();
292
+ }
293
+ catch { /* already closed */ }
294
+ }
295
+ }
296
+ /** Set when the user pressed [q]uit in confirmEdit — the agent should stop. */
297
+ abortRequested = false;
298
+ }