Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
bash.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
14
15#include <nlohmann/json.hpp>
16
17#include <array>
18#include <cerrno>
19#include <chrono>
20#include <csignal>
21#include <cstdio>
22#include <cstring>
23#include <filesystem>
24#include <stdexcept>
25#include <string>
26#include <thread>
27
28#include <fcntl.h>
29#include <poll.h>
30#include <spawn.h>
31#include <sys/types.h>
32#include <sys/wait.h>
33#include <unistd.h>
34
35extern char** environ; // NOLINT — POSIX, for posix_spawn
36
37static auto logger = entropic::log::get("mcp.bash");
38
39namespace entropic {
40
41// ── working_dir validation ──────────────────────────────────────
42
61static bool is_safe_cwd(const std::string& cwd) {
62 static constexpr const char* unsafe_chars = ";&|`$<>\n\r\\\"'*?(){}[]";
63 if (cwd.find_first_of(unsafe_chars) != std::string::npos) {
64 return false;
65 }
66 std::error_code ec;
67 return std::filesystem::is_directory(cwd, ec);
68}
69
70// ── Running one command (v2.13.0) ───────────────────────────────
71//
72// popen() gave no handle on the process: `timeout_` was stored and never
73// read, `sleep 30` held the tool call (and the run thread under it) for
74// 30 s, a background child that inherited stdout kept the read loop open
75// for its whole life, and one that did not simply outlived the call. The
76// command now leads its OWN process group, so the whole tree can be killed
77// as one: on the deadline, and — for anything left running — when the
78// shell exits.
79
80namespace {
81
82using Clock = std::chrono::steady_clock;
83
87constexpr std::chrono::milliseconds kDrainGrace{2000};
88
90constexpr int kPollMs = 50;
91
97struct ShellRun {
98 std::string output;
99 int exit_code = -1;
100 bool timed_out = false;
101 bool cancelled = false;
102 long long elapsed_ms = 0;
103};
104
121pid_t spawn_shell(const std::string& cmd, int& read_fd) {
122 int fds[2] = {-1, -1};
123 read_fd = -1;
124 if (::pipe2(fds, O_CLOEXEC) != 0) {
125 logger->error("bash: pipe2 failed: {}", std::strerror(errno));
126 return -1;
127 }
131 O_RDONLY, 0);
137 posix_spawnattr_setpgroup(&attr, 0); // pgid = the child's own pid
138 const char* argv[] = {"/bin/sh", "-c", cmd.c_str(), nullptr};
139 pid_t pid = -1;
140 int rc = ::posix_spawn(&pid, "/bin/sh", &actions, &attr,
141 const_cast<char* const*>(argv), environ);
144 ::close(fds[1]);
145 if (rc != 0) {
146 logger->error("bash: posix_spawn failed: {}", std::strerror(rc));
147 ::close(fds[0]);
148 pid = -1;
149 }
150 read_fd = pid > 0 ? fds[0] : -1;
151 return pid;
152}
153
163bool pump(int fd, std::string& out, int wait_ms) {
164 pollfd pfd{fd, POLLIN, 0};
165 if (::poll(&pfd, 1, wait_ms) <= 0) {
166 return true; // nothing yet (or EINTR): still open
167 }
168 std::array<char, 4096> buf{};
169 ssize_t n = ::read(fd, buf.data(), buf.size());
170 if (n > 0) {
171 out.append(buf.data(), static_cast<size_t>(n));
172 }
173 return n > 0 || (n < 0 && (errno == EINTR || errno == EAGAIN));
174}
175
190bool shell_exited(pid_t pid) {
191 siginfo_t info{};
192 int rc = ::waitid(P_PID, static_cast<id_t>(pid), &info,
194 return (rc == 0 && info.si_pid == pid) || (rc == -1 && errno == ECHILD);
195}
196
201enum class ShellStop {
202 running,
203 exited,
204 deadline,
205 cancelled
206};
207
223ShellStop poll_stop(pid_t pid, Clock::time_point deadline) {
224 ShellStop stop = ShellStop::running;
225 if (shell_exited(pid)) {
226 stop = ShellStop::exited;
227 } else if (entropic::current_run_cancelled()) {
228 stop = ShellStop::cancelled;
229 } else if (Clock::now() >= deadline) {
230 stop = ShellStop::deadline;
231 }
232 return stop;
233}
234
246ShellStop collect_until_exit(pid_t pid, int fd, Clock::time_point deadline,
247 std::string& out) {
248 bool open = true;
249 ShellStop stop = ShellStop::running;
250 while (stop == ShellStop::running) {
251 if (open) {
252 open = pump(fd, out, kPollMs);
253 } else {
254 // The shell closed its output but is still running.
255 std::this_thread::sleep_for(std::chrono::milliseconds(kPollMs));
256 }
257 stop = poll_stop(pid, deadline);
258 }
259 return stop;
260}
261
273void drain(int fd, std::string& out) {
274 const auto until = Clock::now() + kDrainGrace;
275 bool open = true;
276 while (open && Clock::now() < until) {
277 open = pump(fd, out, kPollMs);
278 }
279 if (open) {
280 logger->warn("bash: output pipe still open {} ms after the process "
281 "group was killed — a process that left the group "
282 "holds it; returning without its output",
283 kDrainGrace.count());
284 }
285}
286
295int reap(pid_t pid) {
296 int status = 0;
297 pid_t rc = -1;
298 do {
299 rc = ::waitpid(pid, &status, 0);
300 } while (rc == -1 && errno == EINTR);
301 int code = -1;
302 if (rc == pid && WIFEXITED(status)) {
303 code = WEXITSTATUS(status);
304 } else if (rc == pid && WIFSIGNALED(status)) {
305 code = 128 + WTERMSIG(status);
306 }
307 return code;
308}
309
324ShellRun run_shell(const std::string& cmd, int timeout_s) {
325 ShellRun run;
326 const auto start = Clock::now();
327 int fd = -1;
328 const pid_t pid = spawn_shell(cmd, fd);
329 if (pid < 0) {
330 run.output = "Failed to start /bin/sh";
331 return run;
332 }
333 const auto stop = collect_until_exit(
334 pid, fd, start + std::chrono::seconds(timeout_s), run.output);
335 run.timed_out = stop == ShellStop::deadline;
336 run.cancelled = stop == ShellStop::cancelled;
337 // The whole group: a timed-out or cancelled tree, or what a finished
338 // command left running in the background.
339 ::kill(-pid, SIGKILL);
340 drain(fd, run.output);
341 ::close(fd);
342 run.exit_code = reap(pid);
343 run.elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
344 Clock::now() - start).count();
345 return run;
346}
347
362nlohmann::json timeout_error(const ShellRun& run, int limit_s) {
363 std::array<char, 32> secs{};
364 std::snprintf(secs.data(), secs.size(), "%.1f",
365 static_cast<double>(run.elapsed_ms) / 1000.0);
366 nlohmann::json err;
367 err["error"] = "timeout";
368 err["message"] = "Command exceeded the bash tool's "
369 + std::to_string(limit_s) + " s timeout (ran " + secs.data()
370 + " s) and was killed, together with every process it started. "
371 "Output printed before the kill is in `output`. Split the work "
372 "into shorter commands; the limit is set by the operator "
373 "(mcp.bash.timeout_seconds).";
374 err["timeout_seconds"] = limit_s;
375 err["elapsed_ms"] = run.elapsed_ms;
376 err["output"] = run.output;
377 return err;
378}
379
391nlohmann::json cancelled_error(const ShellRun& run) {
392 nlohmann::json err;
393 err["error"] = "cancelled";
394 err["message"] = "The session was interrupted while this command ran; "
395 "it was stopped together with every process it started. Output "
396 "printed before then is in `output`.";
397 err["elapsed_ms"] = run.elapsed_ms;
398 err["output"] = run.output;
399 return err;
400}
401
411nlohmann::json shell_result(const ShellRun& run, int limit_s,
412 const std::string& command) {
413 nlohmann::json result;
414 if (run.cancelled) {
415 logger->info("Bash: CANCELLED after {} ms — process group killed, "
416 "cmd='{}'", run.elapsed_ms, command);
417 result = cancelled_error(run);
418 } else if (run.timed_out) {
419 logger->warn("Bash: TIMEOUT after {} ms (limit {} s) — process "
420 "group killed, cmd='{}'", run.elapsed_ms, limit_s, command);
421 result = timeout_error(run, limit_s);
422 } else {
423 logger->info("Bash: exit={}, output={} chars, {} ms, cmd='{}'",
424 run.exit_code, run.output.size(), run.elapsed_ms, command);
425 result["exit_code"] = run.exit_code;
426 result["output"] = run.output;
427 }
428 return result;
429}
430
431} // namespace
432
433// ── ExecuteTool ─────────────────────────────────────────────────
434
440class ExecuteTool : public ToolBase {
441public:
450 : ToolBase(std::move(def)), server_(server) {}
451
459 ServerResponse execute(const std::string& args_json) override;
460
461private:
462 BashServer& server_;
463};
464
491 auto args = nlohmann::json::parse(args_json);
492 std::string command = args.at("command").get<std::string>();
493
494 std::string cwd = args.value(
495 "working_dir", server_.working_dir().string());
496
497 logger->info("[bash.execute] cmd='{}' cwd='{}'", command, cwd);
498
499 if (!is_safe_cwd(cwd)) {
500 logger->warn("Rejected unsafe working_dir: '{}'", cwd);
501 return {"Error: working_dir is not an existing directory or "
502 "contains shell metacharacters", {}};
503 }
504
505 std::string full_cmd =
506 "cd " + cwd + " && " + command + " 2>&1";
507
508 const int limit = server_.timeout();
509 auto run = run_shell(full_cmd, limit);
510 return {shell_result(run, limit, command).dump(), {}};
511}
512
513// ── BashServer ──────────────────────────────────────────────────
514
529 const std::filesystem::path& working_dir,
530 const std::string& data_dir,
531 int timeout)
532 : MCPServerBase("bash")
533 , working_dir_(working_dir)
534 , timeout_(timeout) {
535
537 "execute", "bash", data_dir + "/tools");
538
539 execute_tool_ = std::make_unique<ExecuteTool>(
540 std::move(def), *this);
541
542 register_tool(execute_tool_.get());
543
544 logger->info("BashServer initialized: cwd='{}' timeout={}s",
545 working_dir_.string(), timeout_);
546}
547
553BashServer::~BashServer() = default;
554
571std::string
572BashServer::get_permission_pattern(const std::string& tool_name, const std::string& args_json) const {
573
574 std::string base_cmd = "unknown";
575 try {
576 auto args = nlohmann::json::parse(args_json);
577 std::string cmd = args.at("command").get<std::string>();
578 auto space = cmd.find(' ');
579 base_cmd = (space != std::string::npos)
580 ? cmd.substr(0, space) : cmd;
581 } catch (const std::exception& e) {
582 logger->warn("Failed to parse command for permission: {}",
583 e.what());
584 }
585 return tool_name + ":" + base_cmd + " *";
586}
587
600bool BashServer::set_working_dir(const std::string& path) {
601 working_dir_ = path;
602 logger->info("Working directory set to: {}", path);
603 return true;
604}
605
613const std::filesystem::path& BashServer::working_dir() const {
614 return working_dir_;
615}
616
624 return timeout_;
625}
626
627} // namespace entropic
bool timed_out
The deadline expired and the group was killed.
Definition bash.cpp:100
std::string output
Combined stdout+stderr (partial on timeout)
Definition bash.cpp:98
long long elapsed_ms
Wall time, spawn to reap.
Definition bash.cpp:102
int exit_code
Shell status; 128+N if killed by signal N.
Definition bash.cpp:99
bool cancelled
The run was interrupted and the group was killed (gh#172)
Definition bash.cpp:101
Bash MCP server — shell command execution under a timeout.
Bash MCP server for shell command execution.
Definition bash.h:44
int timeout() const
Get the enforced per-command timeout.
Definition bash.cpp:623
const std::filesystem::path & working_dir() const
Get the working directory.
Definition bash.cpp:613
bool set_working_dir(const std::string &path) override
Set working directory.
Definition bash.cpp:600
~BashServer() override
Destructor.
BashServer(const std::filesystem::path &working_dir, const std::string &data_dir, int timeout=30)
Construct with working directory and data dir.
Definition bash.cpp:528
std::string get_permission_pattern(const std::string &tool_name, const std::string &args_json) const override
Permission pattern: "execute:{base_cmd} *".
Definition bash.cpp:572
Tool for executing shell commands.
Definition bash.cpp:440
ServerResponse execute(const std::string &args_json) override
Execute a shell command.
Definition bash.cpp:490
ExecuteTool(ToolDefinition def, BashServer &server)
Construct from tool definition with server ref.
Definition bash.cpp:449
Concrete base class for MCP servers (80% logic).
Definition server_base.h:66
void register_tool(ToolBase *tool)
Register a tool with this server.
Abstract base class for individual MCP tools.
Definition tool_base.h:52
std::string err
size_exceeded, when set
long limit
Results to keep.
int read_fd
Our end.
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
Activate model on GPU (WARM → ACTIVE).
static bool is_safe_cwd(const std::string &cwd)
Reject working_dir values that would smuggle shell syntax.
Definition bash.cpp:61
ToolDefinition load_tool_definition(const std::string &tool_name, const std::string &server_prefix, const std::string &data_dir)
Load a tool definition from a JSON file.
@ optional
optional: every escape asks the host's path approver
ENTROPIC_EXPORT bool current_run_cancelled()
Whether THIS thread's run has been cancelled.
Definition run_scope.cpp:45
@ read
read_file, list_directory
gh#158: the cancel token of the run executing on THIS thread.
MCPServerBase concrete base class + ServerResponse.
Structured result from tool execution.
Definition server_base.h:33
Parsed tool definition from JSON schema file.
Definition tool_base.h:33
Abstract base class for individual MCP tools.