mcp: optional CBM_ALLOWED_ROOT boundary for index_repository

index_repository canonicalizes repo_path but imposes no workspace boundary,
so a caller-supplied path can resolve anywhere on disk. Add an opt-in
CBM_ALLOWED_ROOT: when set, a repo_path that resolves outside it is refused
(reusing the same containment check the read sinks use). Unset by default, so
the standard index-the-given-path behaviour is unchanged; deployments where
repo_path may be influenced by an untrusted caller can set a boundary.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-07-07 22:13:09 +02:00
parent c8f3db2831
commit 29bf1cd8c1
2 changed files with 46 additions and 0 deletions
+16
View File
@@ -3842,6 +3842,8 @@ char *cbm_mcp_index_run_supervised_path(const char *root_path) {
return index_run_supervised_path(NULL, root_path);
}
bool cbm_path_within_root(const char *root_path, const char *abs_path); /* defined below */
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
/* Supervisor gate: run the index in a crash/hang-isolating worker subprocess
* unless this process IS the worker or the kill switch (CBM_INDEX_SUPERVISOR=0)
@@ -3866,6 +3868,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
repo_path = canonicalize_repo_path_if_exists(repo_path);
/* Optional workspace boundary: when CBM_ALLOWED_ROOT is set (agentic /
* multi-tenant deployments where repo_path may be influenced by an
* untrusted caller), refuse to index a path that resolves outside it.
* Unset by default, so the standard "index the path I gave you" behaviour
* is unchanged. */
const char *allowed_root = getenv("CBM_ALLOWED_ROOT");
if (allowed_root && allowed_root[0] && repo_path &&
!cbm_path_within_root(allowed_root, repo_path)) {
free(mode_str);
free(name_override);
free(repo_path);
return cbm_mcp_text_result("repo_path is outside the allowed root", true);
}
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
free(mode_str);
free(name_override);
+30
View File
@@ -4929,6 +4929,35 @@ TEST(detect_changes_rejects_option_like_base_branch) {
PASS();
}
/* Opt-in workspace boundary: when CBM_ALLOWED_ROOT is set, index_repository
* must refuse a repo_path that resolves outside it. Unset (the default) imposes
* no restriction. */
TEST(index_repository_honors_allowed_root) {
char allowed[512];
snprintf(allowed, sizeof(allowed), "%s/cbm_allowed_XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(allowed)) {
FAIL("cbm_mkdtemp failed");
}
cbm_setenv("CBM_ALLOWED_ROOT", allowed, 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char args[1024];
snprintf(args, sizeof(args),
"{\"jsonrpc\":\"2.0\",\"id\":88,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_repository\","
"\"arguments\":{\"repo_path\":\"%s/../..\"}}}",
allowed); /* resolves to a parent, outside the allowed root */
char *resp = cbm_mcp_server_handle(srv, args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "outside the allowed root"));
free(resp);
cbm_unsetenv("CBM_ALLOWED_ROOT");
cbm_mcp_server_free(srv);
cbm_rmdir(allowed);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SUITE
* ══════════════════════════════════════════════════════════════════ */
@@ -4936,6 +4965,7 @@ TEST(detect_changes_rejects_option_like_base_branch) {
SUITE(mcp) {
RUN_TEST(mcp_path_within_root_rejects_escape);
RUN_TEST(detect_changes_rejects_option_like_base_branch);
RUN_TEST(index_repository_honors_allowed_root);
/* JSON-RPC parsing */
RUN_TEST(jsonrpc_parse_request);
RUN_TEST(jsonrpc_parse_notification);