diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index b49300285..6702fd1c9 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index b501a5eb3..7914a8bb8 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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);