Entropic 2.3.8
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
281bool SandboxManager::ensure_base_snapshot() {
282 if (base_ready_) { return true; }
283 std::error_code ec;
284 std::filesystem::create_directories(base_dir_, ec);
285 bool ok = !ec && snapshot_tree(project_dir_, base_dir_);
286 if (!ok) {
287 logger->error("Failed to materialize base snapshot at {}: {}",
288 base_dir_.string(),
289 ec ? ec.message() : std::string{"snapshot_tree failed"});
290 return false;
291 }
292 base_ready_ = true;
293 logger->info("Base snapshot ready at {}", base_dir_.string());
294 return true;
295}
296
306 const std::filesystem::path& source,
307 const std::filesystem::path& target) {
308 std::string list_cmd = "cd " + shell_quote(source) +
309 " && git ls-files --cached --others --exclude-standard";
310 std::string out;
311 if (run_capture(list_cmd, out) != 0) {
312 logger->error("git ls-files failed in {}", source.string());
313 return false;
314 }
315 std::istringstream iss(out);
316 std::string rel;
317 while (std::getline(iss, rel)) {
318 if (rel.empty()) { continue; }
319 auto src = source / rel;
320 auto dst = target / rel;
321 std::error_code ec;
322 std::filesystem::create_directories(dst.parent_path(), ec);
323 std::filesystem::copy_file(
324 src, dst,
325 std::filesystem::copy_options::overwrite_existing, ec);
326 if (ec) {
327 logger->warn("copy {} -> {} failed: {}",
328 src.string(), dst.string(), ec.message());
329 }
330 }
331 return true;
332}
333
343 const std::filesystem::path& source,
344 const std::filesystem::path& target) {
345 std::error_code ec;
346 std::filesystem::copy(
347 source, target,
348 std::filesystem::copy_options::recursive |
349 std::filesystem::copy_options::overwrite_existing |
350 std::filesystem::copy_options::copy_symlinks,
351 ec);
352 if (ec) {
353 logger->error("plain copy {} -> {} failed: {}",
354 source.string(), target.string(), ec.message());
355 return false;
356 }
357 return true;
358}
359
368bool SandboxManager::snapshot_tree(
369 const std::filesystem::path& source,
370 const std::filesystem::path& target) {
371 if (!path_in_session_base(target)) {
372 logger->error("BLOCKED snapshot to path outside session base: {}",
373 target.string());
374 return false;
375 }
376 if (is_git_repo(source)) {
377 return snapshot_git_project(source, target);
378 }
379 return snapshot_plain_copy(source, target);
380}
381
390std::optional<SandboxInfo> SandboxManager::create_sandbox(
391 const std::string& delegation_id,
392 std::optional<SandboxInfo> chain_from) {
393 auto target = session_base_ / ("d-" + delegation_id);
394 auto source = chain_from.has_value() ? chain_from->path : base_dir_;
395 auto effective_base = chain_from.has_value()
396 ? chain_from->base_dir
397 : base_dir_;
398
399 bool path_ok = path_in_session_base(target);
400 bool base_ok = ensure_base_snapshot();
401 if (!path_ok || !base_ok) {
402 if (!path_ok) {
403 logger->error("BLOCKED sandbox path outside session base: {}",
404 target.string());
405 }
406 return std::nullopt;
407 }
408
409 std::error_code ec;
410 std::filesystem::remove_all(target, ec);
411 if (!snapshot_plain_copy(source, target)) {
412 return std::nullopt;
413 }
414
415 logger->info("Created sandbox: id={} path={} chained_from={}",
416 delegation_id, target.string(),
417 chain_from.has_value() ? chain_from->path.string()
418 : std::string{"(base)"});
419 return SandboxInfo{target, delegation_id, effective_base};
420}
421
431static bool capture_diff(
432 const std::filesystem::path& base,
433 const std::filesystem::path& head,
434 std::string& patch) {
435 std::string cmd = "git diff --no-index --binary --no-color "
436 + shell_quote(base) + " " + shell_quote(head);
437 int status = run_capture(cmd, patch);
438 if (status < 0) {
439 logger->error("git diff failed to spawn");
440 return false;
441 }
442 int exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
443 if (exit_code != 0 && exit_code != 1) {
444 logger->error("git diff exit={} output snippet='{}'",
445 exit_code, patch.substr(0, 256));
446 return false;
447 }
448 return true;
449}
450
458static void trim_path_prefix(std::string& line, std::string p) {
459 if (!p.empty() && p.back() != '/') { p += '/'; }
460 size_t n = 0;
461 if (line.rfind(p, 0) == 0) {
462 n = p.size();
463 } else if (!p.empty() && p.front() == '/'
464 && line.rfind(p.substr(1), 0) == 0) {
465 n = p.size() - 1;
466 }
467 if (n > 0) { line.erase(0, n); }
468}
469
478static std::vector<std::filesystem::path> diff_files(
479 const std::filesystem::path& base,
480 const std::filesystem::path& head) {
481 std::vector<std::filesystem::path> changed;
482 std::string cmd = "git diff --no-index --name-only "
483 + shell_quote(base) + " " + shell_quote(head);
484 std::string out;
485 run_capture(cmd, out);
486 std::istringstream iss(out);
487 std::string line;
488 auto base_s = base.string();
489 auto head_s = head.string();
490 while (std::getline(iss, line)) {
491 if (line.empty()) { continue; }
492 trim_path_prefix(line, base_s);
493 trim_path_prefix(line, head_s);
494 changed.emplace_back(line);
495 }
496 return changed;
497}
498
506std::optional<SandboxResult> SandboxManager::finalize_sandbox(
507 const SandboxInfo& info) {
508 if (!path_in_session_base(info.path)) {
509 logger->error("finalize_sandbox: path not in session base: {}",
510 info.path.string());
511 return std::nullopt;
512 }
513 SandboxResult res;
514 res.base_dir = info.base_dir;
515 res.head_dir = info.path;
516 if (!capture_diff(info.base_dir, info.path, res.patch)) {
517 return std::nullopt;
518 }
519 res.files_touched = diff_files(info.base_dir, info.path);
520 logger->info("Finalized sandbox {}: {} files changed, "
521 "patch={} bytes",
522 info.delegation_id, res.files_touched.size(),
523 res.patch.size());
524 return res;
525}
526
534 safe_remove(info.path);
535 logger->info("Discarded sandbox {}: {}",
536 info.delegation_id, info.path.string());
537}
538
552std::optional<std::filesystem::path> SandboxManager::write_pending_patch(
553 const std::string& delegation_id,
554 const std::string& patch) {
555 auto pending_dir = session_base_ / "pending";
556 auto out_path = pending_dir / (delegation_id + ".patch");
557 std::error_code ec;
558 bool contained = path_in_session_base(pending_dir)
559 && path_in_session_base(out_path);
560 if (!contained) {
561 logger->error("Refusing write_pending_patch: {} not in session base",
562 out_path.string());
563 return std::nullopt;
564 }
565 std::filesystem::create_directories(pending_dir, ec);
566 bool dir_ok = !ec;
567 FILE* f = dir_ok ? std::fopen(out_path.c_str(), "wb") : nullptr;
568 bool write_ok = false;
569 if (f != nullptr) {
570 write_ok = std::fwrite(patch.data(), 1, patch.size(), f)
571 == patch.size();
572 std::fclose(f);
573 }
574 if (!dir_ok || f == nullptr || !write_ok) {
575 logger->error("write_pending_patch failed for {} (mkdir={} open={} "
576 "write={})", out_path.string(), dir_ok,
577 f != nullptr, write_ok);
578 return std::nullopt;
579 }
580 return out_path;
581}
582
589const std::filesystem::path& SandboxManager::project_dir() const {
590 return project_dir_;
591}
592
599const std::filesystem::path& SandboxManager::session_base() const {
600 return session_base_;
601}
602
603// ── ScopedSandbox ────────────────────────────────────────
604
615 SwapDirFn swap_fn,
616 void* user_data,
617 const std::filesystem::path& sandbox_path,
618 const std::filesystem::path& original_path)
619 : swap_fn_(swap_fn),
620 user_data_(user_data),
621 original_path_(original_path) {
622 if (swap_fn_ != nullptr) {
623 swap_fn_(sandbox_path, user_data_);
624 logger->info("Swapped tool dir to sandbox: {}",
625 sandbox_path.string());
626 }
627}
628
635 if (swap_fn_ != nullptr) {
636 swap_fn_(original_path_, user_data_);
637 logger->info("Restored tool dir to: {}", original_path_.string());
638 }
639}
640
641} // namespace entropic
const std::filesystem::path & session_base() const
Get this session's sandbox base directory.
Definition sandbox.cpp:599
void discard_sandbox(const SandboxInfo &info)
Remove a sandbox directory.
Definition sandbox.cpp:533
SandboxManager(const std::filesystem::path &project_dir)
Construct with the user's project directory.
Definition sandbox.cpp:170
std::optional< SandboxResult > finalize_sandbox(const SandboxInfo &info)
Produce the final patch artifact for a sandbox.
Definition sandbox.cpp:506
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:390
~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:552
const std::filesystem::path & project_dir() const
Get the project directory this manager snapshots from.
Definition sandbox.cpp:589
~ScopedSandbox()
Restore the original directory.
Definition sandbox.cpp:634
ScopedSandbox(SwapDirFn swap_fn, void *user_data, const std::filesystem::path &sandbox_path, const std::filesystem::path &original_path)
Construct and swap directories.
Definition sandbox.cpp:614
void(*)(const std::filesystem::path &path, void *user_data) SwapDirFn
Callback type for directory swapping.
Definition sandbox.h:298
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:305
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:478
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:342
static void trim_path_prefix(std::string &line, std::string p)
Strip a directory prefix p from the front of line.
Definition sandbox.cpp:458
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:431
Filesystem-based sandbox isolation for delegations.
Identifies one delegation's sandbox directory.
Definition sandbox.h:49
Final artifact emitted by a finalized sandbox.
Definition sandbox.h:66
std::filesystem::path head_dir
Final sandbox state.
Definition sandbox.h:70
std::vector< std::filesystem::path > files_touched
Relative paths that changed.
Definition sandbox.h:68
std::string patch
Unified diff text.
Definition sandbox.h:67
std::filesystem::path base_dir
Snapshot the diff is against.
Definition sandbox.h:69