From 1e92269d7562d89a816271c4d99904ee53a94606 Mon Sep 17 00:00:00 2001 From: Mark Murawski Date: Wed, 2 Sep 2026 13:38:05 -0400 Subject: [PATCH] feat(workspace): add PATH_ALLOW_BROAD override for too-shallow roots CBM_WS_DENY_TOO_SHALLOW ("path is too broad to index as one root") has no override today: cbm_workspace_verdict_is_overridable() only lifts CBM_WS_DENY_SENSITIVE, and `allow-root --list` already documents shallow/absolute refusals as "always-refused" rather than configurable. That default is right -- a bare top-level tree like "/etc" or "/home" is almost always a mistake -- but it leaves no way to say "I mean this one specific broad root, on purpose." That's a real case: a person who deliberately wants one combined project spanning several sibling repos under a shared parent has no path forward today short of physically restructuring their checkout. PATH_ALLOW_BROAD names one exact canonical path that lifts a CBM_WS_DENY_TOO_SHALLOW verdict, and only that verdict: - exact match only, not a prefix -- naming one broad root must not quietly approve every root below it too, the same "/Users"-style breadth this depth rule exists to catch in the first place - process environment, not a recorded grant: it never appears in cbm_workspace_grant_list, and cbm_workspace_verdict_is_overridable() is untouched, so CBM_WS_DENY_ABSOLUTE and CBM_WS_DENY_SENSITIVE stay exactly as unliftable as before - read once per call site with getenv(), mirroring how CBM_ALLOWED_ROOT is already threaded into cbm_workspace_root_allowed() from each of its four callers, rather than read inside cbm_workspace_classify_root() itself -- that function is documented as a pure function of its arguments precisely so it stays host-independent and directly testable Also updates the refusal message to name the fix, the same way the sensitive-root refusal already names `allow-root --approve-sensitive`. Adds ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only alongside the existing too-shallow coverage, checking the exact-match requirement, the untouched sensitive/absolute paths, and that nothing is written to the grant store. Signed-off-by: Mark Murawski --- src/daemon/application.c | 3 ++- src/foundation/workspace.c | 28 +++++++++++++++++--- src/foundation/workspace.h | 11 +++++--- src/mcp/mcp.c | 6 +++-- src/ui/http_server.c | 3 ++- tests/test_workspace.c | 52 +++++++++++++++++++++++++++++++++----- 6 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/daemon/application.c b/src/daemon/application.c index 12543808e..ecb5095c7 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -437,7 +437,8 @@ static bool application_session_workspace_allowed(const cbm_daemon_application_s bool allowed = root && root[0] && cbm_workspace_root_allowed(root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - cbm_mcp_server_allowed_root(session->mcp), boundary_error, + cbm_mcp_server_allowed_root(session->mcp), + getenv("PATH_ALLOW_BROAD"), boundary_error, sizeof(boundary_error)); if (!allowed) { cbm_log_warn("daemon.workspace.skipped", "operation", operation, "detail", diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c index cb254f3d9..0daab3806 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -522,8 +522,8 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, - const char *cache_dir, const char *configured_root, char *err, - size_t err_sz) { + const char *cache_dir, const char *configured_root, + const char *allow_broad_root, char *err, size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -579,17 +579,37 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return true; } /* An explicit human approval recorded for exactly this path is the only thing - * that lifts a sensitive refusal. Absolute and shallow refusals cannot be - * lifted at all. */ + * that lifts a sensitive refusal. Absolute refusals cannot be lifted at all. */ if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) { return true; } + /* PATH_ALLOW_BROAD is a separate, narrower lever from the grant store above: + * an operator has named this exact root as one they have deliberately chosen + * to index whole, accepting that it may span multiple unrelated projects -- + * the tradeoff CBM_WS_DENY_TOO_SHALLOW normally refuses on their behalf. It + * must match canonical_path exactly, the same way an exact sensitive grant + * does above: a prefix match would let one broad allowance quietly cover + * every root below it too, which is the "/Users"-style breadth this rule + * exists to catch. It is environment, not a recorded grant, so it is not + * listed by cbm_workspace_grant_list and cannot lift CBM_WS_DENY_ABSOLUTE or + * CBM_WS_DENY_SENSITIVE. */ + if (verdict == CBM_WS_DENY_TOO_SHALLOW && allow_broad_root && allow_broad_root[0] && + ws_paths_equal(canonical_path, allow_broad_root)) { + return true; + } if (err) { if (cbm_workspace_verdict_is_overridable(verdict)) { snprintf(err, err_sz, "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root " "--approve-sensitive %s", canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); + } else if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + /* No allow-root command exists for this one -- the grant store is a + * human-approved list of real project roots, and this path is being + * refused for being the opposite of that. PATH_ALLOW_BROAD is the + * deliberate, narrower escape hatch: set it to this exact path. */ + snprintf(err, err_sz, "%s: %s. To index it anyway, set PATH_ALLOW_BROAD=%s", + canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); } else { snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict)); } diff --git a/src/foundation/workspace.h b/src/foundation/workspace.h index 276079155..4884dbdd0 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -93,10 +93,15 @@ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz); /* The whole decision, used by every entry point that accepts a repo path. * canonical_path must already be canonicalized. configured_root is the legacy * CBM_ALLOWED_ROOT / session policy, treated as an additional grant, or NULL. - * On refusal, err receives a message that names the command which would fix it. */ + * allow_broad_root is PATH_ALLOW_BROAD (or NULL): an operator-set exact match + * for canonical_path that lifts a CBM_WS_DENY_TOO_SHALLOW verdict only. It is + * process environment, not a recorded grant, so it does not appear in + * cbm_workspace_grant_list and does not widen CBM_WS_DENY_ABSOLUTE or + * CBM_WS_DENY_SENSITIVE. On refusal, err receives a message that names the + * command which would fix it. */ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, - const char *cache_dir, const char *configured_root, char *err, - size_t err_sz); + const char *cache_dir, const char *configured_root, + const char *allow_broad_root, char *err, size_t err_sz); /* The home and cache directories the policy should be evaluated against. Shared * so two callers cannot derive them differently and reach different verdicts for diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 73534404a..372cd521f 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -8747,7 +8747,8 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { char boundary_err[CBM_SZ_1K]; if (repo_path && repo_path[0] && !cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - allowed_root, boundary_err, sizeof(boundary_err))) { + allowed_root, getenv("PATH_ALLOW_BROAD"), boundary_err, + sizeof(boundary_err))) { free(mode_str); free(name_override); free(repo_path); @@ -12851,7 +12852,8 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT"); char boundary_err[CBM_SZ_1K]; if (!cbm_workspace_root_allowed(srv->session_root, cbm_workspace_home_dir(), - cbm_workspace_cache_dir(), allowed_root, boundary_err, + cbm_workspace_cache_dir(), allowed_root, + getenv("PATH_ALLOW_BROAD"), boundary_err, sizeof(boundary_err))) { cbm_log_warn("autoindex.skip", "reason", "workspace_boundary", "detail", boundary_err); return; diff --git a/src/ui/http_server.c b/src/ui/http_server.c index b187438a5..c95c9d79e 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1173,7 +1173,8 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, } if (!cbm_workspace_root_allowed(canonical_root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), getenv("CBM_ALLOWED_ROOT"), - boundary_err, sizeof(boundary_err))) { + getenv("PATH_ALLOW_BROAD"), boundary_err, + sizeof(boundary_err))) { yyjson_doc_free(doc); char escaped[1024]; cbm_json_escape(escaped, (int)sizeof(escaped), boundary_err); diff --git a/tests/test_workspace.c b/tests/test_workspace.c index faf936e1b..e87958ea9 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -70,6 +70,45 @@ TEST(ws_posix_top_level_trees_are_too_shallow) { PASS(); } +/* PATH_ALLOW_BROAD is deliberately not part of the grant store's overridability: + * cbm_workspace_classify_root and cbm_workspace_verdict_is_overridable must stay + * exactly as strict as the test above already checked. Only the top-level + * decision, cbm_workspace_root_allowed, knows about it, and only as an exact + * match. */ +TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { + char cache[256]; + char *created = th_mktempdir("cbm_ws_allow_broad"); + ASSERT_NOT_NULL(created); + snprintf(cache, sizeof(cache), "%s", created); + + char err[1024]; + ASSERT_FALSE(cbm_workspace_root_allowed("/srv", "/home/dev", cache, NULL, NULL, err, + sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "PATH_ALLOW_BROAD")); + + ASSERT_TRUE( + cbm_workspace_root_allowed("/srv", "/home/dev", cache, NULL, "/srv", err, sizeof(err))); + + /* A grant for the root does not extend to a sibling: PATH_ALLOW_BROAD names + * one path, not a prefix. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/opt", "/home/dev", cache, NULL, "/srv", err, + sizeof(err))); + + /* Sensitive and absolute refusals are a different verdict and stay refused + * even when PATH_ALLOW_BROAD happens to name that exact path. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/home/dev", "/home/dev", cache, NULL, "/home/dev", + err, sizeof(err))); + ASSERT_FALSE( + cbm_workspace_root_allowed("/", "/home/dev", cache, NULL, "/", err, sizeof(err))); + + /* Environment, not a grant: it never shows up in the recorded list. */ + char listed[64]; + ASSERT_FALSE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + + th_cleanup(cache); + PASS(); +} + /* Legitimately shallow project roots must survive: these are the false positives * a blanket depth rule would cause, which is why Windows counts drive-relative. */ TEST(ws_legitimate_shallow_roots_are_allowed) { @@ -170,10 +209,10 @@ TEST(ws_sensitive_root_explicit_approval_is_preserved) { snprintf(cache, sizeof(cache), "%s", created); char err[1024]; - ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); th_cleanup(root); th_cleanup(cache); @@ -193,10 +232,10 @@ TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant) { char err[1024]; ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, err, sizeof(err))); - ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, NULL, err, sizeof(err))); /* A repeated explicit approval must recognize the marked exact grant rather * than append another exception. */ ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err))); @@ -228,11 +267,11 @@ TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor) { ASSERT_EQ(cbm_mkdir(sensitive), 0); char err[1024]; ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, err, sizeof(err))); - ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, NULL, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); ASSERT_TRUE( - cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); + cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, NULL, err, sizeof(err))); ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); char listed[4096]; @@ -385,6 +424,7 @@ SUITE(workspace) { RUN_TEST(ws_volume_roots_are_absolutely_denied); RUN_TEST(ws_non_absolute_paths_are_denied); RUN_TEST(ws_posix_top_level_trees_are_too_shallow); + RUN_TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only); RUN_TEST(ws_legitimate_shallow_roots_are_allowed); RUN_TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine); RUN_TEST(ws_credential_directories_are_sensitive_at_any_depth);