Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 24 additions & 4 deletions src/foundation/workspace.c
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down Expand Up @@ -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));
}
Expand Down
11 changes: 8 additions & 3 deletions src/foundation/workspace.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/ui/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
52 changes: 46 additions & 6 deletions tests/test_workspace.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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)));
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand Down
Loading