feat: enforce one workspace boundary on every indexing entry point

Routes the MCP index_repository handler and the graph UI's POST /api/index
through a single decision function. The UI route previously checked only that
root_path was a directory, so an operator's configured boundary held on one
entry point and not the other; it now canonicalizes first and applies the same
policy, answering 403 with the reason.

The decision is two-tier, because a bare default-deny would refuse every
first run and a bare opt-in leaves the default open:

- Breadth is always enforced, with nothing configured. Filesystem, drive and
  share roots, top-level system trees, the home directory itself and
  credential directories are refused as indexing roots out of the box.
- Containment in a declared root applies once CBM_ALLOWED_ROOT is set or a
  grant exists, and is evaluated first so a path outside a configured root is
  reported as exactly that.

Three things the tests caught, each a real defect rather than a test fix:

- On macOS /etc, /tmp and /var are firmlinked under /private, so
  canonicalizing "/etc" yields "/private/etc" and counted two deep — sailing
  past a minimum of two, missing the very path being guarded. Depth now
  discounts a leading "private" component.
- An earlier draft refused any root containing the cache directory. That was
  over-claimed: the indexer only parses recognised source files and a graph
  database is binary SQLite it would never extract. Refusing a whole root is
  also the wrong remedy where the concern does hold — not walking the cache
  is. Removed, with the reasoning recorded at the site.
- Rewording the refusal to "outside every allowed root" broke an assertion
  matching "outside the allowed root", and that test's early return skipped
  its CBM_ALLOWED_ROOT cleanup, leaking the variable into every later test in
  the suite. The original wording is kept and guidance appended instead.
  Worth remembering: these contracts match strings, not properties.

Docs updated in the same change, since both env-var tables said "unset
imposes no restriction" and that is no longer true: CONFIGURATION.md and
README.md describe the two tiers, and CONFIGURATION.md lists the always-
refused roots along with the two limits that matter — this constrains scope
rather than sensitivity, and the credential list is a denylist that raises the
cost of a mistake rather than closing the class. SECURITY.md's supported-
versions table was still on 0.8.x and now reads 0.9.x.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-06 01:02:02 +02:00
parent 03e935576b
commit 7fa5b07718
9 changed files with 435 additions and 42 deletions
+1 -1
View File
@@ -670,7 +670,7 @@ codebase-memory-mcp config reset auto_index # reset to default
| Variable | Default | Description |
|----------|---------|-------------|
| `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction — but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. One account can use only one canonical cache root at a time; close active CBM sessions/commands before switching it. |
| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable the shared daemon's periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. Exact paths are logged by `diagnostics.start`. |
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
+2 -2
View File
@@ -172,8 +172,8 @@ sha256sum -c checksums.txt
| Version | Supported |
|---------|-----------|
| Latest `0.8.x` | Yes — security fixes land in the newest release |
| < 0.8 | No — please upgrade to the latest release |
| Latest `0.9.x` | Yes — security fixes land in the newest release |
| < 0.9 | No — please upgrade to the latest release |
Only the latest release is supported. Security fixes are shipped in a new
patched release rather than backported to older versions; upgrading to the
+20 -1
View File
@@ -116,7 +116,7 @@ These environment variables affect runtime behavior:
| Variable | Default | Description |
|---|---|---|
| `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller (agentic or multi-tenant deployments). |
| `CBM_ALLOWED_ROOT` | *(unset)* | Confine `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused, and the same check now applies to the graph UI's `POST /api/index` route rather than only to the MCP tool. Unset imposes no *containment* restriction — but see the always-on limits below, which apply whether or not this is set. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. |
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. |
| `CBM_DIAGNOSTICS` | `false` | Enable periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. The daemon records the randomized paths in the `diagnostics.start` discovery record (a single JSON line) in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`; that one record is emitted even when `CBM_LOG_LEVEL` suppresses ordinary logging, so the paths always remain discoverable. |
| `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. |
@@ -125,6 +125,25 @@ These environment variables affect runtime behavior:
Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join the existing process and cannot replace those values. To change them, close every daemon-backed session, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands use their own current environment without starting the daemon.
### Roots that are always refused
Independently of `CBM_ALLOWED_ROOT`, some directories are refused as an indexing
root because they are too broad or too sensitive to index as a unit:
- a filesystem root, a Windows drive root, or a UNC share root;
- a top-level system tree — `/etc`, `/var`, `/usr`, `/home`, `/Users`, and on
Windows `C:\Windows`, `C:\Users`, `C:\ProgramData`, `C:\Program Files`;
- your home directory itself (directories *below* it are fine);
- a credential directory at any depth — `.ssh`, `.aws`, `.gnupg`, `.kube`,
`.docker`, `.netrc`, `.git-credentials`, `.password-store`, macOS `Keychains`.
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
raises the cost of a mistake rather than closing the class — a directory it does
not name is permitted.
## 5. Agent and Editor Integration Files
The `install` command can also write MCP entries and instruction blocks into agent/editor config files such as Claude Code, Codex, Gemini, VS Code, Cursor, Zed, and others.
+267 -5
View File
@@ -4,6 +4,13 @@
*/
#include "foundation/workspace.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/platform.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
enum {
@@ -72,6 +79,14 @@ int cbm_workspace_path_depth(const char *canonical_path) {
}
int depth = 0;
const char *p = canonical_path + prefix;
/* macOS firmlinks /etc, /tmp and /var under /private, so canonicalizing any
* of them adds a component: "/etc" resolves to "/private/etc" and would count
* as two deep, sailing past a minimum of two — exactly the path the reports
* demonstrate. Treat a leading "private" as transparent so depth reflects the
* tree the user named. "/private/tmp/proj" still counts two and is allowed. */
if (strncmp(p, "private", 7) == 0 && (ws_is_sep(p[7]) || p[7] == '\0')) {
p += 7;
}
while (*p) {
while (ws_is_sep(*p)) {
p++;
@@ -222,11 +237,19 @@ cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const c
return CBM_WS_DENY_TOO_SHALLOW;
}
/* Indexing a tree that holds the cache would pull every other project's
* graph database into this project's index. Never overridable. */
if (cache_dir && cache_dir[0] && ws_is_ancestor_or_equal(canonical_path, cache_dir)) {
return CBM_WS_DENY_ABSOLUTE;
}
/* No rule here for "this root contains the cache directory".
*
* An earlier draft refused such roots outright, on the theory that indexing
* them would absorb every other project's graph database. Two things make
* that wrong. The indexer only parses recognised source files, and a graph
* database is binary SQLite it would never extract; and refusing an entire
* root is the wrong remedy even where the concern holds — the right one is to
* not walk the cache. Excluding the cache from discovery is tracked
* separately; classifying the root is not the place for it.
*
* cache_dir stays in the signature because the exclusion work will need it
* and because callers already have it to hand. */
(void)cache_dir;
if (ws_any_component_matches(canonical_path, WS_CREDENTIAL_NAMES,
sizeof(WS_CREDENTIAL_NAMES) / sizeof(WS_CREDENTIAL_NAMES[0]),
@@ -263,3 +286,242 @@ const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict) {
bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict) {
return verdict == CBM_WS_DENY_SENSITIVE;
}
/* ── Grant store ──────────────────────────────────────────────────────────── */
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. */
static const char WS_SENSITIVE_MARK = '!';
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) {
return false;
}
int n = snprintf(out, out_sz, "%s/allowed_roots", cache_dir);
return n > 0 && (size_t)n < 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) {
char store[WS_LINE_MAX];
if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) {
return 0;
}
FILE *f = cbm_fopen(store, "r");
if (!f) {
return 0;
}
char line[WS_LINE_MAX];
int seen = 0;
while (fgets(line, (int)sizeof(line), f)) {
size_t len = strlen(line);
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
line[--len] = '\0';
}
if (len == 0 || line[0] == '#') {
continue;
}
bool sensitive = line[0] == WS_SENSITIVE_MARK;
const char *root = sensitive ? line + 1 : line;
if (!root[0]) {
continue;
}
seen++;
if (visit && visit(root, sensitive, ctx)) {
break;
}
}
(void)fclose(f);
return seen;
}
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 */
} ws_match_t;
static bool ws_match_visit(const char *root, bool sensitive, 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;
}
}
return false; /* visit every entry: a later one may be the exact match */
}
typedef struct {
char *out;
size_t out_sz;
size_t used;
} ws_list_t;
static bool ws_list_visit(const char *root, bool sensitive, 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);
if (n > 0 && (size_t)n < l->out_sz - l->used) {
l->used += (size_t)n;
}
return false;
}
bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz) {
if (!out || out_sz == 0) {
return false;
}
out[0] = '\0';
ws_list_t l = {out, out_sz, 0};
return ws_grant_walk(cache_dir, ws_list_visit, &l) > 0;
}
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) {
if (err && err_sz) {
err[0] = '\0';
}
if (!cache_dir || !cache_dir[0] || !canonical_path || !canonical_path[0]) {
if (err) {
snprintf(err, err_sz, "no path given");
}
return false;
}
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;
}
}
/* Already present is success, not a duplicate error. */
ws_match_t existing = {canonical_path, false, false};
(void)ws_grant_walk(cache_dir, ws_match_visit, &existing);
if (existing.contained) {
return true;
}
char store[WS_LINE_MAX];
if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) {
if (err) {
snprintf(err, err_sz, "cache path too long");
}
return false;
}
FILE *f = cbm_fopen(store, "a");
if (!f) {
if (err) {
snprintf(err, err_sz, "cannot write %s", store);
}
return false;
}
bool sensitive = verdict == CBM_WS_DENY_SENSITIVE;
(void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path);
bool ok = fclose(f) == 0;
if (!ok && err) {
snprintf(err, err_sz, "cannot write %s", store);
}
return ok;
}
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) {
if (err && err_sz) {
err[0] = '\0';
}
if (!canonical_path || !canonical_path[0]) {
if (err) {
snprintf(err, err_sz, "no repository path given");
}
return false;
}
ws_match_t match = {canonical_path, false, false};
int grants = ws_grant_walk(cache_dir, ws_match_visit, &match);
/* A configured root behaves as an additional grant so existing
* CBM_ALLOWED_ROOT deployments keep working unchanged. */
bool configured = configured_root && configured_root[0];
bool configured_contains = configured && cbm_path_within_root(configured_root, canonical_path);
/* Containment first when a boundary has actually been declared. A path
* outside a configured root is best explained as exactly that, and the
* wording is what callers and the shell contracts already match on. Breadth
* then applies to paths that ARE inside the declared root but are still too
* broad to index as one unit. */
bool boundary_declared = grants > 0 || configured;
if (boundary_declared && !match.contained && !configured_contains) {
if (err) {
/* Keep the "outside the allowed root" wording: changing it broke an
* assertion whose early return then leaked CBM_ALLOWED_ROOT into
* every later test in that suite. Guidance is appended, not
* substituted. */
snprintf(err, err_sz,
"%s is outside the allowed root. To allow it, run: "
"codebase-memory-mcp allow-root %s",
canonical_path, canonical_path);
}
return false;
}
cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir);
if (verdict == CBM_WS_ALLOW) {
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. */
if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) {
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 {
snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict));
}
}
return false;
}
/* ── Environment helpers ──────────────────────────────────────────────────── */
/* Callers should not each re-derive these; a caller that resolved the home
* directory differently would classify the same path differently. */
const char *cbm_workspace_home_dir(void) {
const char *home = getenv("HOME");
if (home && home[0]) {
return home;
}
home = getenv("USERPROFILE");
return (home && home[0]) ? home : NULL;
}
const char *cbm_workspace_cache_dir(void) {
return cbm_resolve_cache_dir();
}
+53
View File
@@ -51,4 +51,57 @@ bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict);
* 0 (the share root itself). Exposed for tests. */
int cbm_workspace_path_depth(const char *canonical_path);
/* Canonical containment check: is abs_path at or below root_path, after symlink
* and junction resolution? The definition currently lives in src/mcp/mcp.c; it is
* declared here so the MCP handler, the HTTP UI route and the CLI all reach the
* same implementation. Two entry points evaluating the same policy separately is
* what produced the UI/MCP divergence in the first place. */
bool cbm_path_within_root(const char *root_path, const char *abs_path);
/*
* ── Grant store ──────────────────────────────────────────────────────────────
*
* The authorization half. Grants live in a user-level file under the cache
* directory and are only ever written by an explicit human action (the CLI), so
* neither an indexed repository nor a tool caller can widen its own boundary.
*
* Enforcement is deliberately two-tier, because default-deny with an empty store
* would refuse every first run:
*
* - The breadth policy above is ALWAYS enforced. Out of the box that already
* refuses "/", "/etc", "$HOME", "~/.ssh" and friends — the paths the reports
* actually demonstrate — with no configuration at all.
* - Containment in a granted root is enforced ONLY once the store is non-empty
* or a root is configured. Declaring your first root is therefore what turns
* on true confinement, and it never silently loosens: adding a root can only
* narrow what was previously unrestricted.
*/
/* Absolute path of the grant file for a cache directory. */
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. */
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);
/* 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);
/* 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. */
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);
/* The home and cache directories the policy should be evaluated against. Shared
* so two callers cannot derive them differently and reach different verdicts for
* the same path. Either may return NULL. */
const char *cbm_workspace_home_dir(void);
const char *cbm_workspace_cache_dir(void);
#endif /* CBM_FOUNDATION_WORKSPACE_H */
+14 -6
View File
@@ -64,6 +64,7 @@ enum {
#include "mcp/index_supervisor.h"
#include "mcp/compact_out.h"
#include "foundation/str_util.h"
#include "foundation/workspace.h"
#include "foundation/dump_verify.h"
#include "foundation/compat_regex.h"
#include "pipeline/artifact.h"
@@ -7895,17 +7896,24 @@ 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. Embedded/daemon sessions always use their
* explicit policy, including an explicit NULL meaning unrestricted. A
* standalone server retains the process-wide CBM_ALLOWED_ROOT fallback. */
/* Workspace boundary. Embedded/daemon sessions supply their explicit policy,
* including an explicit NULL meaning unrestricted; a standalone server falls
* back to the process-wide CBM_ALLOWED_ROOT. The decision itself lives in one
* shared function so this handler and the HTTP UI indexing route cannot drift
* apart they had, and the divergence was the defect. */
const char *allowed_root =
srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT");
if (allowed_root && allowed_root[0] && repo_path &&
!cbm_path_within_root(allowed_root, repo_path)) {
/* repo_path is legitimately absent when the caller names an already-known
* project instead; the root is resolved downstream. Only a path supplied here
* is classified here the previous check had the same tolerance. */
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))) {
free(mode_str);
free(name_override);
free(repo_path);
return cbm_mcp_text_result("repo_path is outside the allowed root", true);
return cbm_mcp_text_result(boundary_err, true);
}
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
+23
View File
@@ -35,6 +35,7 @@
#include "foundation/compat_thread.h"
#include "foundation/subprocess.h" /* cbm_build_win_cmdline — shared MS-CRT arg quoting */
#include "foundation/win_utf8.h" /* cbm_utf8_to_wide — CreateProcessW wide cmdline (#423/#20) */
#include "foundation/workspace.h"
#include <sqlite3/sqlite3.h>
#include <yyjson/yyjson.h>
@@ -1052,6 +1053,28 @@ static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c,
return;
}
/* Same workspace boundary the MCP indexing tool applies, through the same
* function. This route used to check only that the path was a directory, so
* it accepted roots the MCP path refused — an operator's boundary held on one
* entry point and not the other. Canonicalize first: the policy is defined
* over resolved paths, and a symlink would otherwise launder the verdict. */
char canonical_root[4096];
char boundary_err[1024];
if (!cbm_canonical_path(rpath, canonical_root, sizeof(canonical_root))) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"cannot resolve root_path\"}");
return;
}
if (!cbm_workspace_root_allowed(canonical_root, cbm_workspace_home_dir(),
cbm_workspace_cache_dir(), getenv("CBM_ALLOWED_ROOT"),
boundary_err, sizeof(boundary_err))) {
yyjson_doc_free(doc);
char escaped[1024];
cbm_json_escape(escaped, (int)sizeof(escaped), boundary_err);
cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"%s\"}", escaped);
return;
}
/* Find free job slot */
int slot = -1;
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
+44 -12
View File
@@ -2940,12 +2940,11 @@ TEST(tool_trace_call_path_prefers_definition) {
TEST(trace_evidence_strategy_class_vocabulary_is_closed) {
/* Every strategy string assigned anywhere in src/ + internal/ as of this
* commit, plus the two literals pass_calls.c writes directly. */
static const char *const lsp[] = {"lsp_direct", "lsp_base_dispatch",
"lsp_embed_dispatch", "lsp_implicit_this",
"lsp_inherited_dispatch", "lsp_method_dispatch",
"lsp_proc_macro", "lsp_smart_ptr_dispatch",
"lsp_strategy_cross_file", "lsp_trait_dispatch",
"lsp_type_dispatch", "lsp_virtual_dispatch"};
static const char *const lsp[] = {
"lsp_direct", "lsp_base_dispatch", "lsp_embed_dispatch",
"lsp_implicit_this", "lsp_inherited_dispatch", "lsp_method_dispatch",
"lsp_proc_macro", "lsp_smart_ptr_dispatch", "lsp_strategy_cross_file",
"lsp_trait_dispatch", "lsp_type_dispatch", "lsp_virtual_dispatch"};
for (size_t i = 0; i < sizeof(lsp) / sizeof(lsp[0]); i++) {
const char *cls = cbm_mcp_edge_strategy_class(lsp[i]);
ASSERT_NOT_NULL(cls);
@@ -6076,8 +6075,8 @@ TEST(tool_index_repository_unknown_project_name_still_requires_repo_path) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_handle_tool(srv, "index_repository",
"{\"project\":\"never-indexed-project\"}");
char *resp =
cbm_mcp_handle_tool(srv, "index_repository", "{\"project\":\"never-indexed-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "repo_path is required"));
free(resp);
@@ -6609,13 +6608,13 @@ TEST(detect_changes_seeds_only_touched_symbol_issue1363) {
"def bar():\n"
" y = 2\n"
" return y\n"),
0);
0);
/* `git -C` with double quotes, not `cd '<dir>' &&`: single quotes are not
* quoting characters for cmd.exe, and identity/branch/signing come from -c
* so the fixture does not depend on the machine's global git config. The
* assertions below read `base: main`, so pin init.defaultBranch. */
#define DC1363_GITCFG \
#define DC1363_GITCFG \
"-c user.name=t -c user.email=t@t.io -c init.defaultBranch=main -c commit.gpgsign=false"
char cmd[1200];
const char *steps[] = {"init -q", "add -A", "commit -q -m init"};
@@ -6644,7 +6643,7 @@ TEST(detect_changes_seeds_only_touched_symbol_issue1363) {
"def bar():\n"
" y = 2\n"
" return y\n"),
0);
0);
char *project = cbm_project_name_from_path(repo);
ASSERT_NOT_NULL(project);
@@ -6691,7 +6690,7 @@ TEST(detect_changes_zero_overlap_falls_back_issue1363) {
" return 2\n"),
0);
#define DC1363B_GITCFG \
#define DC1363B_GITCFG \
"-c user.name=t -c user.email=t@t.io -c init.defaultBranch=main -c commit.gpgsign=false"
char cmd[1200];
const char *steps[] = {"init -q", "add -A", "commit -q -m init"};
@@ -10003,6 +10002,38 @@ TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root) {
#endif
}
/* With no boundary configured at all, index_repository must still refuse roots
* that are too broad or too sensitive to index as a unit. This is the part that
* holds out of the box: the paths the advisories actually demonstrate are refused
* without anyone setting an environment variable first. */
TEST(index_repository_refuses_overbroad_roots_by_default) {
const char *saved = getenv("CBM_ALLOWED_ROOT");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_unsetenv("CBM_ALLOWED_ROOT");
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
/* A top-level system tree: refused on breadth, with no configuration. */
char *resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\"/etc\"}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "too broad") != NULL);
free(resp);
/* The filesystem root is refused outright and is never overridable. */
resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\"/\"}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "cannot be indexed") != NULL);
free(resp);
cbm_mcp_server_free(srv);
if (saved_copy) {
cbm_setenv("CBM_ALLOWED_ROOT", saved_copy, 1);
free(saved_copy);
}
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. */
@@ -10292,6 +10323,7 @@ SUITE(mcp) {
RUN_TEST(detect_changes_rejects_option_like_base_branch);
RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_base_branch);
RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root);
RUN_TEST(index_repository_refuses_overbroad_roots_by_default);
RUN_TEST(index_repository_honors_allowed_root);
/* JSON-RPC parsing */
RUN_TEST(jsonrpc_parse_request);
+11 -15
View File
@@ -17,6 +17,11 @@ TEST(ws_depth_counts_components_below_the_volume) {
ASSERT_EQ(cbm_workspace_path_depth("/etc/"), 1);
ASSERT_EQ(cbm_workspace_path_depth("/Users/dev"), 2);
ASSERT_EQ(cbm_workspace_path_depth("/Users//dev///x"), 3);
/* macOS firmlinks: the /private prefix must not inflate depth, or "/etc"
* resolves to "/private/etc" and passes a minimum of two. */
ASSERT_EQ(cbm_workspace_path_depth("/private/etc"), 1);
ASSERT_EQ(cbm_workspace_path_depth("/private/tmp/proj"), 2);
ASSERT_EQ(cbm_workspace_path_depth("/private"), 0);
/* Drive-relative, so an ordinary Windows workspace is one deep. */
ASSERT_EQ(cbm_workspace_path_depth("C:/"), 0);
ASSERT_EQ(cbm_workspace_path_depth("D:/repos"), 1);
@@ -32,6 +37,9 @@ TEST(ws_volume_roots_are_absolutely_denied) {
ASSERT_EQ(cbm_workspace_classify_root("C:/", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("C:\\", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("//srv/share", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
/* "/private" carries no components of its own once the macOS firmlink prefix
* is discounted, so it is a volume root rather than merely shallow. */
ASSERT_EQ(cbm_workspace_classify_root("/private", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_FALSE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_ABSOLUTE));
PASS();
}
@@ -48,8 +56,9 @@ TEST(ws_non_absolute_paths_are_denied) {
/* One depth rule refuses every POSIX top-level tree without a list to maintain.
* This is the whole reason depth carries its weight. */
TEST(ws_posix_top_level_trees_are_too_shallow) {
static const char *const shallow[] = {"/etc", "/home", "/Users", "/var",
"/opt", "/srv", "/usr", "/private"};
static const char *const shallow[] = {"/etc", "/home", "/Users", "/var",
"/opt", "/srv", "/usr", "/private/etc",
"/private/var", "/private/tmp"};
for (size_t i = 0; i < sizeof(shallow) / sizeof(shallow[0]); i++) {
ASSERT_EQ(cbm_workspace_classify_root(shallow[i], HOME, CACHE), CBM_WS_DENY_TOO_SHALLOW);
}
@@ -95,17 +104,6 @@ TEST(ws_credential_directories_are_sensitive_at_any_depth) {
PASS();
}
/* Indexing a tree holding the cache would absorb every other project's graph
* database, so this is absolute rather than overridable. */
TEST(ws_cache_holding_roots_are_absolutely_denied) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.cache", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.cache/codebase-memory-mcp", HOME, CACHE),
CBM_WS_DENY_ABSOLUTE);
/* A sibling of the cache is not an ancestor of it. */
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.cachex", HOME, CACHE), CBM_WS_ALLOW);
PASS();
}
TEST(ws_windows_system_trees_are_sensitive) {
ASSERT_EQ(cbm_workspace_classify_root("C:/Windows", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/Users", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
@@ -129,7 +127,6 @@ TEST(ws_posix_matching_is_case_sensitive) {
* they must not crash or deny everything. */
TEST(ws_null_context_disables_dependent_checks) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev", NULL, NULL), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.cache", NULL, NULL), CBM_WS_ALLOW);
/* Depth and volume rules are context-free and still apply. */
ASSERT_EQ(cbm_workspace_classify_root("/etc", NULL, NULL), CBM_WS_DENY_TOO_SHALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/", NULL, NULL), CBM_WS_DENY_ABSOLUTE);
@@ -154,7 +151,6 @@ SUITE(workspace) {
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);
RUN_TEST(ws_cache_holding_roots_are_absolutely_denied);
RUN_TEST(ws_windows_system_trees_are_sensitive);
RUN_TEST(ws_posix_matching_is_case_sensitive);
RUN_TEST(ws_null_context_disables_dependent_checks);