Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
filesystem.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
18
19#include <nlohmann/json.hpp>
20
21#include <algorithm>
22#include <cstring>
23#include <filesystem>
24#include <fstream>
25#include <functional>
26#include <optional>
27#include <regex>
28#include <sstream>
29
30namespace fs = std::filesystem;
31using json = nlohmann::json;
32
33static auto logger = entropic::log::get("mcp.filesystem");
34
35namespace entropic {
36
37// ── FileAccessTracker ────────────────────────────────────
38
57 const std::string& path,
58 size_t hash) {
59 reads_.with(session, [&](auto& reads) { reads[path] = hash; });
60 logger->info("Tracked read: session='{}' {}", session, path);
61}
62
63
79bool FileAccessTracker::was_read(const std::string& session,
80 const std::string& path) const {
81 return reads_.peek(session, [&](const auto* reads) {
82 return reads != nullptr && reads->count(path) > 0;
83 });
84}
85
99 return reads_.release(session);
100}
101
109 return reads_.session_count();
110}
111
112// ── File-local helpers ───────────────────────────────────
113
114namespace {
115
129bool should_skip_dir(const std::string& name) {
131}
132
147bool path_within(const fs::path& path, const fs::path& base) {
148 fs::path rel = path.lexically_relative(base);
149 return !rel.empty()
150 && *rel.begin() != fs::path("..")
151 && rel != fs::path("..");
152}
153
162fs::path canonical_entry(const fs::path& entry) {
163 std::error_code ec;
164 auto c = fs::weakly_canonical(entry, ec);
165 return ec ? entry.lexically_normal() : c;
166}
167
181std::optional<fs::path> first_containing(
182 const std::vector<fs::path>& entries, const fs::path& resolved) {
183 std::optional<fs::path> hit;
184 for (const auto& e : entries) {
185 auto base = canonical_entry(e);
186 if (path_within(resolved, base)) {
187 hit = base;
188 break;
189 }
190 }
191 return hit;
192}
193
202 const char* name = "optional";
203 if (mode == OutsideRootAccess::refuse) { name = "false"; }
204 if (mode == OutsideRootAccess::allow) { name = "true"; }
205 return name;
206}
207
215const char* access_verb(PathAccess access) {
216 return access == PathAccess::write ? "write" : "read";
217}
218
233std::string outside_root_refusal(OutsideRootVerdict verdict,
234 const OutsideRootRequest& req) {
235 std::string head = "Path escapes project root: " + req.path
236 + " (root: " + req.root + ")";
237 if (verdict == OutsideRootVerdict::rejected) {
238 return "outside_root_rejected: " + head + " — the host's path "
239 "approver refused this " + access_verb(req.access) + ".";
240 }
241 return "outside_root_approval_required: " + head + " — "
242 + access_verb(req.access) + " access outside the project root "
243 "needs approval (mcp.filesystem.allow_outside_root: optional) and "
244 "no path approver is registered, so it is refused. Pre-approve "
245 "the directory with mcp.filesystem.outside_root_allow, set "
246 "allow_outside_root: true, or register an approver with "
247 "entropic_set_path_approval_callback.";
248}
249
260[[noreturn]] void refuse_denied(const fs::path& resolved,
261 const fs::path& entry, PathAccess access,
262 const fs::path& root) {
263 logger->error("Path escape blocked by outside_root_deny: {} {} "
264 "(entry: {}, root: {})", access_verb(access),
265 resolved.string(), entry.string(), root.string());
266 throw std::runtime_error(
267 "outside_root_denied: " + resolved.string() + " is under "
268 "mcp.filesystem.outside_root_deny entry " + entry.string()
269 + " — refused regardless of allow_outside_root.");
270}
271
284[[noreturn]] void refuse_escape(const fs::path& resolved,
285 const fs::path& root) {
286 logger->error("Path escape blocked: {} (root: {})",
287 resolved.string(), root.string());
288 throw std::runtime_error(
289 "Path escapes project root: " + resolved.string());
290}
291
301void log_outside_root_served(const fs::path& resolved, PathAccess access,
302 const std::optional<fs::path>& allowed_by) {
303 std::string why = allowed_by.has_value()
304 ? "outside_root_allow entry " + allowed_by->string()
305 : std::string("allow_outside_root: true");
306 logger->info("Outside-root {} served ({}): {}", access_verb(access),
307 why, resolved.string());
308}
309
317std::string join_paths(const std::vector<fs::path>& paths) {
318 std::string s;
319 for (const auto& p : paths) {
320 s += (s.empty() ? "" : ", ") + p.string();
321 }
322 return "[" + s + "]";
323}
324
337void warn_deny_entries_inside_root(const std::vector<fs::path>& deny,
338 const fs::path& root) {
339 for (const auto& e : deny) {
340 if (path_within(canonical_entry(e), root)) {
341 logger->warn("outside_root_deny entry {} is INSIDE the root {} "
342 "— the outside-root lists govern paths outside "
343 "the root only, so it has no effect there",
344 e.string(), root.string());
345 }
346 }
347}
348
359struct WalkBudget {
360 int max_entries = 0;
361 long visited = 0;
362 bool truncated = false;
363};
364
372bool walk_budget_spent(WalkBudget& budget) {
373 ++budget.visited;
374 bool spent = budget.max_entries > 0
375 && budget.visited >= static_cast<long>(budget.max_entries);
376 if (spent) { budget.truncated = true; }
377 return spent;
378}
379
380
381
382
391std::string read_file_contents(const fs::path& path) {
392 std::ifstream in(path, std::ios::binary);
393 if (!in.is_open()) {
394 throw std::runtime_error(
395 "Cannot open file: " + path.string());
396 }
397 std::ostringstream ss;
398 ss << in.rdbuf();
399 return ss.str();
400}
401
409void write_file_contents(const fs::path& path,
410 const std::string& content) {
411 fs::create_directories(path.parent_path());
412 std::ofstream out(path, std::ios::binary | std::ios::trunc);
413 if (!out.is_open()) {
414 throw std::runtime_error(
415 "Cannot write file: " + path.string());
416 }
417 out << content;
418}
419
427size_t hash_content(const std::string& s) {
428 return std::hash<std::string>{}(s);
429}
430
446std::string make_error(const std::string& code,
447 const std::string& message) {
448 json j;
449 j["error"] = code;
450 j["message"] = message;
451 return j.dump();
452}
453
459struct Page {
460 long offset = 0;
461 long limit = 0;
462};
463
479std::string read_page(const json& args, long default_limit, long max_limit,
480 Page& page) {
481 page.offset = args.value<long>("offset", 0);
482 page.limit = args.value("limit", default_limit);
483 std::string err;
484 // Written with '>' only: knots v1.4.3 reads a bare '<' here as a
485 // template bracket and loses the function boundary.
486 if (0 > page.offset || 1 > page.limit || page.limit > max_limit) {
487 err = make_error("invalid_args",
488 "offset must be >= 0 and limit 1.." + std::to_string(max_limit)
489 + " (got offset " + std::to_string(page.offset) + ", limit "
490 + std::to_string(page.limit) + ")");
491 }
492 return err;
493}
494
506class PagedResult {
507public:
513 explicit PagedResult(Page page) : page_(page) {}
514
523 bool offer(json item) {
524 bool keep_going = true;
525 if (page_.offset > seen_) {
526 ++seen_;
527 } else if (static_cast<long>(items_.size()) >= page_.limit) {
528 more_ = true;
529 keep_going = false;
530 } else {
531 items_.push_back(std::move(item));
532 }
533 return keep_going;
534 }
535
542 bool full() const { return more_; }
543
552 void finish(json& result, const std::string& key,
553 bool walk_truncated) {
554 const long returned = static_cast<long>(items_.size());
555 result[key] = std::move(items_);
556 result["truncated"] = more_ || walk_truncated;
557 if (more_) {
558 result["reason"] = "limit";
559 result["next_offset"] = page_.offset + returned;
560 } else if (walk_truncated) {
561 result["reason"] = "walk";
562 }
563 }
564
565private:
566 Page page_;
567 long seen_ = 0;
568 bool more_ = false;
569 json items_ = json::array();
570};
571
582std::string display_path(const fs::path& root, const fs::path& path) {
583 return path_within(path, root)
584 ? path.lexically_relative(root).generic_string()
585 : path.string();
586}
587
593struct ReadWindow {
594 long offset = 1;
595 long limit = 0;
596 long budget = 0;
597};
598
604struct WindowReader {
605 const ReadWindow& w;
606 json lines = json::array();
607 long bytes = 0;
608 bool more = false;
609 std::string err;
610
618 void take(long n, const std::string& line) {
619 const long size = static_cast<long>(line.size()) + 1;
620 const bool full = (w.limit > 0 && static_cast<long>(lines.size()) >= w.limit)
621 || (w.budget > 0 && bytes + size > w.budget);
622 if (w.offset > n || more) { return; }
623 if (full && lines.empty()) {
624 err = make_error("size_exceeded", "Line " + std::to_string(n)
625 + " is " + std::to_string(size) + " bytes, more than the "
626 "whole read budget (" + std::to_string(w.budget) + ")");
627 } else if (full) {
628 more = true;
629 } else {
630 json entry;
631 entry["line"] = n;
632 entry["content"] = line;
633 lines.push_back(std::move(entry));
634 bytes += size;
635 }
636 }
637};
638
657json read_numbered_lines(const fs::path& path, const std::string& display,
658 const ReadWindow& w, std::string& err) {
659 std::ifstream in(path, std::ios::binary);
660 WindowReader reader{w};
661 std::string line;
662 long n = 0;
663 while (reader.err.empty() && std::getline(in, line)) {
664 reader.take(++n, line);
665 }
666 err = reader.err;
667 json result;
668 result["path"] = display;
669 result["total"] = n;
670 result["offset"] = w.offset;
671 result["truncated"] = reader.more;
672 if (reader.more) {
673 result["next_offset"] = w.offset + static_cast<long>(reader.lines.size());
674 }
675 result["lines"] = std::move(reader.lines);
676 return result;
677}
678
694void emit_glob_star(const std::string& pat, size_t& i, std::string& out) {
695 out += ".*";
696 bool dbl = (i + 1 < pat.size()) && (pat[i + 1] == '*');
697 if (!dbl) { return; }
698 ++i;
699 if (i + 1 < pat.size() && pat[i + 1] == '/') { ++i; }
700}
701
716bool glob_match(const std::string& filename,
717 const std::string& pattern) {
718 std::string regex_str;
719 regex_str.reserve(pattern.size() * 2);
720
721 for (size_t i = 0; i < pattern.size(); ++i) {
722 char ch = pattern[i];
723 if (ch == '*') {
725 } else if (ch == '?') {
726 regex_str += '.';
727 } else if (ch == '.') {
728 regex_str += "\\.";
729 } else {
730 regex_str += ch;
731 }
732 }
733
734 try {
735 std::regex re(regex_str, std::regex::icase);
736 return std::regex_match(filename, re);
737 } catch (const std::regex_error& e) {
738 logger->warn(
739 "glob_match: malformed pattern '{}' → {} — treated as "
740 "non-match", pattern, e.what());
741 return false;
742 }
743}
744
773std::vector<std::string> split_brace_alternatives(
774 const std::string& body) {
775 std::vector<std::string> out;
776 std::string current;
777 for (char c : body) {
778 if (c == ',') {
779 out.push_back(std::move(current));
780 current.clear();
781 } else {
782 current += c;
783 }
784 }
785 out.push_back(std::move(current));
786 return out;
787}
788
794std::vector<std::string> multiply_alternatives(
795 const std::vector<std::string>& bases,
796 const std::vector<std::string>& alternatives) {
797 std::vector<std::string> next;
798 next.reserve(bases.size() * alternatives.size());
799 for (const auto& base : bases) {
800 for (const auto& alt : alternatives) {
801 next.push_back(base + alt);
802 }
803 }
804 return next;
805}
806
818std::vector<std::string> expand_braces(const std::string& pattern) {
819 std::vector<std::string> out{""};
820 size_t i = 0;
821 while (i < pattern.size()) {
822 char c = pattern[i];
823 auto close = (c == '{')
824 ? pattern.find('}', i + 1)
825 : std::string::npos;
826 bool is_group = (c == '{') && (close != std::string::npos);
827 if (!is_group) {
828 for (auto& s : out) { s += c; }
829 ++i;
830 continue;
831 }
832 auto body = pattern.substr(i + 1, close - i - 1);
834 out, split_brace_alternatives(body));
835 i = close + 1;
836 }
837 return out;
838}
839
856std::string check_read_before_write(
857 const FileAccessTracker& tracker,
858 const std::string& path) {
859 const auto session = current_run_session();
860 if (fs::exists(path) && !tracker.was_read(session, path)) {
861 logger->warn("Read-before-write violation: session='{}' {}",
862 session, path);
863 return make_error("read_before_write",
864 "File must be read before writing: " + path);
865 }
866 return "";
867}
868
877int count_occurrences(const std::string& content,
878 const std::string& needle) {
879 int count = 0;
880 size_t pos = 0;
881 while ((pos = content.find(needle, pos)) != std::string::npos) {
882 count++;
883 pos += needle.size();
884 }
885 return count;
886}
887
899std::optional<std::string>
900apply_str_replace(const std::string& content, const std::string& old_str, const std::string& new_str, bool replace_all, std::string& error_type) {
901
902 int occurrences = count_occurrences(content, old_str);
903 if (occurrences == 0) {
904 error_type = "no_match";
905 return std::nullopt;
906 }
907 if (!replace_all && occurrences > 1) {
908 error_type = "multiple_matches";
909 return std::nullopt;
910 }
911
912 std::string result = content;
913 auto pos = result.find(old_str);
914 while (pos != std::string::npos) {
915 result.replace(pos, old_str.size(), new_str);
916 if (!replace_all) { break; }
917 pos = result.find(old_str, pos + new_str.size());
918 }
919 return result;
920}
921
929long count_lines(const std::string& content) {
930 long n = static_cast<long>(std::count(content.begin(), content.end(), '\n'));
931 return (!content.empty() && content.back() != '\n') ? n + 1 : n;
932}
933
949std::string apply_insert(const std::string& content,
950 long after,
951 const std::string& new_str) {
952 size_t pos = 0;
953 for (long line = 0; line < after; ++line) {
954 auto nl = content.find('\n', pos);
955 pos = (nl == std::string::npos) ? content.size() : nl + 1;
956 }
957 std::string text = new_str;
958 bool at_unterminated_end = pos == content.size() && !content.empty()
959 && content.back() != '\n';
961 text = "\n" + text;
962 } else if (text.empty() || text.back() != '\n') {
963 text += '\n';
964 }
965 return content.substr(0, pos) + text + content.substr(pos);
966}
967
968
977bool any_glob_match(const std::string& filename,
978 const std::vector<std::string>& patterns) {
979 for (const auto& p : patterns) {
980 if (glob_match(filename, p)) { return true; }
981 }
982 return false;
983}
984
1010enum class EntryAction {
1011 kSkip,
1012 kSkipPrune,
1013 kTake
1014};
1015
1032EntryAction classify_glob_entry(
1033 const fs::directory_entry& entry,
1034 const fs::path& root,
1035 const std::vector<std::string>& patterns,
1036 const IgnoreMatcher* ignore) {
1037 bool is_dir = entry.is_directory();
1038 EntryAction result = EntryAction::kSkip;
1039 bool hardcoded_skip = is_dir
1040 && should_skip_dir(entry.path().filename().string());
1041 auto rel = fs::relative(entry.path(), root).generic_string();
1043 && ignore != nullptr
1044 && !rel.empty()
1045 && ignore->is_ignored(rel, is_dir);
1046 if (hardcoded_skip || (ignore_hit && is_dir)) {
1047 result = EntryAction::kSkipPrune;
1048 } else if (ignore_hit) {
1049 result = EntryAction::kSkip;
1050 } else if (entry.is_regular_file()
1051 && any_glob_match(rel, patterns)) {
1052 result = EntryAction::kTake;
1053 }
1054 return result;
1055}
1056
1075 const fs::path& root,
1076 const std::string& pattern,
1077 const IgnoreMatcher* ignore,
1078 WalkBudget& budget,
1079 PagedResult& page) {
1080
1081 auto patterns = expand_braces(pattern);
1082 auto it = fs::recursive_directory_iterator(
1083 root, fs::directory_options::skip_permission_denied);
1084
1085 for (auto& entry : it) {
1086 auto action = classify_glob_entry(entry, root, patterns, ignore);
1087 if (action == EntryAction::kSkipPrune) {
1088 it.disable_recursion_pending();
1089 } else if (action == EntryAction::kTake
1090 && !page.offer(display_path(root, entry.path()))) {
1091 break;
1092 }
1093 if (walk_budget_spent(budget)) { break; }
1094 }
1095}
1096
1108bool looks_binary(const fs::path& path) {
1109 std::ifstream in(path, std::ios::binary);
1110 char buf[8192];
1111 in.read(buf, sizeof(buf));
1112 return std::memchr(buf, '\0', static_cast<size_t>(in.gcount())) != nullptr;
1113}
1114
1125bool grep_file(const fs::path& path, const std::string& display,
1126 const std::regex& re, PagedResult& page) {
1127 std::ifstream in(path);
1128 std::string line;
1129 long line_num = 0;
1130 bool keep_going = !looks_binary(path);
1131 while (keep_going && std::getline(in, line)) {
1132 ++line_num;
1133 if (std::regex_search(line, re)) {
1134 json m;
1135 m["path"] = display;
1136 m["line"] = line_num;
1137 m["content"] = line;
1138 keep_going = page.offer(std::move(m));
1139 }
1140 }
1141 return !page.full();
1142}
1143
1152json entry_to_json(const fs::directory_entry& entry, const fs::path& base) {
1153 json j;
1154 j["name"] = entry.path().lexically_relative(base).generic_string();
1155 bool dir = entry.is_directory();
1156 j["type"] = dir ? "directory" : "file";
1157 j["size"] = (!dir && entry.is_regular_file())
1158 ? static_cast<int64_t>(entry.file_size()) : 0;
1159 return j;
1160}
1161
1178std::vector<json> collect_entries(const fs::path& dir,
1179 bool recursive,
1180 int max_depth,
1181 WalkBudget& budget) {
1182 std::vector<json> entries;
1183 auto it = fs::recursive_directory_iterator(
1184 dir, fs::directory_options::skip_permission_denied);
1185 for (auto& entry : it) {
1186 if (!recursive || it.depth() >= max_depth) {
1187 it.disable_recursion_pending();
1188 }
1189 entries.push_back(entry_to_json(entry, dir));
1190 if (walk_budget_spent(budget)) { break; }
1191 }
1192 std::sort(entries.begin(), entries.end(),
1193 [](const json& a, const json& b) { return a["name"] < b["name"]; });
1194 return entries;
1195}
1196
1210std::string do_str_replace(const json& args,
1211 const std::string& content,
1212 std::string& out, json& ok) {
1213 auto old_str = args.at("old_string").get<std::string>();
1214 auto new_str = args.at("new_string").get<std::string>();
1215 bool replace_all = args.value("replace_all", false);
1216
1217 std::string error_type;
1218 auto result = apply_str_replace(
1220 if (!result.has_value()) {
1221 auto msg = (error_type == "multiple_matches")
1222 ? "old_string found multiple times — add context or use replace_all"
1223 : "old_string not found in file";
1224 return make_error(error_type, msg);
1225 }
1226 out = result.value();
1227 ok["mode"] = "str_replace";
1228 ok["replacements"] = replace_all ? count_occurrences(content, old_str) : 1;
1229 ok["message"] = "Edit applied";
1230 return "";
1231}
1232
1244std::string do_insert(const json& args,
1245 const std::string& content,
1246 std::string& out, json& ok) {
1247 const long after = args.at("insert_line").get<long>();
1248 const long total = count_lines(content);
1250 return make_error("out_of_range", "insert_line must be 0.."
1251 + std::to_string(total) + " for this file (got "
1252 + std::to_string(after) + ")");
1253 }
1254 out = apply_insert(content, after, args.at("new_string").get<std::string>());
1255 ok["mode"] = "insert";
1256 ok["inserted_after"] = after;
1257 ok["message"] = "Text inserted";
1258 return "";
1259}
1260
1274std::string apply_edit(const json& args,
1275 const std::filesystem::path& resolved,
1276 const std::string& display) {
1277 const bool replace = args.contains("old_string");
1278 if (replace == args.contains("insert_line")) {
1279 return make_error("invalid_args", "edit_file takes exactly one of "
1280 "old_string (STR_REPLACE) or insert_line (INSERT)");
1281 }
1282 auto content = read_file_contents(resolved);
1283 std::string edited;
1284 json ok;
1285 auto out = replace ? do_str_replace(args, content, edited, ok)
1286 : do_insert(args, content, edited, ok);
1287 if (out.empty()) {
1288 write_file_contents(resolved, edited);
1289 logger->info("Edited file: {} ({})", display, ok["mode"].get<std::string>());
1290 ok["path"] = display;
1291 out = ok.dump();
1292 }
1293 return out;
1294}
1295
1296} // anonymous namespace
1297
1298// ── ReadFileTool ─────────────────────────────────────────
1299
1305class ReadFileTool : public ToolBase {
1306public:
1315 const std::string& data_dir)
1317 "read_file", "filesystem",
1318 data_dir + "/tools")),
1319 server_(server) {}
1320
1329 return MCPAccessLevel::READ;
1330 }
1331
1339 ServerResponse execute(const std::string& args_json) override;
1340
1353 std::string anchor_key(
1354 const std::string& args_json) const override {
1355 auto args = json::parse(args_json);
1356 return "file:" + args.at("path").get<std::string>();
1357 }
1358
1359private:
1360 FilesystemServer& server_;
1361};
1362
1391 const fs::path& resolved,
1392 const std::string& display) {
1393 std::string err;
1394 auto rel = fs::relative(resolved, server.root_dir()).generic_string();
1395 if (!fs::exists(resolved)) {
1396 err = make_error("not_found",
1397 "File not found: " + display
1398 + ". Use list_directory(\".\") to see available files"
1399 " in the working directory, or verify the path is"
1400 " relative to the configured root.");
1401 } else if (fs::is_directory(resolved)) {
1402 err = make_error("is_directory",
1403 "Path is a directory, not a file: " + display);
1404 } else if (!rel.empty() && server.ignore().is_ignored(rel, false)) {
1405 err = make_error("ignored",
1406 "Path '" + rel + "' is excluded by .gitignore or "
1407 ".explorerignore.");
1408 }
1409 return err;
1410}
1411
1421std::string read_window(const json& args, long budget, ReadWindow& w) {
1422 w.offset = args.value<long>("offset", 1);
1423 w.limit = args.value<long>("limit", 0);
1424 w.budget = budget;
1425 return (1 > w.offset || 0 > w.limit)
1426 ? make_error("invalid_args", "offset must be >= 1 and limit >= 1")
1427 : std::string{};
1428}
1429
1442json read_and_track(FilesystemServer& server, const fs::path& resolved,
1443 const std::string& display, const json& args,
1444 std::string& err) {
1445 ReadWindow w;
1446 err = read_window(args, server.max_read_bytes(), w);
1447 json result;
1448 if (err.empty()) { result = read_numbered_lines(resolved, display, w, err); }
1449 if (err.empty()) {
1450 server.tracker().record_read(current_run_session(), resolved.string(),
1451 hash_content(result["lines"].dump()));
1452 logger->info("Read file: {} lines {}..{} of {} (truncated={})",
1453 resolved.string(), w.offset,
1454 w.offset + static_cast<long>(result["lines"].size()) - 1,
1455 result["total"].get<long>(), result["truncated"].get<bool>());
1456 }
1457 return result;
1458}
1459
1478 auto args = json::parse(args_json);
1479 auto resolved = server_.resolve_path(args.at("path").get<std::string>(),
1481 auto display = display_path(server_.root_dir(), resolved);
1482 auto err = check_read_gates(server_, resolved, display);
1483 json result;
1484 if (err.empty()) { result = read_and_track(server_, resolved, display, args, err); }
1485 return {err.empty() ? result.dump() : err, {}};
1486}
1487
1488// ── WriteFileTool ────────────────────────────────────────
1489
1495class WriteFileTool : public ToolBase {
1496public:
1505 const std::string& data_dir)
1507 "write_file", "filesystem",
1508 data_dir + "/tools")),
1509 server_(server) {}
1510
1518 ServerResponse execute(const std::string& args_json) override;
1519
1520private:
1521 FilesystemServer& server_;
1522};
1523
1540 const std::string& args_json) {
1541
1542 auto args = json::parse(args_json);
1543 auto requested = args.at("path").get<std::string>();
1544 auto content = args.at("content").get<std::string>();
1545 auto resolved = server_.resolve_path(requested, PathAccess::write,
1546 name());
1547 auto path_str = resolved.string();
1548
1550 server_.tracker(), path_str);
1551 if (!violation.empty()) {
1552 return {violation, {}};
1553 }
1554
1555 write_file_contents(resolved, content);
1556 logger->info("Wrote file: {} ({} bytes)",
1557 path_str, content.size());
1558
1559 json result;
1560 result["path"] = display_path(server_.root_dir(), resolved);
1561 result["bytes_written"] = content.size();
1562 result["message"] = "File written successfully";
1563 return {result.dump(), {}};
1564}
1565
1566// ── EditFileTool ─────────────────────────────────────────
1567
1573class EditFileTool : public ToolBase {
1574public:
1583 const std::string& data_dir)
1585 "edit_file", "filesystem",
1586 data_dir + "/tools")),
1587 server_(server) {}
1588
1596 ServerResponse execute(const std::string& args_json) override;
1597
1598private:
1599 FilesystemServer& server_;
1600};
1601
1614 auto args = json::parse(args_json);
1615 auto requested = args.at("path").get<std::string>();
1616 auto resolved = server_.resolve_path(requested, PathAccess::write,
1617 name());
1619 server_.tracker(), resolved.string());
1620 if (!violation.empty()) {
1621 return {violation, {}};
1622 }
1623 return {apply_edit(args, resolved,
1624 display_path(server_.root_dir(), resolved)), {}};
1625}
1626
1627// ── GlobTool ─────────────────────────────────────────────
1628
1634class GlobTool : public ToolBase {
1635public:
1643 GlobTool(FilesystemServer& server, const std::string& data_dir)
1645 "glob", "filesystem",
1646 data_dir + "/tools")),
1647 server_(server) {}
1648
1657 return MCPAccessLevel::READ;
1658 }
1659
1667 ServerResponse execute(const std::string& args_json) override;
1668
1669private:
1670 FilesystemServer& server_;
1671};
1672
1686 auto args = json::parse(args_json);
1687 auto pattern = args.at("pattern").get<std::string>();
1688 Page requested;
1689 auto err = read_page(args, 500, 5000, requested);
1690 if (!err.empty()) { return {err, {}}; }
1691
1692 WalkBudget budget{server_.config().max_walk_entries, 0, false};
1693 PagedResult page(requested);
1694 collect_glob_matches(server_.root_dir(), pattern, &server_.ignore(),
1695 budget, page);
1696 json result;
1697 page.finish(result, "paths", budget.truncated);
1698 logger->info("Glob '{}': {} paths, {} entries walked, truncated={}{}",
1699 pattern, result["paths"].size(), budget.visited,
1700 result["truncated"].get<bool>(),
1701 budget.truncated ? " (walk cap — narrow the pattern)" : "");
1702 return {result.dump(), {}};
1703}
1704
1705// ── GrepTool ─────────────────────────────────────────────
1706
1712class GrepTool : public ToolBase {
1713public:
1720 GrepTool(FilesystemServer& server, const std::string& data_dir)
1722 "grep", "filesystem",
1723 /* tools dir: */ data_dir + "/tools")),
1724 server_(server) {}
1725
1734 return MCPAccessLevel::READ;
1735 }
1736
1744 ServerResponse execute(const std::string& args_json) override;
1745
1746private:
1747 FilesystemServer& server_;
1748};
1749
1762std::regex compile_grep_or_error(const std::string& pattern,
1763 std::string& err) {
1764 try {
1765 return std::regex(pattern);
1766 } catch (const std::regex_error& e) {
1767 err = make_error("invalid_regex", e.what());
1768 return std::regex("(?!)");
1769 }
1770}
1771
1795static void grep_search(
1796 const fs::path& root, const fs::path& start,
1797 const std::vector<std::string>& file_patterns,
1798 const std::regex& re, const IgnoreMatcher& ignore,
1799 WalkBudget& budget, PagedResult& page) {
1800 auto it = fs::recursive_directory_iterator(
1801 start, fs::directory_options::skip_permission_denied);
1802 for (auto& entry : it) {
1803 auto action = classify_glob_entry(entry, root, file_patterns,
1804 &ignore);
1805 if (action == EntryAction::kSkipPrune) {
1806 it.disable_recursion_pending();
1807 } else if (action == EntryAction::kTake
1808 && !grep_file(entry.path(),
1809 display_path(root, entry.path()), re, page)) {
1810 break;
1811 }
1812 if (walk_budget_spent(budget)) { break; }
1813 }
1814}
1815
1827static void grep_target(FilesystemServer& server, const fs::path& start,
1828 const json& args, const std::regex& re,
1829 WalkBudget& budget, PagedResult& page) {
1830 const auto& root = server.root_dir();
1831 if (fs::is_regular_file(start)) {
1832 grep_file(start, display_path(root, start), re, page);
1833 } else {
1834 grep_search(root, start,
1835 expand_braces(args.value("glob", std::string("*"))),
1836 re, server.ignore(), budget, page);
1837 }
1838}
1839
1852static std::string grep_preflight(const json& args, const fs::path& start,
1853 const fs::path& root, Page& requested,
1854 std::regex& re) {
1855 std::string err = read_page(args, 100, 1000, requested);
1856 re = compile_grep_or_error(args.at("pattern").get<std::string>(), err);
1857 if (err.empty() && !fs::exists(start)) {
1858 err = make_error("not_found", "No such file or directory: "
1859 + display_path(root, start));
1860 }
1861 return err;
1862}
1863
1882 auto args = json::parse(args_json);
1883 auto start = server_.resolve_path(args.value("path", std::string(".")),
1885 Page requested;
1886 std::regex re;
1887 auto err = grep_preflight(args, start, server_.root_dir(), requested, re);
1888 if (!err.empty()) { return {err, {}}; }
1889
1890 WalkBudget budget{server_.config().max_walk_entries, 0, false};
1891 PagedResult page(requested);
1892 grep_target(server_, start, args, re, budget, page);
1893 json result;
1894 page.finish(result, "matches", budget.truncated);
1895 logger->info("Grep '{}' in {}: {} matches, {} entries walked, truncated={}",
1896 args.at("pattern").get<std::string>(), start.string(),
1897 result["matches"].size(), budget.visited,
1898 result["truncated"].get<bool>());
1899 return {result.dump(), {}};
1900}
1901
1902// ── ListDirectoryTool ────────────────────────────────────
1903
1910public:
1919 const std::string& data_dir)
1921 "list_directory", "filesystem",
1922 data_dir + "/tools")),
1923 server_(server) {}
1924
1933 return MCPAccessLevel::READ;
1934 }
1935
1943 ServerResponse execute(const std::string& args_json) override;
1944
1945private:
1946 FilesystemServer& server_;
1947};
1948
1959static void list_page(const fs::path& dir, const json& args, Page requested,
1960 int max_walk, json& result) {
1961 WalkBudget budget{max_walk, 0, false};
1962 PagedResult page(requested);
1963 for (auto& e : collect_entries(dir, args.value("recursive", false),
1964 args.value("max_depth", 3), budget)) {
1965 if (!page.offer(std::move(e))) { break; }
1966 }
1967 page.finish(result, "entries", budget.truncated);
1968}
1969
1988 const std::string& args_json) {
1989
1990 auto args = json::parse(args_json);
1991 auto resolved = server_.resolve_path(args.value("path", std::string(".")),
1993 auto display = display_path(server_.root_dir(), resolved);
1994 Page requested;
1995 auto err = read_page(args, 500, 5000, requested);
1996 if (err.empty() && !fs::is_directory(resolved)) {
1997 err = make_error("not_directory", "Not a directory: " + display);
1998 }
1999 if (!err.empty()) { return {err, {}}; }
2000
2001 json result;
2002 result["path"] = display;
2003 list_page(resolved, args, requested, server_.config().max_walk_entries, result);
2004 logger->info("Listed {}: {} entries, truncated={}", display,
2005 result["entries"].size(), result["truncated"].get<bool>());
2006 return {result.dump(), {}};
2007}
2008
2009// ── FilesystemServer ─────────────────────────────────────
2010
2023 int model_context_bytes) {
2024 if (config.max_read_bytes.has_value()) {
2025 return config.max_read_bytes.value();
2026 }
2027 if (model_context_bytes <= 0) {
2028 // Safe default: 32KB prevents a single file from blowing typical
2029 // context budgets (16-128K). Large files trigger size_exceeded and
2030 // the model must use offset/limit or docs.* tools instead.
2031 return 32 * 1024;
2032 }
2033 return static_cast<int>(
2034 model_context_bytes * config.max_read_context_pct);
2035}
2036
2054 const fs::path& root_dir,
2055 const FilesystemConfig& config,
2056 const std::string& data_dir,
2058 : MCPServerBase("filesystem"),
2059 root_dir_(fs::weakly_canonical(root_dir)),
2060 config_(config),
2061 max_read_bytes_(compute_max_read_bytes(
2063
2064 create_fs_tools(data_dir);
2065 register_fs_tools();
2066
2067 // Issue #15 (v2.1.4): load .gitignore + .explorerignore so glob,
2068 // grep, and read_file can filter out build artifacts and vendor
2069 // blobs that pre-2.1.4 leaked into results.
2070 ignore_.load(root_dir_);
2071
2072 logger->info("FilesystemServer initialized: root={}, "
2073 "max_read_bytes={}, ignore_rules={}",
2074 root_dir_.string(),
2075 max_read_bytes_,
2076 ignore_.rule_count());
2077 log_outside_root_policy(); // v2.13.0: the posture is observable
2078}
2079
2091void FilesystemServer::create_fs_tools(const std::string& data_dir) {
2092 read_file_ = std::make_unique<ReadFileTool>(*this, data_dir);
2093 write_file_ = std::make_unique<WriteFileTool>(*this, data_dir);
2094 edit_file_ = std::make_unique<EditFileTool>(*this, data_dir);
2095 glob_ = std::make_unique<GlobTool>(*this, data_dir);
2096 grep_ = std::make_unique<GrepTool>(*this, data_dir);
2097 list_dir_ = std::make_unique<ListDirectoryTool>(*this, data_dir);
2098}
2099
2109void FilesystemServer::register_fs_tools() {
2110 register_tool(read_file_.get());
2111 register_tool(write_file_.get());
2112 register_tool(edit_file_.get());
2113 register_tool(glob_.get());
2114 register_tool(grep_.get());
2115 register_tool(list_dir_.get());
2116}
2117
2124
2139 const std::string& tool_name) const {
2140 return tool_name == "read_file";
2141}
2142
2157bool FilesystemServer::set_working_dir(const std::string& path) {
2158 auto canonical = fs::weakly_canonical(path);
2159 if (!fs::is_directory(canonical)) {
2160 logger->error("set_working_dir: not a directory: {}",
2161 path);
2162 return false;
2163 }
2164 root_dir_ = canonical;
2165 // Issue #15 (v2.1.4): reload ignore rules for the new root.
2166 ignore_.load(root_dir_);
2167 logger->info("Working directory changed to: {} (ignore_rules={})",
2168 root_dir_.string(), ignore_.rule_count());
2169 // v2.13.0: "outside the root" just moved; say what that now means.
2170 log_outside_root_policy();
2171 return true;
2172}
2173
2181const fs::path& FilesystemServer::root_dir() const {
2182 return root_dir_;
2183}
2184
2194 return tracker_;
2195}
2196
2205bool FilesystemServer::release_session(const std::string& key) {
2206 return tracker_.release_session(key);
2207}
2208
2216 return tracker_.session_count();
2217}
2218
2228 return ignore_;
2229}
2230
2238 return config_;
2239}
2240
2249 return max_read_bytes_;
2250}
2251
2280 const std::string& requested, PathAccess access,
2281 const std::string& tool) const {
2282
2283 fs::path req_path(requested);
2284 fs::path resolved = req_path.is_absolute()
2285 ? fs::weakly_canonical(req_path)
2286 : fs::weakly_canonical(root_dir_ / req_path);
2287
2288 if (!path_within(resolved, root_dir_)) {
2289 authorize_outside_root(resolved, access, tool);
2290 }
2291 return resolved;
2292}
2293
2312void FilesystemServer::authorize_outside_root(
2313 const fs::path& resolved, PathAccess access,
2314 const std::string& tool) const {
2315 auto denied = first_containing(config_.outside_root_deny, resolved);
2316 auto allowed = first_containing(config_.outside_root_allow, resolved);
2317 const auto mode = config_.allow_outside_root;
2318 if (denied.has_value()) {
2319 refuse_denied(resolved, *denied, access, root_dir_);
2320 } else if (allowed.has_value() || mode == OutsideRootAccess::allow) {
2321 log_outside_root_served(resolved, access, allowed);
2322 } else if (mode == OutsideRootAccess::refuse) {
2323 refuse_escape(resolved, root_dir_);
2324 } else {
2325 ask_outside_root_approver(resolved, access, tool);
2326 }
2327}
2328
2346void FilesystemServer::ask_outside_root_approver(
2347 const fs::path& resolved, PathAccess access,
2348 const std::string& tool) const {
2349 OutsideRootApprover fn = nullptr;
2350 void* ud = nullptr;
2351 {
2352 std::lock_guard<std::mutex> lock(approver_mutex_);
2353 fn = approver_;
2354 ud = approver_data_;
2355 }
2356 OutsideRootRequest req{resolved.string(), root_dir_.string(),
2357 name() + "." + tool, access};
2358 auto verdict = fn != nullptr ? fn(req, ud)
2360 if (verdict == OutsideRootVerdict::approved) {
2361 logger->info("Outside-root {} APPROVED by host: {} ({})",
2362 access_verb(access), req.path, req.tool);
2363 } else {
2364 auto msg = outside_root_refusal(verdict, req);
2365 logger->error("Path escape blocked: {}", msg);
2366 throw std::runtime_error(msg);
2367 }
2368}
2369
2378 OutsideRootApprover fn, void* user_data) {
2379 std::lock_guard<std::mutex> lock(approver_mutex_);
2380 approver_ = fn;
2381 approver_data_ = user_data;
2382 logger->info("Outside-root approver {} (root: {})",
2383 fn != nullptr ? "installed" : "cleared",
2384 root_dir_.string());
2385}
2386
2399void FilesystemServer::log_outside_root_policy() const {
2400 const auto& deny = config_.outside_root_deny;
2402 logger->warn("Outside-root policy: allow_outside_root=true — every "
2403 "path outside {} is served (deny={})",
2404 root_dir_.string(), join_paths(deny));
2405 } else {
2406 logger->info("Outside-root policy: allow_outside_root={} allow={} "
2407 "deny={} (root: {})",
2410 join_paths(deny), root_dir_.string());
2411 }
2412 warn_deny_entries_inside_root(deny, root_dir_);
2413}
2414
2415} // namespace entropic
Tool for in-place file editing (string replace or insert).
ServerResponse execute(const std::string &args_json) override
Edit a file via string replacement or line insertion.
EditFileTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
Tracks file read state for read-before-write enforcement.
Definition filesystem.h:42
bool was_read(const std::string &session, const std::string &path) const
Check whether session has read a file.
void record_read(const std::string &session, const std::string &path, size_t hash)
Record that session read a file.
std::size_t session_count() const
Sessions currently holding recorded reads.
bool release_session(const std::string &session)
Forget every read session recorded (gh#158).
Filesystem MCP server with read-before-write enforcement.
Definition filesystem.h:153
int max_read_bytes() const
Get max read bytes (size gate).
void set_outside_root_approver(OutsideRootApprover fn, void *user_data)
Install the approver consulted under optional (v2.13.0).
const IgnoreMatcher & ignore() const
Get the ignore matcher (#15, v2.1.4).
std::size_t session_count() const override
Sessions whose reads this server currently tracks (gh#158).
bool skip_duplicate_check(const std::string &tool_name) const override
read_file must always execute (updates FileAccessTracker).
bool set_working_dir(const std::string &path) override
Set working directory (changes root_dir).
~FilesystemServer() override
Destructor (default, unique_ptr cleanup).
const FilesystemConfig & config() const
Get the filesystem config.
std::filesystem::path resolve_path(const std::string &requested, PathAccess access, const std::string &tool) const
Resolve a path and apply the outside-root policy (v2.13.0).
FileAccessTracker & tracker()
Get the file access tracker.
FilesystemServer(const std::filesystem::path &root_dir, const FilesystemConfig &config, const std::string &data_dir, int model_context_bytes=0)
Construct with root directory, config, and data dir.
bool release_session(const std::string &key) override
Release key's recorded reads (gh#158).
const std::filesystem::path & root_dir() const
Get the root directory.
Tool for recursive file pattern matching.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
ServerResponse execute(const std::string &args_json) override
Find files matching a glob pattern.
GlobTool(FilesystemServer &server, const std::string &data_dir)
Construct with server reference and data directory.
Tool for regex content search across files.
ServerResponse execute(const std::string &args_json) override
Search files for regex pattern matches.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
GrepTool(FilesystemServer &server, const std::string &data_dir)
Construct from data directory.
gitignore-style path matcher (#15, v2.1.4).
static bool is_skipped_dir_name(const std::string &name)
Directory names never walked, by name alone (gh#161).
void load(const std::filesystem::path &root)
Load gitignore + explorerignore from a workspace root.
bool is_ignored(const std::string &rel_path, bool is_dir) const
Test whether a path is ignored.
std::size_t rule_count() const
Number of compiled rules (test surface).
Tool for listing directory contents.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
ListDirectoryTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
ServerResponse execute(const std::string &args_json) override
List directory entries with optional recursion.
Concrete base class for MCP servers (80% logic).
Definition server_base.h:66
const std::string & name() const
Get the server name.
void register_tool(ToolBase *tool)
Register a tool with this server.
Tool for reading file contents with line numbering.
ReadFileTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
ServerResponse execute(const std::string &args_json) override
Read a file and return numbered lines as JSON.
MCPAccessLevel required_access_level() const override
Read-only tool — requires READ access.
std::string anchor_key(const std::string &args_json) const override
Anchor key for context deduplication.
auto peek(const std::string &key, Fn &&fn) const
Run fn on key's state WITHOUT creating it.
bool release(const std::string &key)
Forget key's state.
auto with(const std::string &key, Fn &&fn)
Run fn on key's state, creating it empty on first use.
std::size_t session_count() const
Sessions currently holding state (bounded-growth probe).
Abstract base class for individual MCP tools.
Definition tool_base.h:52
const std::string & name() const
Get the tool name.
Definition tool_base.cpp:40
Tool for writing file contents with read-before-write.
ServerResponse execute(const std::string &args_json) override
Write content to a file after read-before-write check.
WriteFileTool(FilesystemServer &server, const std::string &data_dir)
Construct from server reference and data directory.
json lines
Lines taken so far.
long visited
Entries visited so far.
bool more
A line past the window exists.
std::string err
size_exceeded, when set
const ReadWindow & w
Requested window.
long budget
Max content bytes; <= 0 = unbounded.
bool truncated
Walk stopped on the cap.
int max_entries
<= 0 means unbounded
long bytes
Content bytes taken.
long limit
Results to keep.
long offset
Results to skip (from a previous next_offset)
Filesystem MCP server — read/write/edit/glob/grep/list_directory.
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).
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.
static int compute_max_read_bytes(const FilesystemConfig &config, int model_context_bytes)
Compute max read bytes from config and model context.
@ ok
Tool dispatched, returned non-empty content.
static void grep_search(const fs::path &root, const fs::path &start, const std::vector< std::string > &file_patterns, const std::regex &re, const IgnoreMatcher &ignore, WalkBudget &budget, PagedResult &page)
Execute grep: brace-expand the file glob, compile the content regex (error-safe), iterate the tree ap...
OutsideRootVerdict
The host's answer to an OutsideRootRequest (v2.13.0).
Definition filesystem.h:120
@ approved
Serve this one call.
@ rejected
The approver said no.
@ no_approver
No approver is registered — refuse, fail loud.
OutsideRootAccess
What the filesystem server does with a path outside its root.
Definition config.h:771
@ refuse
false: every escape refused ("Path escapes project root")
@ allow
true: every escape served, no prompt
@ optional
optional: every escape asks the host's path approver
static void grep_target(FilesystemServer &server, const fs::path &start, const json &args, const std::regex &re, WalkBudget &budget, PagedResult &page)
Search start — one file, or a tree under the glob filter.
static void list_page(const fs::path &dir, const json &args, Page requested, int max_walk, json &result)
Collect one page of a directory listing into result.
@ count
Sentinel — MUST remain last.
ENTROPIC_EXPORT std::string current_run_session()
The session key of the run executing on this thread (gh#166).
Definition run_scope.cpp:95
OutsideRootVerdict(*)(const OutsideRootRequest &request, void *user_data) OutsideRootApprover
Approver the server consults under allow_outside_root: optional.
Definition filesystem.h:134
PathAccess
What a filesystem tool is about to do with a path (v2.13.0).
Definition filesystem.h:95
@ read
read_file, list_directory
@ write
write_file, edit_file
std::regex compile_grep_or_error(const std::string &pattern, std::string &err)
Compile a regex or return a structured tool error.
static std::string grep_preflight(const json &args, const fs::path &start, const fs::path &root, Page &requested, std::regex &re)
Validate a grep call: paging, regex, and that path exists.
MCPAccessLevel
MCP tool access level for per-identity authorization.
Definition config.h:38
@ READ
Read-only operations (e.g., read_file, list_directory)
std::string check_read_gates(FilesystemServer &server, const fs::path &resolved, const std::string &display)
Execute read_file: resolve, size-check, read, hash, track.
json read_and_track(FilesystemServer &server, const fs::path &resolved, const std::string &display, const json &args, std::string &err)
Read the requested window and record the read for the session.
std::string read_window(const json &args, long budget, ReadWindow &w)
The window a read_file call asks for, validated.
@ full
-1 or >= 99: the whole file is resident.
gh#158: the cancel token of the run executing on THIS thread.
std::string config
–config FILE
Definition serve.cpp:38
MCPServerBase concrete base class + ServerResponse.
Filesystem MCP server configuration.
Definition config.h:781
std::vector< std::filesystem::path > outside_root_allow
Pre-approved subtrees outside the root — no prompt (v2.13.0).
Definition config.h:813
std::vector< std::filesystem::path > outside_root_deny
Subtrees outside the root that are ALWAYS refused (v2.13.0).
Definition config.h:821
int max_walk_entries
Hard cap on directory entries visited by ONE glob or grep walk; <= 0 disables the cap (gh#161).
Definition config.h:849
OutsideRootAccess allow_outside_root
Paths OUTSIDE the root: refuse, allow, or ask (v2.13.0).
Definition config.h:806
Structured result from tool execution.
Definition server_base.h:33
Abstract base class for individual MCP tools.