From 1e92269d7562d89a816271c4d99904ee53a94606 Mon Sep 17 00:00:00 2001 From: Mark Murawski Date: Wed, 2 Sep 2026 13:38:05 -0400 Subject: [PATCH 1/2] 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 12543808ef..ecb5095c7b 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 cb254f3d91..0daab38069 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 276079155b..4884dbdd0e 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 73534404a7..372cd521f7 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 b187438a5e..c95c9d79ee 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 faf936e1b1..e87958ea99 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); From 3e2de74e985f3fa688db98a6b4bb6873d2c740d8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 19:26:31 +0200 Subject: [PATCH 2/2] feat(workspace): record broad approvals with allow-root --approve-broad Replace the PATH_ALLOW_BROAD environment variable with a recorded grant, the shape agreed in review: `codebase-memory-mcp allow-root --approve-broad ` records an explicit human approval for one root refused as too broad (CBM_WS_DENY_TOO_SHALLOW), mirroring --approve-sensitive end to end. - cbm_workspace_grant_add() takes approve_broad next to approve_sensitive. Each flag lifts only its own verdict, absolute denials stay unliftable, and a refusal names the flag that would lift it. - The approval is stored with a '^' marker, listed by allow-root --list as "(approved broad)", and honoured by cbm_workspace_root_allowed() only for CBM_WS_DENY_TOO_SHALLOW and only on an exact path match, keeping the exact-match rule of the original change. - cbm_workspace_root_allowed() returns to its previous signature. Every caller (MCP index_repository and index-job start, auto-index, daemon sessions, the HTTP UI) reads the same grant store, so stdio and daemon sessions decide identically and nothing depends on the environment a daemon was started with. - The too-broad refusal names `allow-root --approve-broad `, a command a person runs. - cbm_workspace_classify_root() and cbm_workspace_verdict_is_overridable() are unchanged. The regression test keeps its name and its exact-match, sibling, absolute and sensitive checks, now against the grant store, and adds that a broad marker never lifts a sensitive verdict. Co-authored-by: Mark Murawski Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 9 ++ src/daemon/application.c | 3 +- src/foundation/workspace.c | 169 ++++++++++++++++++++------------ src/foundation/workspace.h | 31 +++--- src/main.c | 15 ++- src/mcp/mcp.c | 9 +- src/ui/http_server.c | 3 +- tests/test_daemon_application.c | 10 +- tests/test_mcp.c | 2 +- tests/test_workspace.c | 111 +++++++++++++-------- 10 files changed, 228 insertions(+), 134 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3e9956d601..04afa5b3ef 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -237,6 +237,15 @@ root because they are too broad or too sensitive to index as a unit: - a credential directory at any depth — `.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.netrc`, `.git-credentials`, `.password-store`, macOS `Keychains`. +A single top-level directory can still be indexed as one root on purpose, for +example a container or build machine that keeps its project in `/work`, `/app` or +`/src`, by approving that exact path in a terminal: +`codebase-memory-mcp allow-root --approve-broad /work`. The approval is recorded +like any other allowed root, listed as `(approved broad)` by `allow-root --list`, +and covers no other top-level directory; as with any recorded root, indexing is +then confined to the recorded roots. Filesystem, drive and share roots cannot be +approved. + Two limits are worth stating plainly. This constrains *scope*, not *sensitivity*: inside a root that is allowed, every file the process can read may be indexed and later returned. And the credential list is a denylist, so it diff --git a/src/daemon/application.c b/src/daemon/application.c index 0fd66e674e..22b6f0d1a7 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -521,8 +521,7 @@ 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), - getenv("PATH_ALLOW_BROAD"), boundary_error, + cbm_mcp_server_allowed_root(session->mcp), 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 ab10d4f1f8..3f77c48793 100644 --- a/src/foundation/workspace.c +++ b/src/foundation/workspace.c @@ -383,8 +383,18 @@ enum { WS_LINE_MAX = 4096 }; /* A line may carry a leading '!' meaning "a person explicitly approved this * sensitive root". The marker is stored rather than inferred so re-classifying * later (a new credential name is added to the list, say) cannot silently - * upgrade an ordinary grant into a sensitive one. */ + * upgrade an ordinary grant into a sensitive one. A leading '^' is the same + * kind of record for a root refused as too broad (allow-root --approve-broad). + * Neither character can begin a canonical absolute path, so a marked line can + * never be misread as an ordinary grant. */ static const char WS_SENSITIVE_MARK = '!'; +static const char WS_BROAD_MARK = '^'; + +typedef enum { + WS_GRANT_ORDINARY = 0, + WS_GRANT_SENSITIVE, /* explicit approval of a CBM_WS_DENY_SENSITIVE root */ + WS_GRANT_BROAD, /* explicit approval of a CBM_WS_DENY_TOO_SHALLOW root */ +} ws_grant_kind_t; bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { if (!cache_dir || !cache_dir[0] || !out || out_sz == 0) { @@ -397,7 +407,8 @@ bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { /* Walk the grant file, invoking visit for each entry. visit returns true to stop. * Returns the number of entries seen. */ static int ws_grant_walk(const char *cache_dir, - bool (*visit)(const char *root, bool sensitive, void *ctx), void *ctx) { + bool (*visit)(const char *root, ws_grant_kind_t kind, void *ctx), + void *ctx) { char store[WS_LINE_MAX]; if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) { return 0; @@ -416,13 +427,18 @@ static int ws_grant_walk(const char *cache_dir, if (len == 0 || line[0] == '#') { continue; } - bool sensitive = line[0] == WS_SENSITIVE_MARK; - const char *root = sensitive ? line + 1 : line; + ws_grant_kind_t kind = WS_GRANT_ORDINARY; + if (line[0] == WS_SENSITIVE_MARK) { + kind = WS_GRANT_SENSITIVE; + } else if (line[0] == WS_BROAD_MARK) { + kind = WS_GRANT_BROAD; + } + const char *root = kind == WS_GRANT_ORDINARY ? line : line + 1; if (!root[0]) { continue; } seen++; - if (visit && visit(root, sensitive, ctx)) { + if (visit && visit(root, kind, ctx)) { break; } } @@ -434,14 +450,21 @@ typedef struct { const char *candidate; bool contained; /* candidate is at or below some granted root */ bool exact_sensitive; /* candidate is itself a grant marked sensitive */ + bool exact_broad; /* candidate is itself a grant marked broad */ } ws_match_t; -static bool ws_match_visit(const char *root, bool sensitive, void *ctx) { +static bool ws_match_visit(const char *root, ws_grant_kind_t kind, void *ctx) { ws_match_t *m = ctx; if (cbm_path_within_root(root, m->candidate)) { m->contained = true; - if (sensitive && ws_paths_equal(root, m->candidate)) { - m->exact_sensitive = true; + /* Exact equality, never a prefix: an approval names one root, and must + * not quietly cover every root below it. */ + if (kind != WS_GRANT_ORDINARY && ws_paths_equal(root, m->candidate)) { + if (kind == WS_GRANT_SENSITIVE) { + m->exact_sensitive = true; + } else { + m->exact_broad = true; + } } } return false; /* visit every entry: a later one may be the exact match */ @@ -453,10 +476,15 @@ typedef struct { size_t used; } ws_list_t; -static bool ws_list_visit(const char *root, bool sensitive, void *ctx) { +static bool ws_list_visit(const char *root, ws_grant_kind_t kind, void *ctx) { ws_list_t *l = ctx; - int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", - sensitive ? "(approved) " : "", root); + const char *label = ""; + if (kind == WS_GRANT_SENSITIVE) { + label = "(approved) "; + } else if (kind == WS_GRANT_BROAD) { + label = "(approved broad) "; + } + int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", label, root); if (n > 0 && (size_t)n < l->out_sz - l->used) { l->used += (size_t)n; } @@ -472,9 +500,49 @@ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz) { return ws_grant_walk(cache_dir, ws_list_visit, &l) > 0; } +/* The allow-root flag that records an explicit approval for a refused verdict, + * or NULL when nothing can lift it (absolute denials). */ +static const char *ws_verdict_approval_flag(cbm_ws_verdict_t verdict) { + if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + return "--approve-broad"; + } + if (cbm_workspace_verdict_is_overridable(verdict)) { + return "--approve-sensitive"; + } + return NULL; +} + +/* True when a refused verdict carries the explicit approval that lifts it. Each + * flag lifts only its own verdict: --approve-sensitive never widens breadth and + * --approve-broad never widens sensitivity; absolute denials lift for neither. + * Refuses by default and names the flag, rather than asking a question whose + * answer we cannot authenticate. */ +static bool ws_grant_verdict_approved(cbm_ws_verdict_t verdict, bool approve_sensitive, + bool approve_broad, char *err, size_t err_sz) { + bool approved = false; + if (verdict == CBM_WS_DENY_TOO_SHALLOW) { + approved = approve_broad; + } else if (cbm_workspace_verdict_is_overridable(verdict)) { + approved = approve_sensitive; + } + if (approved) { + return true; + } + if (err) { + const char *flag = ws_verdict_approval_flag(verdict); + if (flag) { + snprintf(err, err_sz, "%s; re-run with %s if that is intended", + cbm_workspace_verdict_reason(verdict), flag); + } else { + snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); + } + } + return false; +} + bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, - const char *canonical_path, bool approve_sensitive, char *err, - size_t err_sz) { + const char *canonical_path, bool approve_sensitive, bool approve_broad, + char *err, size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -486,33 +554,22 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir); - if (verdict != CBM_WS_ALLOW) { - if (!cbm_workspace_verdict_is_overridable(verdict)) { - if (err) { - snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); - } - return false; - } - if (!approve_sensitive) { - /* Refuse by default and name the flag, rather than asking a question - * whose answer we cannot authenticate. */ - if (err) { - snprintf(err, err_sz, "%s; re-run with --approve-sensitive if that is intended", - cbm_workspace_verdict_reason(verdict)); - } - return false; - } + if (verdict != CBM_WS_ALLOW && + !ws_grant_verdict_approved(verdict, approve_sensitive, approve_broad, err, err_sz)) { + return false; } bool sensitive = verdict == CBM_WS_DENY_SENSITIVE; + bool broad = verdict == CBM_WS_DENY_TOO_SHALLOW; - /* Already present is success, not a duplicate error. A sensitive approval + /* Already present is success, not a duplicate error. An explicit approval * is narrower: an old ordinary grant (or an ordinary ancestor) establishes * containment, but does not record the exact human-approved exception that * cbm_workspace_root_allowed requires. Append that exact marked entry once. */ - ws_match_t existing = {canonical_path, false, false}; + ws_match_t existing = {canonical_path, false, false, false}; (void)ws_grant_walk(cache_dir, ws_match_visit, &existing); - if (existing.contained && (!sensitive || existing.exact_sensitive)) { + if (existing.contained && (!sensitive || existing.exact_sensitive) && + (!broad || existing.exact_broad)) { return true; } @@ -530,7 +587,13 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, } return false; } - (void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path); + char mark[2] = {'\0', '\0'}; + if (sensitive) { + mark[0] = WS_SENSITIVE_MARK; + } else if (broad) { + mark[0] = WS_BROAD_MARK; + } + (void)fprintf(f, "%s%s\n", mark, canonical_path); bool ok = fclose(f) == 0; if (!ok && err) { snprintf(err, err_sz, "cannot write %s", store); @@ -539,8 +602,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, - const char *allow_broad_root, char *err, size_t err_sz) { + const char *cache_dir, const char *configured_root, char *err, + size_t err_sz) { if (err && err_sz) { err[0] = '\0'; } @@ -551,7 +614,7 @@ bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir return false; } - ws_match_t match = {canonical_path, false, false}; + ws_match_t match = {canonical_path, false, false, false}; int grants = ws_grant_walk(cache_dir, ws_match_visit, &match); /* A configured root behaves as an additional grant so existing @@ -596,37 +659,23 @@ 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 refusals cannot be lifted at all. */ + * that lifts a sensitive or a too-broad refusal, each by its own marker: a + * broad approval says "index this whole tree on purpose", which is not a + * statement about credentials, and the reverse. Exact match, never a prefix, + * so one broad approval cannot cover every root below it — the "/Users"-style + * breadth the depth rule exists to catch. Absolute refusals cannot be lifted. */ 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)) { + if (verdict == CBM_WS_DENY_TOO_SHALLOW && match.exact_broad) { return true; } if (err) { - if (cbm_workspace_verdict_is_overridable(verdict)) { + const char *flag = ws_verdict_approval_flag(verdict); + if (flag) { 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); + "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root %s %s", + canonical_path, cbm_workspace_verdict_reason(verdict), flag, 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 99dcb8fea4..a956529abf 100644 --- a/src/foundation/workspace.h +++ b/src/foundation/workspace.h @@ -25,7 +25,9 @@ typedef enum { CBM_WS_ALLOW = 0, - /* Fewer path components than the platform minimum — e.g. "/etc", "/home". */ + /* Fewer path components than the platform minimum — e.g. "/etc", "/home". + * Lifted only for one exact path by a recorded broad approval + * (allow-root --approve-broad), never by a manifest or a tool call. */ CBM_WS_DENY_TOO_SHALLOW, /* A filesystem root, a drive root, a UNC share root, or a path that is or * contains the cbm cache directory. Never overridable. */ @@ -42,8 +44,10 @@ cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const c /* Stable, human-facing reason for a verdict. Never NULL. */ const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict); -/* True when an explicit human action may override this verdict. Absolute denials - * and shallow paths are never overridable; sensitive ones are. */ +/* True when a sensitive approval (allow-root --approve-sensitive) may override + * this verdict. Absolute denials never are. A shallow path is not overridable + * in this sense either: only an exact broad approval lifts it, see + * cbm_workspace_grant_add. */ bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict); /* Number of path components below the volume root, ignoring repeated and @@ -81,11 +85,13 @@ bool cbm_path_within_root(const char *root_path, const char *abs_path); bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz); /* Record canonical_path as an allowed root. approve_sensitive records an explicit - * human override for a CBM_WS_DENY_SENSITIVE path; absolute denials and shallow - * paths are never grantable. Returns false and fills err on refusal. */ + * human override for a CBM_WS_DENY_SENSITIVE path; approve_broad records one for a + * CBM_WS_DENY_TOO_SHALLOW path. Each flag lifts only its own verdict, and only + * for exactly this path. Absolute denials are never grantable. Returns false and + * fills err on refusal. */ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, - const char *canonical_path, bool approve_sensitive, char *err, - size_t err_sz); + const char *canonical_path, bool approve_sensitive, bool approve_broad, + char *err, size_t err_sz); /* Newline-separated granted roots into out. True when at least one exists. */ bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz); @@ -93,15 +99,10 @@ 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. - * 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. */ + * 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, - const char *allow_broad_root, char *err, size_t err_sz); + const char *cache_dir, const char *configured_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/main.c b/src/main.c index 54bad875eb..8f31d3d799 100644 --- a/src/main.c +++ b/src/main.c @@ -1175,21 +1175,27 @@ static void print_help(void) { /* Try to handle a subcommand (cli/install/uninstall/update/config/--version/--help). * Returns -1 if no subcommand matched, otherwise the exit code. */ -/* `allow-root [--approve-sensitive] ` — record an indexing root. +/* `allow-root [--approve-sensitive | --approve-broad] ` — record an + * indexing root. * * Enrollment lives here, in a command a person types, and deliberately nowhere * else: the whole point of the grant store is that neither an indexed repository * nor a tool caller can widen its own boundary. A confirmation delivered through * the MCP surface would be answered by the same agent that may have been - * influenced, so it would not be a human decision at all. */ + * influenced, so it would not be a human decision at all. --approve-broad is the + * same kind of record for one exact root refused as too broad ("/work", "/app"), + * so it is listed, revocable and seen identically by stdio and daemon sessions. */ static int main_run_allow_root(int argc, char **argv) { const char *path = NULL; bool approve_sensitive = false; + bool approve_broad = false; bool list_only = false; bool approve_manifest = false; for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--approve-sensitive") == 0) { approve_sensitive = true; + } else if (strcmp(argv[i], "--approve-broad") == 0) { + approve_broad = true; } else if (strcmp(argv[i], "--list") == 0) { list_only = true; } else if (strcmp(argv[i], "--approve-manifest") == 0) { @@ -1221,7 +1227,8 @@ static int main_run_allow_root(int argc, char **argv) { } if (!path && !list_only) { (void)fprintf(stderr, - "usage: codebase-memory-mcp allow-root [--approve-sensitive] \n" + "usage: codebase-memory-mcp allow-root [--approve-sensitive | " + "--approve-broad] \n" " codebase-memory-mcp allow-root --approve-manifest \n" " codebase-memory-mcp allow-root --list\n"); return EXIT_FAILURE; @@ -1264,7 +1271,7 @@ static int main_run_allow_root(int argc, char **argv) { char err[CBM_SZ_1K]; if (!cbm_workspace_grant_add(cache_dir, cbm_workspace_home_dir(), canonical, approve_sensitive, - err, sizeof(err))) { + approve_broad, err, sizeof(err))) { (void)fprintf(stderr, "refused: %s\n", err[0] ? err : "not an allowable root"); return EXIT_FAILURE; } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cf8ade005f..f6e43b317a 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -12214,8 +12214,7 @@ static char *index_status_project_key(cbm_mcp_server_t *srv, const char *args, c srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT"); if (repo_path && repo_path[0] && !cbm_workspace_root_allowed(repo_path, cbm_workspace_home_dir(), cbm_workspace_cache_dir(), - allowed_root, getenv("PATH_ALLOW_BROAD"), error_out, - error_size)) { + allowed_root, error_out, error_size)) { safe_free(repo_path); return NULL; } @@ -12318,8 +12317,7 @@ 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, getenv("PATH_ALLOW_BROAD"), boundary_err, - sizeof(boundary_err))) { + allowed_root, boundary_err, sizeof(boundary_err))) { index_args_free(repo_path, mode_str, name_override); return cbm_mcp_text_result(boundary_err, true); } @@ -20828,8 +20826,7 @@ 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, - getenv("PATH_ALLOW_BROAD"), boundary_err, + cbm_workspace_cache_dir(), allowed_root, 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 e2b87a4f3a..ffc834f6d4 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -1713,8 +1713,7 @@ 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"), - getenv("PATH_ALLOW_BROAD"), boundary_err, - sizeof(boundary_err))) { + 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_daemon_application.c b/tests/test_daemon_application.c index a923043ca4..4faea87a99 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -2594,8 +2594,9 @@ TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls) app_wait_for_active_jobs(application, 0); char grant_error[APP_TEST_PATH_CAP]; - bool approved = ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true, - grant_error, sizeof(grant_error)); + bool approved = + ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); bool approved_initialized = approved && app_test_initialize_profile(&callbacks, approved_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); @@ -2720,8 +2721,9 @@ TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) { bool ordinary_released = ordinary_watched && cbm_watcher_watch_count(watcher) == 0; char grant_error[APP_TEST_PATH_CAP]; - bool approved = ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true, - grant_error, sizeof(grant_error)); + bool approved = + ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, + grant_error, sizeof(grant_error)); bool approved_initialized = approved && app_test_initialize_profile(&callbacks, approved_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 1ced07c957..536dbbc533 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2045,7 +2045,7 @@ static int issue403_initialize_count_calls(const char *session_root, bool approv char err[1024]; bool approved = !approve_sensitive || cbm_workspace_grant_add(cache, cbm_workspace_home_dir(), session_root, - true, err, sizeof(err)); + true, false, err, sizeof(err)); cbm_config_t *cfg = approved ? cbm_config_open(cache) : NULL; cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL; int calls = -2; diff --git a/tests/test_workspace.c b/tests/test_workspace.c index 70298e7b8d..4e1ca77c24 100644 --- a/tests/test_workspace.c +++ b/tests/test_workspace.c @@ -75,11 +75,11 @@ 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. */ +/* A recorded broad approval (allow-root --approve-broad) is the only thing that + * lifts CBM_WS_DENY_TOO_SHALLOW, and only for that exact path. Classification + * and cbm_workspace_verdict_is_overridable stay exactly as strict as the test + * above checks; the approval is a grant, so it is listed like any other human + * decision, and it lifts no other verdict. */ TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { char cache[256]; char *created = th_mktempdir("cbm_ws_allow_broad"); @@ -87,29 +87,58 @@ TEST(ws_allow_broad_root_lifts_too_shallow_for_an_exact_match_only) { 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]; + char listed[4096]; + /* Refused out of the box, and the refusal names the command a person runs. */ + ASSERT_FALSE(cbm_workspace_root_allowed("/usr", HOME, cache, NULL, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "allow-root --approve-broad /usr")); + + /* Neither an ordinary grant nor a sensitive approval lifts breadth; a broad + * approval lifts neither an absolute nor a sensitive verdict. A refused grant + * records nothing. */ + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/usr", false, false, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-broad")); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/usr", true, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, "/", false, true, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_grant_add(cache, HOME, HOME, false, true, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); ASSERT_FALSE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + /* The explicit approval is recorded and marked. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, "/usr", false, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + ASSERT_STR_EQ(listed, "(approved broad) /usr\n"); + +#ifndef _WIN32 + /* Containment resolves real paths, so the rest needs a top-level directory + * that exists on every POSIX host: "/usr". A repeated approval is recognized + * rather than appended again. */ + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, "/usr", false, true, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed))); + ASSERT_STR_EQ(listed, "(approved broad) /usr\n"); + + /* It lifts exactly that path. A configured root of "/" puts "/dev" inside the + * declared boundary too, so the only thing still refusing it is that the + * approval names another path. */ + ASSERT_TRUE(cbm_workspace_root_allowed("/usr", HOME, cache, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed("/usr", HOME, cache, "/", err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed("/dev", HOME, cache, "/", err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "allow-root --approve-broad /dev")); +#endif + + /* Each marker lifts only its own verdict: a broad mark on a credential + * directory, which grant_add would never write, still does not lift it. */ + char secret[1024]; + snprintf(secret, sizeof(secret), "%s/.ssh", cache); + ASSERT_EQ(cbm_mkdir(secret), 0); + char store[1024]; + ASSERT_TRUE(cbm_workspace_grant_path(cache, store, sizeof(store))); + FILE *f = cbm_fopen(store, "a"); + ASSERT_NOT_NULL(f); + (void)fprintf(f, "^%s\n", secret); + ASSERT_EQ(fclose(f), 0); + ASSERT_FALSE(cbm_workspace_root_allowed(secret, HOME, cache, NULL, err, sizeof(err))); + ASSERT_NOT_NULL(strstr(err, "--approve-sensitive")); + th_cleanup(cache); PASS(); } @@ -214,10 +243,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, NULL, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, 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, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err))); th_cleanup(root); th_cleanup(cache); @@ -236,14 +265,14 @@ TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant) { snprintf(cache, sizeof(cache), "%s", created); 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, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, 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, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, 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))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, false, err, sizeof(err))); char listed[4096]; char expected[4096]; @@ -271,13 +300,15 @@ TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor) { snprintf(sensitive, sizeof(sensitive), "%s/private-project", 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, NULL, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, false, err, sizeof(err))); + ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, 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, NULL, err, sizeof(err))); - ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err))); + cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, err, sizeof(err))); + ASSERT_TRUE( + cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err))); + ASSERT_TRUE( + cbm_workspace_grant_add(cache, sensitive, sensitive, true, false, err, sizeof(err))); char listed[4096]; char expected[4096]; @@ -719,7 +750,7 @@ TEST(ws_manifest_ordinary_entries_unchanged) { char err[1024]; /* The grant store names a root the sdk is not under. */ - ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, err, sizeof(err))); + ASSERT_TRUE(cbm_workspace_grant_add(cache, HOME, granted, false, false, err, sizeof(err))); ws_manifest_write_line(base, sdk); ASSERT_TRUE(cbm_workspace_manifest_approve(cache, HOME, base, err, sizeof(err)));