Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
sandbox.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
16
17#include <array>
18#include <cstdio>
19#include <cstdlib>
20#include <random>
21#include <sstream>
22
23#include <pwd.h>
24#include <signal.h>
25#include <sys/types.h>
26#include <sys/wait.h>
27#include <unistd.h>
28
29static auto logger = entropic::log::get("core.sandbox");
30
31namespace entropic {
32
33namespace {
34
42std::string shell_quote(const std::filesystem::path& p) {
43 std::string s = p.string();
44 std::string out;
45 out.reserve(s.size() + 2);
46 out.push_back('\'');
47 for (char c : s) {
48 if (c == '\'') {
49 out += "'\\''";
50 } else {
51 out.push_back(c);
52 }
53 }
54 out.push_back('\'');
55 return out;
56}
57
66int run_capture(const std::string& cmd, std::string& output) {
67 output.clear();
68 FILE* pipe = popen(cmd.c_str(), "r");
69 if (pipe == nullptr) {
70 return -1;
71 }
72 std::array<char, 4096> buf{};
73 while (fgets(buf.data(), static_cast<int>(buf.size()), pipe) != nullptr) {
74 output += buf.data();
75 }
76 return pclose(pipe);
77}
78
85std::filesystem::path sandbox_root() {
86 const char* home = std::getenv("HOME");
87 if ((home == nullptr) || home[0] == '\0') {
88 struct passwd* pw = getpwuid(getuid());
89 home = (pw != nullptr) ? pw->pw_dir : "/tmp";
90 }
91 return std::filesystem::path(home) / ".entropic" / "sandbox";
92}
93
100std::string make_session_id() {
101 std::random_device rd;
102 std::uniform_int_distribution<uint32_t> dist;
103 char hex[9];
104 std::snprintf(hex, sizeof(hex), "%08x", dist(rd));
105 return std::to_string(static_cast<long>(getpid())) + "-" + hex;
106}
107
119bool pid_is_alive(long pid) {
120 if (kill(static_cast<pid_t>(pid), 0) == 0) { return true; }
121 return errno != ESRCH;
122}
123
132bool parse_session_pid(const std::string& name, long& pid) {
133 auto dash = name.find('-');
134 bool shape_ok = dash != std::string::npos && dash != 0 &&
135 name.size() - dash - 1 == 8;
136 if (!shape_ok) { return false; }
137 bool parsed = false;
138 try {
139 size_t consumed = 0;
140 pid = std::stol(name.substr(0, dash), &consumed);
141 parsed = (consumed == dash);
142 } catch (const std::exception&) {
143 parsed = false;
144 }
145 return parsed;
146}
147
155bool is_git_repo(const std::filesystem::path& dir) {
156 std::error_code ec;
157 return std::filesystem::exists(dir / ".git", ec);
158}
159
160} // namespace
161
162// ── SandboxManager ───────────────────────────────────────
163
170SandboxManager::SandboxManager(const std::filesystem::path& project_dir)
171 : project_dir_(std::filesystem::absolute(project_dir)),
172 session_id_(make_session_id()),
173 session_base_(sandbox_root() / session_id_),
174 base_dir_(session_base_ / "base") {
175 logger->info("SandboxManager: project={} session={} base={}",
176 project_dir_.string(), session_id_,
177 session_base_.string());
178 prune_stale_sessions();
179 std::error_code ec;
180 std::filesystem::create_directories(session_base_, ec);
181 if (ec) {
182 logger->error("Failed to create session base {}: {}",
183 session_base_.string(), ec.message());
184 }
185}
186
193 safe_remove(session_base_);
194 logger->info("Session sandbox cleanup: {}", session_base_.string());
195}
196
210bool SandboxManager::path_in_session_base(
211 const std::filesystem::path& p) const {
212 if (session_base_.empty() || p.empty()) { return false; }
213 auto norm = std::filesystem::absolute(p).lexically_normal();
214 auto base = session_base_.lexically_normal();
215 auto rel = norm.lexically_relative(base);
216 if (rel.empty()) { return false; }
217 auto first = rel.begin();
218 return first != rel.end() && first->string() != "..";
219}
220
227void SandboxManager::safe_remove(const std::filesystem::path& p) {
228 if (p.empty()) { return; }
229 if (p == session_base_) {
230 std::error_code ec;
231 std::filesystem::remove_all(p, ec);
232 if (ec) {
233 logger->warn("remove_all({}) failed: {}",
234 p.string(), ec.message());
235 }
236 return;
237 }
238 if (!path_in_session_base(p)) {
239 logger->error("BLOCKED remove outside session base: path='{}' "
240 "session_base='{}'",
241 p.string(), session_base_.string());
242 return;
243 }
244 std::error_code ec;
245 std::filesystem::remove_all(p, ec);
246 if (ec) {
247 logger->warn("remove_all({}) failed: {}", p.string(), ec.message());
248 }
249}
250
256void SandboxManager::prune_stale_sessions() {
257 auto root = sandbox_root();
258 std::error_code ec;
259 if (!std::filesystem::exists(root, ec)) { return; }
260 for (const auto& entry :
261 std::filesystem::directory_iterator(root, ec)) {
262 if (!entry.is_directory()) { continue; }
263 std::string name = entry.path().filename().string();
264 if (name == session_id_) { continue; }
265 long pid = 0;
266 if (!parse_session_pid(name, pid)) { continue; }
267 if (pid_is_alive(pid)) { continue; }
268 std::error_code rec;
269 std::filesystem::remove_all(entry.path(), rec);
270 logger->info("Pruned stale session sandbox: {} (pid {} gone)",
271 entry.path().string(), pid);
272 }
273}
274
288bool SandboxManager::ensure_base_snapshot() {
289 std::lock_guard<std::mutex> guard(base_mutex_);
290 if (base_ready_) { return true; }
291 std::error_code ec;
292 std::filesystem::create_directories(base_dir_, ec);
293 bool ok = !ec && snapshot_tree(project_dir_, base_dir_);
294 if (!ok) {
295 logger->error("Failed to materialize base snapshot at {}: {}",
296 base_dir_.string(),
297 ec ? ec.message() : std::string{"snapshot_tree failed"});
298 return false;
299 }
300 base_ready_ = true;
301 logger->info("Base snapshot ready at {}", base_dir_.string());
302 return true;
303}
304
314 const std::filesystem::path& source,
315 const std::filesystem::path& target) {
316 std::string list_cmd = "cd " + shell_quote(source) +
317 " && git ls-files --cached --others --exclude-standard";
318 std::string out;
319 if (run_capture(list_cmd, out) != 0) {
320 logger->error("git ls-files failed in {}", source.string());
321 return false;
322 }
323 std::istringstream iss(out);
324 std::string rel;
325 while (std::getline(iss, rel)) {
326 if (rel.empty()) { continue; }
327 auto src = source / rel;
328 auto dst = target / rel;
329 std::error_code ec;
330 std::filesystem::create_directories(dst.parent_path(), ec);
331 std::filesystem::copy_file(
332 src, dst,
333 std::filesystem::copy_options::overwrite_existing, ec);
334 if (ec) {
335 logger->warn("copy {} -> {} failed: {}",
336 src.string(), dst.string(), ec.message());
337 }
338 }
339 return true;
340}
341
351 const std::filesystem::path& source,
352 const std::filesystem::path& target) {
353 std::error_code ec;
354 std::filesystem::copy(
355 source, target,
356 std::filesystem::copy_options::recursive |
357 std::filesystem::copy_options::overwrite_existing |
358 std::filesystem::copy_options::copy_symlinks,
359 ec);
360 if (ec) {
361 logger->error("plain copy {} -> {} failed: {}",
362 source.string(), target.string(), ec.message());
363 return false;
364 }
365 return true;
366}
367
376bool SandboxManager::snapshot_tree(
377 const std::filesystem::path& source,
378 const std::filesystem::path& target) {
379 if (!path_in_session_base(target)) {
380 logger->error("BLOCKED snapshot to path outside session base: {}",
381 target.string());
382 return false;
383 }
384 if (is_git_repo(source)) {
385 return snapshot_git_project(source, target);
386 }
387 return snapshot_plain_copy(source, target);
388}
389
398std::optional<SandboxInfo> SandboxManager::create_sandbox(
399 const std::string& delegation_id,
400 std::optional<SandboxInfo> chain_from) {
401 auto target = session_base_ / ("d-" + delegation_id);
402 auto source = chain_from.has_value() ? chain_from->path : base_dir_;
403 auto effective_base = chain_from.has_value()
404 ? chain_from->base_dir
405 : base_dir_;
406
407 bool path_ok = path_in_session_base(target);
408 bool base_ok = ensure_base_snapshot();
409 if (!path_ok || !base_ok) {
410 if (!path_ok) {
411 logger->error("BLOCKED sandbox path outside session base: {}",
412 target.string());
413 }
414 return std::nullopt;
415 }
416
417 std::error_code ec;
418 std::filesystem::remove_all(target, ec);
419 if (!snapshot_plain_copy(source, target)) {
420 return std::nullopt;
421 }
422
423 logger->info("Created sandbox: id={} path={} chained_from={}",
424 delegation_id, target.string(),
425 chain_from.has_value() ? chain_from->path.string()
426 : std::string{"(base)"});
427 return SandboxInfo{target, delegation_id, effective_base};
428}
429
439static bool capture_diff(
440 const std::filesystem::path& base,
441 const std::filesystem::path& head,
442 std::string& patch) {
443 std::string cmd = "git diff --no-index --binary --no-color "
444 + shell_quote(base) + " " + shell_quote(head);
445 int status = run_capture(cmd, patch);
446 if (status < 0) {
447 logger->error("git diff failed to spawn");
448 return false;
449 }
450 int exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
451 if (exit_code != 0 && exit_code != 1) {
452 logger->error("git diff exit={} output snippet='{}'",
453 exit_code, patch.substr(0, 256));
454 return false;
455 }
456 return true;
457}
458
466static void trim_path_prefix(std::string& line, std::string p) {
467 if (!p.empty() && p.back() != '/') { p += '/'; }
468 size_t n = 0;
469 if (line.rfind(p, 0) == 0) {
470 n = p.size();
471 } else if (!p.empty() && p.front() == '/'
472 && line.rfind(p.substr(1), 0) == 0) {
473 n = p.size() - 1;
474 }
475 if (n > 0) { line.erase(0, n); }
476}
477
486static std::vector<std::filesystem::path> diff_files(
487 const std::filesystem::path& base,
488 const std::filesystem::path& head) {
489 std::vector<std::filesystem::path> changed;
490 std::string cmd = "git diff --no-index --name-only "
491 + shell_quote(base) + " " + shell_quote(head);
492 std::string out;
493 run_capture(cmd, out);
494 std::istringstream iss(out);
495 std::string line;
496 auto base_s = base.string();
497 auto head_s = head.string();
498 while (std::getline(iss, line)) {
499 if (line.empty()) { continue; }
502 changed.emplace_back(line);
503 }
504 return changed;
505}
506
514std::optional<SandboxResult> SandboxManager::finalize_sandbox(
515 const SandboxInfo& info) {
516 if (!path_in_session_base(info.path)) {
517 logger->error("finalize_sandbox: path not in session base: {}",
518 info.path.string());
519 return std::nullopt;
520 }
522 res.base_dir = info.base_dir;
523 res.head_dir = info.path;
524 if (!capture_diff(info.base_dir, info.path, res.patch)) {
525 return std::nullopt;
526 }
527 res.files_touched = diff_files(info.base_dir, info.path);
528 logger->info("Finalized sandbox {}: {} files changed, "
529 "patch={} bytes",
530 info.delegation_id, res.files_touched.size(),
531 res.patch.size());
532 return res;
533}
534
542 safe_remove(info.path);
543 logger->info("Discarded sandbox {}: {}",
544 info.delegation_id, info.path.string());
545}
546
560std::optional<std::filesystem::path> SandboxManager::write_pending_patch(
561 const std::string& delegation_id,
562 const std::string& patch) {
563 auto pending_dir = session_base_ / "pending";
564 auto out_path = pending_dir / (delegation_id + ".patch");
565 std::error_code ec;
566 bool contained = path_in_session_base(pending_dir)
567 && path_in_session_base(out_path);
568 if (!contained) {
569 logger->error("Refusing write_pending_patch: {} not in session base",
570 out_path.string());
571 return std::nullopt;
572 }
573 std::filesystem::create_directories(pending_dir, ec);
574 bool dir_ok = !ec;
575 FILE* f = dir_ok ? std::fopen(out_path.c_str(), "wb") : nullptr;
576 bool write_ok = false;
577 if (f != nullptr) {
578 write_ok = std::fwrite(patch.data(), 1, patch.size(), f)
579 == patch.size();
580 std::fclose(f);
581 }
582 if (!dir_ok || f == nullptr || !write_ok) {
583 logger->error("write_pending_patch failed for {} (mkdir={} open={} "
584 "write={})", out_path.string(), dir_ok,
585 f != nullptr, write_ok);
586 return std::nullopt;
587 }
588 return out_path;
589}
590
597const std::filesystem::path& SandboxManager::project_dir() const {
598 return project_dir_;
599}
600
607const std::filesystem::path& SandboxManager::session_base() const {
608 return session_base_;
609}
610
618std::string SandboxManager::next_delegation_id(const std::string& prefix) {
619 auto n = id_seq_.fetch_add(1, std::memory_order_relaxed) + 1;
620 return prefix + "_" + std::to_string(n);
621}
622
623// ── ScopedSandbox ────────────────────────────────────────
624
636 void* user_data,
637 std::string session_key,
638 const std::filesystem::path& sandbox_path,
639 const std::filesystem::path& original_path)
640 : swap_fn_(swap_fn),
641 user_data_(user_data),
642 session_key_(std::move(session_key)),
643 original_path_(original_path) {
644 if (swap_fn_ != nullptr) {
645 swap_fn_(session_key_, sandbox_path, true, user_data_);
646 logger->info("Swapped tool dir to sandbox: {} (session='{}')",
647 sandbox_path.string(), session_key_);
648 }
649}
650
657 if (swap_fn_ != nullptr) {
658 swap_fn_(session_key_, original_path_, false, user_data_);
659 logger->info("Restored tool dir to: {} (session='{}')",
660 original_path_.string(), session_key_);
661 }
662}
663
664} // namespace entropic
std::string output
Combined stdout+stderr (partial on timeout)
Definition bash.cpp:98
int exit_code
Shell status; 128+N if killed by signal N.
Definition bash.cpp:99
const std::filesystem::path & session_base() const
Get this session's sandbox base directory.
Definition sandbox.cpp:607
void discard_sandbox(const SandboxInfo &info)
Remove a sandbox directory.
Definition sandbox.cpp:541
SandboxManager(const std::filesystem::path &project_dir)
Construct with the user's project directory.
Definition sandbox.cpp:170
std::string next_delegation_id(const std::string &prefix)
Mint a sandbox id that no sibling delegation can collide with.
Definition sandbox.cpp:618
std::optional< SandboxResult > finalize_sandbox(const SandboxInfo &info)
Produce the final patch artifact for a sandbox.
Definition sandbox.cpp:514
std::optional< SandboxInfo > create_sandbox(const std::string &delegation_id, std::optional< SandboxInfo > chain_from=std::nullopt)
Create a new delegation sandbox.
Definition sandbox.cpp:398
~SandboxManager()
Remove this session's sandbox tree.
Definition sandbox.cpp:192
std::optional< std::filesystem::path > write_pending_patch(const std::string &delegation_id, const std::string &patch)
Write a patch to the session's pending/ directory.
Definition sandbox.cpp:560
const std::filesystem::path & project_dir() const
Get the project directory this manager snapshots from.
Definition sandbox.cpp:597
~ScopedSandbox()
Restore the original directory.
Definition sandbox.cpp:656
ScopedSandbox(SwapDirFn swap_fn, void *user_data, std::string session_key, const std::filesystem::path &sandbox_path, const std::filesystem::path &original_path)
Construct and swap directories.
Definition sandbox.cpp:634
void(*)(const std::string &session_key, const std::filesystem::path &path, bool entering, void *user_data) SwapDirFn
Callback type for directory swapping.
Definition sandbox.h:361
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).
@ ok
Tool dispatched, returned non-empty content.
static bool snapshot_git_project(const std::filesystem::path &source, const std::filesystem::path &target)
Copy git-tracked + untracked-not-ignored files from a project.
Definition sandbox.cpp:313
@ optional
optional: every escape asks the host's path approver
static std::vector< std::filesystem::path > diff_files(const std::filesystem::path &base, const std::filesystem::path &head)
List files differing between base and head via diff -rq-style logic.
Definition sandbox.cpp:486
static bool snapshot_plain_copy(const std::filesystem::path &source, const std::filesystem::path &target)
Recursive copy for non-git sources (or sandbox-to-sandbox chains).
Definition sandbox.cpp:350
static void trim_path_prefix(std::string &line, std::string p)
Strip a directory prefix p from the front of line.
Definition sandbox.cpp:466
static bool capture_diff(const std::filesystem::path &base, const std::filesystem::path &head, std::string &patch)
Run git diff --no-index between base and head, capture patch.
Definition sandbox.cpp:439
Filesystem-based sandbox isolation for delegations.
Identifies one delegation's sandbox directory.
Definition sandbox.h:66
Final artifact emitted by a finalized sandbox.
Definition sandbox.h:83
std::filesystem::path base_dir
Snapshot the diff is against.
Definition sandbox.h:86