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/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +501 -0
- package/dist/build.js +41 -0
- package/dist/config.js +117 -0
- package/dist/constants.js +69 -0
- package/dist/context.js +44 -0
- package/dist/cost.js +141 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +314 -0
- package/dist/llm.js +276 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +298 -0
- package/dist/provider-anthropic.js +332 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +361 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +51 -4
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
|
+
}
|