Merge pull request #1464 from DeusData/fix/bounds-and-workspace-roots

fix: tighten buffer, recursion and indexing-root bounds
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-08-06 04:44:25 +02:00
commit 0d6f26a170
24 files changed
+1428 -91

No files matched your search

+2
View File
@@ -188,6 +188,7 @@ FOUNDATION_SRCS = \
src/foundation/str_intern.c \
src/foundation/log.c \
src/foundation/str_util.c \
src/foundation/workspace.c \
src/foundation/platform.c \
src/foundation/system_info.c \
src/foundation/slab_alloc.c \
@@ -453,6 +454,7 @@ TEST_FOUNDATION_SRCS = \
tests/test_str_intern.c \
tests/test_log.c \
tests/test_str_util.c \
tests/test_workspace.c \
tests/test_platform.c \
tests/test_diagnostics.c \
tests/test_dump_verify.c \
+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.
+28 -18
View File
@@ -326,26 +326,36 @@ static char *extract_callee_from_fields(CBMArena *a, TSNode node, const char *so
}
// Haskell/OCaml: extract callee from apply/infix nodes.
/* Apply-style node kinds whose function head can nest another application. */
static bool fp_is_apply_kind(const char *kind) {
return strcmp(kind, "apply") == 0 || strcmp(kind, "application_expression") == 0 ||
strcmp(kind, "exp_apply") == 0;
}
static char *extract_fp_callee(CBMArena *a, TSNode node, const char *source, const char *nk) {
if (strcmp(nk, "apply") == 0 || strcmp(nk, "application_expression") == 0 ||
strcmp(nk, "exp_apply") == 0) {
if (ts_node_child_count(node) > 0) {
TSNode callee = ts_node_child(node, 0);
const char *ck = ts_node_type(callee);
if (strcmp(ck, "identifier") == 0 || strcmp(ck, "variable") == 0 ||
strcmp(ck, "constructor") == 0 || strcmp(ck, "value_path") == 0 ||
/* PureScript: exp_apply's function head is an `exp_name` whose
* text is the (possibly qualified) function name. */
strcmp(ck, "exp_name") == 0) {
return cbm_node_text(a, callee, source);
}
/* Curried application `f a b` nests exp_apply/apply — descend the
* function head to recover the leftmost callee. */
if (strcmp(ck, "exp_apply") == 0 || strcmp(ck, "apply") == 0 ||
strcmp(ck, "application_expression") == 0) {
return extract_fp_callee(a, callee, source, ck);
}
/* Curried application `f a b …` nests one apply node per argument on the
* function head. Walk that left spine iteratively: recursing once per
* application makes stack use follow the parse-tree depth of the indexed
* file, and a long enough chain runs out. The other descendant finders in
* this tree are already depth-bounded.
*
* Reassigning node/nk before continuing leaves the fall-through below
* operating on the innermost apply node — where the recursive form left it. */
while (fp_is_apply_kind(nk) && ts_node_child_count(node) > 0) {
TSNode callee = ts_node_child(node, 0);
const char *ck = ts_node_type(callee);
if (strcmp(ck, "identifier") == 0 || strcmp(ck, "variable") == 0 ||
strcmp(ck, "constructor") == 0 || strcmp(ck, "value_path") == 0 ||
/* PureScript: exp_apply's function head is an `exp_name` whose
* text is the (possibly qualified) function name. */
strcmp(ck, "exp_name") == 0) {
return cbm_node_text(a, callee, source);
}
if (!fp_is_apply_kind(ck)) {
break;
}
node = callee;
nk = ck;
}
if (strcmp(nk, "infix") == 0 || strcmp(nk, "infix_expression") == 0) {
TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator"));
+31
View File
@@ -432,13 +432,36 @@ void cbm_lex_free(cbm_lex_result_t *r) {
* PARSER
* ══════════════════════════════════════════════════════════════════ */
/* The WHERE grammar descends once per nested '(' and once per NOT, so parse
* depth follows the query text rather than anything bounded. A few tens of KB of
* either exhausts the stack before any semantic limit applies. 256 is far past
* any hand-written or generated query while leaving the stack untouched. */
enum { CYPHER_MAX_PARSE_DEPTH = 256 };
typedef struct {
const cbm_token_t *tokens;
int count;
int pos;
int depth; /* current recursive-descent depth; see CYPHER_MAX_PARSE_DEPTH */
char error[CBM_SZ_512];
} parser_t;
/* Enter one level of recursive descent. Returns false when the cap is hit, in
* which case the caller must return NULL without recursing. */
static bool parse_depth_enter(parser_t *p) {
if (p->depth >= CYPHER_MAX_PARSE_DEPTH) {
snprintf(p->error, sizeof(p->error), "expression nested deeper than %d levels",
CYPHER_MAX_PARSE_DEPTH);
return false;
}
p->depth++;
return true;
}
static void parse_depth_leave(parser_t *p) {
p->depth--;
}
static const cbm_token_t *peek(parser_t *p) {
if (p->pos >= p->count) {
return &p->tokens[p->count - SKIP_ONE]; /* EOF */
@@ -1203,7 +1226,11 @@ static cbm_expr_t *parse_condition_expr(parser_t *p) {
/* Atom: ( expr ) | condition */
static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
if (match(p, TOK_LPAREN)) {
if (!parse_depth_enter(p)) {
return NULL;
}
cbm_expr_t *e = parse_or_expr(p);
parse_depth_leave(p);
expect(p, TOK_RPAREN);
return e;
}
@@ -1213,7 +1240,11 @@ static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
/* NOT: NOT atom | atom */
static cbm_expr_t *parse_not_expr(parser_t *p) { // NOLINT(misc-no-recursion)
if (match(p, TOK_NOT)) {
if (!parse_depth_enter(p)) {
return NULL;
}
cbm_expr_t *child = parse_not_expr(p);
parse_depth_leave(p);
return child ? expr_not(child) : NULL;
}
return parse_atom_expr(p);
+44 -17
View File
@@ -39,17 +39,31 @@ struct cbm_gitignore {
/* ── Pattern matching engine ─────────────────────────────────────── */
/* Backtracking budget. `**` retries the remainder at every position, and
* consecutive literal + `**` groups multiply, so cost is exponential in the
* number of groups. Patterns come from a committed .gitignore / .cbmignore and
* every discovered path is matched against every pattern, so a small ignore file
* can make discovery take unbounded time. Cap the match steps per
* (pattern, path) pair.
*
* Exhausting the budget reports "no match", so a pathological pattern fails to
* ignore rather than ignoring the wrong files: the file gets indexed, which is
* the recoverable direction. The cap is far above any real pattern — ordinary
* globs finish in tens of steps. */
enum { GI_MATCH_MAX_STEPS = 20000 };
/* Forward declaration for recursive calls. */
static bool glob_match(const char *pat, const char *str); // NOLINT(misc-no-recursion)
static bool glob_match(const char *pat, const char *str,
int *budget); // NOLINT(misc-no-recursion)
/* Match a ** (doublestar-slash) pattern: try rest at every / boundary. */
static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recursion)
const char *str) {
if (glob_match(pat, str)) {
const char *str, int *budget) {
if (glob_match(pat, str, budget)) {
return true;
}
for (const char *s = str; *s; s++) {
if (*s == '/' && glob_match(pat, s + SKIP_ONE)) {
if (*s == '/' && glob_match(pat, s + SKIP_ONE, budget)) {
return true;
}
}
@@ -58,9 +72,9 @@ static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recur
/* Match a ** (doublestar) followed by non-slash: try at every position. */
static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursion)
const char *str) {
const char *str, int *budget) {
for (const char *s = str;; s++) {
if (glob_match(pat, s)) {
if (glob_match(pat, s, budget)) {
return true;
}
if (!*s) {
@@ -70,9 +84,10 @@ static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursi
}
/* Match a * (single star): match any sequence not containing /. */
static bool glob_match_star(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
static bool glob_match_star(const char *pat, const char *str,
int *budget) { // NOLINT(misc-no-recursion)
for (const char *s = str;; s++) {
if (glob_match(pat, s)) {
if (glob_match(pat, s, budget)) {
return true;
}
if (!*s || *s == '/') {
@@ -119,24 +134,29 @@ static bool glob_match_charclass(const char *pat, char ch, const char **pat_out)
* Handles: * (non-slash), ** (any path), ? (single non-slash), [class]
*/
/* Handle ** at current position. Returns match result. */
static bool glob_match_doublestar(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
static bool glob_match_doublestar(const char *pat, const char *str,
int *budget) { // NOLINT(misc-no-recursion)
if (pat[GI_CHAR_IDX2] == '/') {
return glob_match_doublestar_slash(pat + GI_SKIP3, str);
return glob_match_doublestar_slash(pat + GI_SKIP3, str, budget);
}
if (pat[GI_CHAR_IDX2] == '\0') {
return true;
}
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str);
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str, budget);
}
static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
static bool glob_match(const char *pat, const char *str,
int *budget) { // NOLINT(misc-no-recursion)
if (--*budget <= 0) {
return false; /* budget exhausted — see GI_MATCH_MAX_STEPS */
}
while (*pat && *str) {
if (pat[0] == '*' && pat[GI_CHAR_IDX1] == '*') {
return glob_match_doublestar(pat, str);
return glob_match_doublestar(pat, str, budget);
}
if (*pat == '*') {
return glob_match_star(pat + SKIP_ONE, str);
return glob_match_star(pat + SKIP_ONE, str, budget);
}
if (*pat == '?') {
@@ -171,6 +191,13 @@ static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-rec
return *pat == '\0' && *str == '\0';
}
/* Match one pattern against one path with a fresh backtracking budget. Not
* recursive itself — it seeds the budget and hands off to the engine. */
static bool glob_match_bounded(const char *pat, const char *str) {
int budget = GI_MATCH_MAX_STEPS;
return glob_match(pat, str, &budget);
}
/* ── Pattern parsing ─────────────────────────────────────────────── */
static void gi_add_pattern(cbm_gitignore_t *gi, const char *line, int len) {
@@ -320,7 +347,7 @@ cbm_gitignore_t *cbm_gitignore_load(const char *path) {
/* Match a non-rooted pattern against basename and path suffixes. */
static bool match_unrooted(const char *pattern, const char *rel_path, const char *basename) {
if (glob_match(pattern, basename)) {
if (glob_match_bounded(pattern, basename)) {
return true;
}
if (!strchr(rel_path, '/')) {
@@ -329,7 +356,7 @@ static bool match_unrooted(const char *pattern, const char *rel_path, const char
/* Try matching at every / boundary */
const char *s = rel_path;
while (*s) {
if (glob_match(pattern, s)) {
if (glob_match_bounded(pattern, s)) {
return true;
}
const char *next = strchr(s, '/');
@@ -359,7 +386,7 @@ int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path,
continue;
}
bool this_match = p->rooted ? glob_match(p->pattern, rel_path)
bool this_match = p->rooted ? glob_match_bounded(p->pattern, rel_path)
: match_unrooted(p->pattern, rel_path, basename);
if (this_match) {
+17
View File
@@ -271,6 +271,23 @@ bool cbm_validate_shell_arg(const char *s) {
return true;
}
bool cbm_validate_shell_path_arg(const char *path) {
if (!cbm_validate_shell_arg(path)) {
return false;
}
#ifdef _WIN32
/* cmd.exe expands %VAR% and, with delayed expansion, !VAR! inside the
* double quotes these commands use; ^ is its escape character. None of the
* three can be neutralised by quoting, so reject them outright. */
for (const char *p = path; *p; p++) {
if (*p == '%' || *p == '!' || *p == '^') {
return false;
}
}
#endif
return true;
}
bool cbm_validate_project_name(const char *name) {
if (!name || !*name)
return false;
+10
View File
@@ -57,6 +57,16 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
* Returns true if safe, false if the string contains shell metacharacters. */
bool cbm_validate_shell_arg(const char *s);
/* Validate a filesystem path that will be interpolated into a shell command.
* Everything cbm_validate_shell_arg rejects, plus the cmd.exe expansion
* metacharacters % ! ^ on Windows: a path reaching `git -C "%s"` through
* cmd.exe would otherwise get %VAR% / delayed-!VAR! expansion applied to it.
*
* Use this — not cbm_validate_shell_arg — for every path that crosses into a
* shell command, so the three git shell-out sites cannot drift apart again.
* Returns true if safe. */
bool cbm_validate_shell_path_arg(const char *path);
/* Validate a project name is safe for file path construction.
* Allows: alphanumeric, dash, underscore, dot (but not leading dot or dot-dot).
* Rejects: path separators (/ \), directory traversal (..), and control chars.
+566
View File
@@ -0,0 +1,566 @@
/*
* workspace.c — workspace boundary policy. See workspace.h for the contract and
* for why this is only the breadth half of the boundary.
*/
#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 {
/* POSIX: two components, so every top-level tree is refused in one rule with
* no list to maintain — "/etc", "/home", "/Users", "/var", "/opt", "/srv".
* Depth alone cannot do this on Windows, where the equivalent system trees
* and a perfectly ordinary workspace are both one component below the drive
* ("C:/Windows" and "D:/repos"), so Windows uses one component plus the
* named system directories below. Windows' top-level set is small and stable
* enough for a list; POSIX's is not. */
WS_MIN_DEPTH_POSIX = 2,
WS_MIN_DEPTH_WINDOWS = 1,
};
static bool ws_is_sep(char c) {
return c == '/' || c == '\\';
}
/* Length of the volume prefix: "/" on POSIX, "C:/" for a drive, "//srv/share"
* for a UNC share. Returns 0 when the path is not absolute. */
static size_t ws_volume_prefix_len(const char *path) {
if (!path || !path[0]) {
return 0;
}
/* UNC: two separators, a host, then a share. */
if (ws_is_sep(path[0]) && ws_is_sep(path[1])) {
size_t i = 2;
while (path[i] && !ws_is_sep(path[i])) { /* host */
i++;
}
while (ws_is_sep(path[i])) {
i++;
}
while (path[i] && !ws_is_sep(path[i])) { /* share */
i++;
}
return i;
}
if (ws_is_sep(path[0])) {
return 1;
}
/* Drive-letter root, with or without a trailing separator. */
if (path[1] == ':' &&
((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z'))) {
return ws_is_sep(path[2]) ? 3 : 2;
}
return 0;
}
static bool ws_is_unc(const char *path) {
return path && ws_is_sep(path[0]) && ws_is_sep(path[1]);
}
static bool ws_is_windows_style(const char *path) {
if (!path || !path[0]) {
return false;
}
return path[1] == ':' &&
((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z'));
}
int cbm_workspace_path_depth(const char *canonical_path) {
size_t prefix = ws_volume_prefix_len(canonical_path);
if (prefix == 0) {
return 0;
}
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++;
}
if (!*p) {
break;
}
depth++;
while (*p && !ws_is_sep(*p)) {
p++;
}
}
return depth;
}
/* Case-insensitive on Windows-style paths, exact elsewhere: NTFS is
* case-insensitive, so ".SSH" must not slip past a case-sensitive compare. */
static bool ws_component_equals(const char *component, size_t len, const char *name,
bool fold_case) {
if (strlen(name) != len) {
return false;
}
for (size_t i = 0; i < len; i++) {
char a = component[i];
char b = name[i];
if (fold_case) {
if (a >= 'A' && a <= 'Z') {
a = (char)(a - 'A' + 'a');
}
if (b >= 'A' && b <= 'Z') {
b = (char)(b - 'A' + 'a');
}
}
if (a != b) {
return false;
}
}
return true;
}
/* Directory and file names that carry credentials. Matched against every
* component, so both "…/.ssh" and "…/.ssh/sub" are refused. Additive by design:
* a pull request appending a name here needs no design authority. */
static const char *const WS_CREDENTIAL_NAMES[] = {
".ssh",
".aws",
".gnupg",
".gpg",
".kube",
".docker",
".netrc",
"_netrc",
".git-credentials",
".azure",
".gcloud",
"Keychains",
".password-store",
".authinfo",
};
/* Windows system trees, matched only as the FIRST component below the drive.
* Nothing legitimate is indexed inside them at any depth.
*
* "Users" is deliberately NOT here. Every Windows user's files live under
* C:\Users\<name>, so matching it the way credential names are matched — against
* every component — refuses every ordinary Windows project path. It is handled
* below as the tree root only, mirroring POSIX where "/Users" is refused but
* "/Users/dev/projects" is not. */
static const char *const WS_WINDOWS_SYSTEM_TREES[] = {
"Windows",
"ProgramData",
"Program Files",
"Program Files (x86)",
};
/* Roots that are the tree itself rather than something inside it. */
static const char *const WS_WINDOWS_USER_TREE = "Users";
/* Match only the first component below the volume prefix. */
static bool ws_first_component_matches(const char *path, const char *const *names, size_t count,
bool fold_case) {
const char *p = path + ws_volume_prefix_len(path);
while (ws_is_sep(*p)) {
p++;
}
const char *start = p;
while (*p && !ws_is_sep(*p)) {
p++;
}
size_t len = (size_t)(p - start);
for (size_t i = 0; i < count; i++) {
if (ws_component_equals(start, len, names[i], fold_case)) {
return true;
}
}
return false;
}
static bool ws_any_component_matches(const char *path, const char *const *names, size_t count,
bool fold_case) {
size_t prefix = ws_volume_prefix_len(path);
const char *p = path + prefix;
while (*p) {
while (ws_is_sep(*p)) {
p++;
}
if (!*p) {
break;
}
const char *start = p;
while (*p && !ws_is_sep(*p)) {
p++;
}
size_t len = (size_t)(p - start);
for (size_t i = 0; i < count; i++) {
if (ws_component_equals(start, len, names[i], fold_case)) {
return true;
}
}
}
return false;
}
/* True when b is a or lives under a. Compares on a separator boundary so
* "/a/bc" is not treated as living under "/a/b". */
static bool ws_is_ancestor_or_equal(const char *a, const char *b) {
if (!a || !b || !a[0] || !b[0]) {
return false;
}
size_t la = strlen(a);
while (la > 1 && ws_is_sep(a[la - 1])) {
la--;
}
if (strncmp(a, b, la) != 0) {
return false;
}
return b[la] == '\0' || ws_is_sep(b[la]);
}
static bool ws_paths_equal(const char *a, const char *b) {
return ws_is_ancestor_or_equal(a, b) && ws_is_ancestor_or_equal(b, a);
}
cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const char *home_dir,
const char *cache_dir) {
if (!canonical_path || !canonical_path[0] || ws_volume_prefix_len(canonical_path) == 0) {
/* A relative or empty path is not a usable root; refuse it the same way
* as a volume root rather than letting it fall through as allowed. */
return CBM_WS_DENY_ABSOLUTE;
}
bool windows_style = ws_is_windows_style(canonical_path);
int depth = cbm_workspace_path_depth(canonical_path);
/* A volume, drive or share root, whatever the platform. */
if (depth == 0) {
return CBM_WS_DENY_ABSOLUTE;
}
/* Order matters below, and not for cosmetic reasons.
*
* The home directory normally CONTAINS the cache directory, so testing the
* cache first would report every $HOME as "holds the cache" — and, worse,
* would make it absolutely denied when the design says a person may override
* it. Home is therefore classified first.
*
* Depth comes before the cache test for the same kind of reason: "/Users" is
* both too broad and an ancestor of the cache, and "too broad, name a
* project directory below it" is the reason that actually helps the reader. */
if (home_dir && home_dir[0] && ws_paths_equal(canonical_path, home_dir)) {
return CBM_WS_DENY_SENSITIVE;
}
/* Below a drive or a UNC share the first component is already user space
* ("D:/repos", "//srv/share/proj"), so one component is enough there. Below a
* POSIX root it is a system tree, so require two. */
int min_depth =
(windows_style || ws_is_unc(canonical_path)) ? WS_MIN_DEPTH_WINDOWS : WS_MIN_DEPTH_POSIX;
if (depth < min_depth) {
return CBM_WS_DENY_TOO_SHALLOW;
}
/* 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]),
windows_style)) {
return CBM_WS_DENY_SENSITIVE;
}
if (windows_style && ws_first_component_matches(canonical_path, WS_WINDOWS_SYSTEM_TREES,
sizeof(WS_WINDOWS_SYSTEM_TREES) /
sizeof(WS_WINDOWS_SYSTEM_TREES[0]),
true)) {
return CBM_WS_DENY_SENSITIVE;
}
/* "C:/Users" is the user tree itself — refuse it as a root, but leave the
* projects below it alone; that is where Windows users actually work. */
if (windows_style && depth == 1 &&
ws_first_component_matches(canonical_path, &WS_WINDOWS_USER_TREE, 1, true)) {
return CBM_WS_DENY_SENSITIVE;
}
return CBM_WS_ALLOW;
}
const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict) {
switch (verdict) {
case CBM_WS_ALLOW:
return "allowed";
case CBM_WS_DENY_TOO_SHALLOW:
return "path is too broad to index as one root; name a project directory below it";
case CBM_WS_DENY_ABSOLUTE:
return "path is a volume root or holds the codebase-memory cache; it cannot be indexed";
case CBM_WS_DENY_SENSITIVE:
return "path is a home or credential directory";
default:
break;
}
return "refused";
}
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();
}
+107
View File
@@ -0,0 +1,107 @@
#ifndef CBM_FOUNDATION_WORKSPACE_H
#define CBM_FOUNDATION_WORKSPACE_H
#include <stdbool.h>
#include <stddef.h>
/*
* Workspace boundary policy — classify a directory as an indexing root.
*
* This is the breadth half of the boundary: it rejects roots that are too broad
* or too sensitive to be indexed as a unit. It is NOT the authorization half.
* Whether a caller may index a given tree at all is answered by the user-level
* grant store, because that is the only input a caller cannot write. A verdict
* of CBM_WS_ALLOW here means "this path is not obviously too broad", never
* "this path is authorized".
*
* Every function takes an ALREADY-CANONICALIZED absolute path. Classifying
* before symlink resolution lets `/a/b/c/d/e/link -> /` present as a deep path
* and launder straight through, so callers must resolve first.
*
* home_dir and cache_dir are passed in rather than read from the environment so
* the policy is a pure function and can be tested without touching the host.
* Either may be NULL, which disables the checks that depend on it.
*/
typedef enum {
CBM_WS_ALLOW = 0,
/* Fewer path components than the platform minimum — e.g. "/etc", "/home". */
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. */
CBM_WS_DENY_ABSOLUTE,
/* The home directory itself, or a credential/system directory. Overridable
* by an explicit human action, never by a manifest or a tool call. */
CBM_WS_DENY_SENSITIVE,
} cbm_ws_verdict_t;
/* Classify canonical_path as a candidate indexing root. */
cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const char *home_dir,
const char *cache_dir);
/* 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. */
bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict);
/* Number of path components below the volume root, ignoring repeated and
* trailing separators. "/" is 0, "/etc" is 1, "C:/repos" is 1, "//srv/share" is
* 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 */
+1 -11
View File
@@ -40,17 +40,7 @@ static void trim_newlines(char *s) {
}
static bool git_validate_repo_path(const char *repo_path) {
if (!cbm_validate_shell_arg(repo_path)) {
return false;
}
#ifdef _WIN32
for (const char *p = repo_path; *p; p++) {
if (*p == '%' || *p == '!' || *p == '^') {
return false;
}
}
#endif
return true;
return cbm_validate_shell_path_arg(repo_path);
}
static int git_capture(const char *repo_path, const char *git_args, char **out) {
+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) {
+1 -11
View File
@@ -221,17 +221,7 @@ static int write_file_atomic(const char *path, const char *data, size_t len,
* % ! ^. Callers then use DOUBLE quotes (honored by both POSIX sh and cmd.exe, unlike
* single quotes on cmd.exe), so a repo path may legitimately contain spaces. */
bool cbm_artifact_repo_path_is_shell_safe(const char *repo_path) {
if (!cbm_validate_shell_arg(repo_path)) {
return false;
}
#ifdef _WIN32
for (const char *p = repo_path; *p; p++) {
if (*p == '%' || *p == '!' || *p == '^') {
return false;
}
}
#endif
return true;
return cbm_validate_shell_path_arg(repo_path);
}
/* Get current git HEAD hash. buf must be >= CBM_SZ_64. Returns false on error. */
+5 -1
View File
@@ -303,7 +303,11 @@ static void calls_append_args(char *props, size_t cap, const CBMCall *call) {
n = snprintf(one, sizeof(one), "%s{\"i\":%d,\"e\":\"%s\"}", i > 0 ? "," : "", a->index,
esc_e);
}
if (n <= 0 || (size_t)n >= cap - pos - PAIR_LEN) {
/* Add rather than subtract: pos is unsigned, so `cap - pos - PAIR_LEN`
* wraps once pos reaches cap - PAIR_LEN and stops bounding the memcpy
* below. The closing write at the end of this function already guards
* additively; match it. */
if (n <= 0 || pos + (size_t)n + PAIR_LEN >= cap) {
break; /* not enough room — close the array with what fits */
}
memcpy(props + pos, one, (size_t)n);
+1 -1
View File
@@ -111,7 +111,7 @@ static int parse_git_log(const char *repo_path, commit_t **out, int *out_count)
*out = NULL;
*out_count = 0;
if (!cbm_validate_shell_arg(repo_path)) {
if (!cbm_validate_shell_path_arg(repo_path)) {
return CBM_NOT_FOUND;
}
+67 -10
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>
@@ -482,8 +483,18 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
int start = (g_log_head - count + LOG_RING_SIZE) % LOG_RING_SIZE;
int total = g_log_count;
/* Copy lines under lock */
size_t buf_size = (size_t)count * (LOG_LINE_MAX + 10) + 64;
/* Copy lines under lock.
*
* JSON escaping expands '"', '\\' and '\n' to two bytes each, so an
* line made mostly of those serialises to roughly twice its stored length.
* The previous budget of LOG_LINE_MAX + 10 per line under-counted that by
* half. Ring contents come from indexer stderr, which is not escaped on
* ingest and can legitimately contain both doubling characters — a POSIX
* filename may.
*
* Budget the escaped worst case, and clamp the framing writes below anyway
* so the size calculation is not the only thing keeping pos in range. */
size_t buf_size = (size_t)count * (2 * LOG_LINE_MAX + 8) + 64;
char *buf = malloc(buf_size);
if (!buf) {
cbm_mutex_unlock(&g_log_mutex);
@@ -496,9 +507,9 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
for (int i = 0; i < count; i++) {
int idx = (start + i) % LOG_RING_SIZE;
if (i > 0)
buf[pos++] = ',';
http_appendf(buf, buf_size, &pos, ",");
/* Escape quotes in log lines */
buf[pos++] = '"';
http_appendf(buf, buf_size, &pos, "\"");
for (int j = 0; g_log_ring[idx][j] && (size_t)pos < buf_size - 10; j++) {
char ch = g_log_ring[idx][j];
if (ch == '"') {
@@ -514,11 +525,19 @@ static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
buf[pos++] = ch;
}
}
buf[pos++] = '"';
http_appendf(buf, buf_size, &pos, "\"");
}
cbm_mutex_unlock(&g_log_mutex);
http_appendf(buf, buf_size, &pos, "],\"total\":%d}", total);
/* http_appendf pins pos to buf_size on truncation and then writes nothing,
* so a saturated buffer would reach the "%s" reply with no terminator in
* range. Terminate explicitly. */
if ((size_t)pos >= buf_size) {
pos = (int)buf_size - 1;
}
buf[pos] = '\0';
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
free(buf);
}
@@ -627,7 +646,9 @@ static void append_roots_json(char *buf, size_t bufsz, int *pos) {
continue;
}
if (count++ > 0) {
buf[(*pos)++] = ',';
/* Once a wide listing saturates buf, http_appendf has already
* pinned *pos to bufsz, so this separator goes through it too. */
http_appendf(buf, bufsz, pos, ",");
}
http_appendf(buf, bufsz, pos, "\"%c:/\"", 'A' + i);
}
@@ -1032,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++) {
@@ -1089,14 +1132,28 @@ static void handle_index_status(cbm_http_server_t *server, cbm_http_conn_t *c) {
if (st == 0)
continue;
if (pos > 1)
buf[pos++] = ',';
http_appendf(buf, sizeof(buf), &pos, ",");
const char *ss = st == 1 ? "indexing" : st == 2 ? "done" : "error";
/* root_path comes from POST /api/index and is up to 1023 bytes, so four
* occupied slots exceed this buffer. http_appendf pins pos to
* sizeof(buf) on truncation, so the separator and the close have to go
* through it as well rather than indexing raw. Both fields are free-form,
* so escape them — a quote in a path would otherwise end its JSON string
* early. */
/* Escaping can double each byte: root_path is 1024, error_msg 256. */
char esc_path[2048];
char esc_error[512];
cbm_json_escape(esc_path, (int)sizeof(esc_path), server->index_jobs[i].root_path);
cbm_json_escape(esc_error, (int)sizeof(esc_error),
st == 3 ? server->index_jobs[i].error_msg : "");
http_appendf(buf, sizeof(buf), &pos,
"{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss,
server->index_jobs[i].root_path,
st == 3 ? server->index_jobs[i].error_msg : "");
esc_path, esc_error);
}
http_appendf(buf, sizeof(buf), &pos, "]");
if ((size_t)pos >= sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
buf[pos++] = ']';
buf[pos] = '\0';
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
+53
View File
@@ -512,6 +512,58 @@ static cbm_store_t *setup_cypher_store(void) {
return s;
}
/* The query string is caller-supplied and the WHERE grammar recurses once per
* nested '(' and once per NOT, with no depth counter between the MCP entry point
* and the recursive descent. A few tens of KB of '(' therefore exhausted the
* stack at parse time. Parse in a forked child so the crash surfaces as a
* killing signal rather than taking the test runner with it; a bounded parser
* must reject the query cleanly instead. */
TEST(cypher_deep_nesting_rejected_not_crash) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the depth cap is platform-agnostic");
#else
enum { NEST = 30000 };
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
char *q = malloc(NEST * 2 + 64);
if (!q) {
_exit(2);
}
int n = snprintf(q, 64, "MATCH (f:Function) WHERE ");
for (int i = 0; i < NEST; i++) {
q[n++] = '(';
}
n += snprintf(q + n, 32, "f.name = \"x\"");
for (int i = 0; i < NEST; i++) {
q[n++] = ')';
}
q[n] = '\0';
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
/* Any clean outcome is acceptable — success or a parse error. Only a
* crash is a failure, and that is what the signal check below catches. */
(void)cbm_cypher_execute(s, q, "test", 0, &r);
cbm_cypher_result_free(&r);
cbm_store_close(s);
free(q);
_exit(0);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "parser killed by signal %d — unbounded recursion depth",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
TEST(cypher_exec_match_all_functions) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
@@ -3664,6 +3716,7 @@ SUITE(cypher) {
/* Execution */
RUN_TEST(cypher_exec_deadline_aborts_runaway_query_issue601);
RUN_TEST(cypher_exec_deadline_allows_normal_query_issue601);
RUN_TEST(cypher_deep_nesting_rejected_not_crash);
RUN_TEST(cypher_exec_match_all_functions);
RUN_TEST(cypher_exec_optional_empty_label_no_overflow);
RUN_TEST(cypher_cross_join_alloc_rejects_overflow);
+59
View File
@@ -7,6 +7,10 @@
#include "test_framework.h"
#include "discover/discover.h"
#include <string.h> /* strdup (test seam) */
#ifndef _WIN32
#include <sys/wait.h> /* fork/waitpid crash-isolation for the backtracking budget */
#include <unistd.h> /* alarm() as the liveness backstop */
#endif
/* ── Basic pattern matching ────────────────────────────────────── */
@@ -274,9 +278,64 @@ TEST(gi_merge_atomic_on_alloc_failure) {
PASS();
}
/* `**` retries the remainder at every position, so consecutive literal + `**`
* groups multiply and cost is exponential in the number of groups. Patterns come
* from a committed .gitignore and every discovered path is tested against every
* pattern, so a small ignore file can make discovery take unbounded time. The
* matcher must give up on its step budget.
*
* Run in a forked child under an alarm: the alarm is only a liveness backstop —
* the assertion is that matching TERMINATES, and the unbounded matcher never
* reaches the exit. */
TEST(gi_doublestar_backtracking_terminates) {
#ifdef _WIN32
SKIP_PLATFORM("fork/alarm crash-isolation is POSIX-only; the budget is platform-agnostic");
#else
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
/* 20 "a**" groups then a literal that cannot match, against a run of
* 'a' — the classic catastrophic-backtracking shape. */
char pattern[256];
int n = 0;
for (int i = 0; i < 20; i++) {
n += snprintf(pattern + n, sizeof(pattern) - (size_t)n, "a**");
}
(void)snprintf(pattern + n, sizeof(pattern) - (size_t)n, "X\n");
char path[64];
memset(path, 'a', sizeof(path) - 1);
path[sizeof(path) - 1] = '\0';
alarm(20);
cbm_gitignore_t *gi = cbm_gitignore_parse(pattern);
if (!gi) {
_exit(2);
}
/* The pattern cannot match; the only question is whether we come back. */
(void)cbm_gitignore_matches(gi, path, false);
cbm_gitignore_free(gi);
_exit(0);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char m[112];
snprintf(m, sizeof(m),
"glob matching did not terminate (signal %d) — no backtracking budget",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(gitignore) {
RUN_TEST(gi_doublestar_backtracking_terminates);
RUN_TEST(gi_empty_pattern);
RUN_TEST(gi_exact_file);
RUN_TEST(gi_wildcard_star);
+158
View File
@@ -2030,10 +2030,168 @@ TEST(ui_server_browse_wide_dir_no_overflow) {
#endif
}
/* The log endpoint serialises the ring into one heap buffer budgeted at
* LOG_LINE_MAX + 10 bytes per line. JSON escaping doubles every '"' and '\\',
* so an escape-dense line needs ~2x LOG_LINE_MAX. The inner escape loop stops
* at buf_size - 10, but the per-line framing writes (separator comma, opening
* quote, closing quote) were raw indexes, so every line past saturation wrote
* three bytes beyond the allocation. Fill the ring with escape-dense lines and
* read it back in a forked child, so the overflow surfaces as a killing signal
* (ASan abort) instead of silent heap corruption. */
TEST(ui_server_logs_escape_dense_no_overflow) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
#else
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
/* Worst case the ring can hold: every byte escapes to two bytes. */
char dense[512];
for (size_t i = 0; i < sizeof(dense) - 1; i++) {
dense[i] = (i % 2 == 0) ? '"' : '\\';
}
dense[sizeof(dense) - 1] = '\0';
for (int i = 0; i < 500; i++) { /* fills the whole 500-entry ring */
cbm_ui_log_append(dense);
}
th_server_t ts;
if (th_server_start(&ts) != 0) {
_exit(2);
}
char req[256];
int port = cbm_http_server_port(ts.srv);
snprintf(req, sizeof(req),
"GET /api/logs?lines=500 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port);
size_t cap = 4u * 1024u * 1024u;
char *resp = malloc(cap);
int n = resp ? th_http(port, req, resp, (int)cap) : 0;
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
free(resp);
th_server_stop(&ts);
_exit(ok ? 0 : 3);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "logs killed by signal %d — response buffer overflow",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
/* The index-status endpoint renders every active job into a fixed 2 KB stack
* buffer. http_appendf clamps its own writes, but the separator and the closing
* bracket were raw indexes, so two jobs holding ~1 KB root paths (the field is
* 1024 bytes and the value comes straight from POST /api/index) pushed pos to
* the clamp and the close then wrote past the buffer. Drive it through the real
* endpoint with the index executor stubbed out, in a forked child so the
* overflow surfaces as a killing signal. */
#define MAX_TEST_INDEX_JOBS 4
TEST(ui_server_index_status_long_paths_no_overflow) {
#ifdef _WIN32
SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic");
#else
char *base = th_mktempdir("cbm_status");
if (!base) {
FAIL("mktempdir");
}
/* Two real directories whose paths are long enough that two rendered job
* entries exceed the 2 KB response buffer. */
/* Four slots x ~520 chars overflows the 2 KB buffer while every path stays
* well inside PATH_MAX — a single pair of ~1 KB paths would reach the
* buffer too, but mkdir refuses them once the temp-dir prefix is added. */
char comp[200];
memset(comp, 'd', sizeof(comp) - 1);
comp[sizeof(comp) - 1] = '\0';
char deep[MAX_TEST_INDEX_JOBS][800];
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
int n = snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j);
while (n < 520) {
n += snprintf(deep[j] + n, sizeof(deep[j]) - (size_t)n, "/%s", comp);
}
th_mkdir_p(deep[j]);
}
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
/* The blocking executor holds every job open so all four slots are
* occupied at once. With the non-blocking stub each job finishes
* immediately and handle_index_start recycles the same slot, leaving a
* single entry to render — far short of the buffer. */
th_ui_blocking_index_executor_t executor = {0};
atomic_init(&executor.calls, 0);
atomic_init(&executor.release, 0);
th_server_t ts;
ts.srv = cbm_http_server_new(0);
if (!ts.srv) {
_exit(2);
}
cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor);
if (th_server_thread_start(&ts.tid, ts.srv) != 0) {
_exit(2);
}
int port = cbm_http_server_port(ts.srv);
for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) {
char body[1200];
snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j],
j);
char request[1500];
snprintf(request, sizeof(request),
"POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n"
"Content-Length: %zu\r\n\r\n%s",
strlen(body), body);
char response[4096];
int rn = th_http(port, request, response, sizeof(response));
/* A rejected POST would leave the slot empty and the endpoint would
* render nothing — a vacuous pass. Fail loudly instead. */
if (rn <= 0 || th_status(response) != 202) {
_exit(4);
}
}
/* Wait for the state the assertion depends on — all four jobs actually
* running — rather than for a duration. */
if (!th_wait_atomic_int(&executor.calls, MAX_TEST_INDEX_JOBS, 5000)) {
atomic_store(&executor.release, 1);
_exit(5);
}
char req[256];
snprintf(req, sizeof(req), "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n",
port);
char resp[8192];
int n = th_http(port, req, resp, sizeof(resp));
int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL);
atomic_store(&executor.release, 1);
th_server_stop(&ts);
_exit(ok ? 0 : 3);
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
th_cleanup(base);
if (WIFSIGNALED(status)) {
char m[96];
snprintf(m, sizeof(m), "index-status killed by signal %d — response buffer overflow",
WTERMSIG(status));
FAIL(m);
}
ASSERT_TRUE(WIFEXITED(status));
ASSERT_EQ(WEXITSTATUS(status), 0);
PASS();
#endif
}
/* ── Suite ────────────────────────────────────────────────────── */
SUITE(httpd) {
RUN_TEST(ui_server_browse_wide_dir_no_overflow);
RUN_TEST(ui_server_logs_escape_dense_no_overflow);
RUN_TEST(ui_server_index_status_long_paths_no_overflow);
/* Parser / helpers */
RUN_TEST(httpd_parse_simple_get);
RUN_TEST(httpd_parse_security_headers_and_rejects_duplicates);
+2
View File
@@ -603,6 +603,7 @@ extern void suite_dyn_array(void);
extern void suite_str_intern(void);
extern void suite_log(void);
extern void suite_str_util(void);
extern void suite_workspace(void);
extern void suite_platform(void);
extern void suite_diagnostics(void);
extern void suite_subprocess(void);
@@ -848,6 +849,7 @@ int main(int argc, char **argv) {
RUN_SELECTED_SUITE(str_intern);
RUN_SELECTED_SUITE(log);
RUN_SELECTED_SUITE(str_util);
RUN_SELECTED_SUITE(workspace);
RUN_SELECTED_SUITE(platform);
RUN_SELECTED_SUITE(diagnostics);
RUN_SELECTED_SUITE(subprocess);
+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);
+25
View File
@@ -220,6 +220,30 @@ TEST(shell_rejects_newline) {
PASS();
}
/* Every path that reaches a shell command must go through the path variant, not
* the bare one: the three git shell-out sites each wrap the repo path in cmd.exe
* double quotes, where %VAR%, !VAR! and ^ stay active. One site used the bare
* validator and so accepted them. */
TEST(shell_path_arg_rejects_cmd_expansion) {
/* Anything the base validator rejects, the path variant rejects too. */
ASSERT_FALSE(cbm_validate_shell_path_arg("foo'bar"));
ASSERT_FALSE(cbm_validate_shell_path_arg("$(whoami)"));
ASSERT_FALSE(cbm_validate_shell_path_arg("foo;rm -rf /"));
ASSERT_FALSE(cbm_validate_shell_path_arg(NULL));
#ifdef _WIN32
ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/%USERPROFILE%"));
ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/!PATH!"));
ASSERT_FALSE(cbm_validate_shell_path_arg("C:/repo/a^b"));
ASSERT_TRUE(cbm_validate_shell_path_arg("C:/Users/dev/my repo"));
#else
/* The cmd.exe metacharacters are inert on POSIX, so a path containing them
* stays valid here; only the Windows build rejects them. */
ASSERT_TRUE(cbm_validate_shell_path_arg("/tmp/repo/100%"));
ASSERT_TRUE(cbm_validate_shell_path_arg("/tmp/my repo"));
#endif
PASS();
}
TEST(shell_rejects_carriage_return) {
ASSERT_FALSE(cbm_validate_shell_arg("foo\rbar"));
PASS();
@@ -782,6 +806,7 @@ SUITE(security) {
RUN_TEST(shell_rejects_ampersand);
RUN_TEST(shell_rejects_backslash);
RUN_TEST(shell_rejects_newline);
RUN_TEST(shell_path_arg_rejects_cmd_expansion);
RUN_TEST(shell_rejects_carriage_return);
RUN_TEST(shell_rejects_null);
RUN_TEST(shell_rejects_double_quote);
+170
View File
@@ -0,0 +1,170 @@
/*
* test_workspace.c — workspace boundary policy.
*
* The policy is a pure function over an already-canonicalized path, so these are
* table tests with no filesystem involved. home_dir and cache_dir are injected.
*/
#include "../src/foundation/compat.h"
#include "test_framework.h"
#include "foundation/workspace.h"
static const char *HOME = "/Users/dev";
static const char *CACHE = "/Users/dev/.cache/codebase-memory-mcp";
TEST(ws_depth_counts_components_below_the_volume) {
ASSERT_EQ(cbm_workspace_path_depth("/"), 0);
ASSERT_EQ(cbm_workspace_path_depth("/etc"), 1);
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);
ASSERT_EQ(cbm_workspace_path_depth("D:\\repos\\app"), 2);
/* A UNC share root is the share itself. */
ASSERT_EQ(cbm_workspace_path_depth("//srv/share"), 0);
ASSERT_EQ(cbm_workspace_path_depth("//srv/share/proj"), 1);
PASS();
}
TEST(ws_volume_roots_are_absolutely_denied) {
ASSERT_EQ(cbm_workspace_classify_root("/", 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("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();
}
/* A relative or empty path is not a usable root and must not fall through as
* allowed just because no rule matched it. */
TEST(ws_non_absolute_paths_are_denied) {
ASSERT_EQ(cbm_workspace_classify_root("", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root("relative/path", HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
ASSERT_EQ(cbm_workspace_classify_root(NULL, HOME, CACHE), CBM_WS_DENY_ABSOLUTE);
PASS();
}
/* 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/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);
}
ASSERT_FALSE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_TOO_SHALLOW));
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) {
ASSERT_EQ(cbm_workspace_classify_root("/opt/sdk", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/srv/protos", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("D:/repos", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("//srv/share/proj", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/dev/app", HOME, CACHE), CBM_WS_ALLOW);
PASS();
}
/* $HOME is depth 2 on both macOS and Linux, so depth cannot see it. */
TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/projects", HOME, CACHE), CBM_WS_ALLOW);
/* A sibling that merely shares a prefix is not the home directory. */
ASSERT_EQ(cbm_workspace_classify_root("/Users/developer", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_TRUE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_SENSITIVE));
PASS();
}
/* The enumeration bypass: a credential directory passes every breadth rule, so
* it has to be named. Matched on any component, so subdirectories go too. */
TEST(ws_credential_directories_are_sensitive_at_any_depth) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.ssh", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.ssh/keys", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.aws", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.gnupg", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.kube", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/Library/Keychains", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
/* A name that merely contains a listed one is a different directory. */
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.sshconfig", 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);
/* THE regression that matters on Windows: every user's work lives under
* C:\Users\<name>, so matching "Users" against every component would refuse
* every ordinary project path — including a CI runner's own workspace. Only
* the tree root is refused. */
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/dev/projects/app", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/runneradmin/work/repo", HOME, CACHE),
CBM_WS_ALLOW);
/* System trees are refused at any depth inside them, not just at the root. */
ASSERT_EQ(cbm_workspace_classify_root("C:/Windows/System32", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
/* A project merely named after one is not one. */
ASSERT_EQ(cbm_workspace_classify_root("C:/dev/Windows-app", HOME, CACHE), CBM_WS_ALLOW);
ASSERT_EQ(cbm_workspace_classify_root("C:/ProgramData", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/Program Files/app", HOME, CACHE),
CBM_WS_DENY_SENSITIVE);
/* Case-insensitive: NTFS is, so a case-flipped name must not slip past. */
ASSERT_EQ(cbm_workspace_classify_root("C:/WINDOWS", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/users/dev/.SSH", HOME, CACHE), CBM_WS_DENY_SENSITIVE);
PASS();
}
/* POSIX is case-sensitive, so a differently-cased directory is a different one
* and must not be refused. */
TEST(ws_posix_matching_is_case_sensitive) {
ASSERT_EQ(cbm_workspace_classify_root("/Users/dev/.SSH", HOME, CACHE), CBM_WS_ALLOW);
PASS();
}
/* Absent injected context, the checks that depend on it simply do not fire —
* 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);
/* 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);
PASS();
}
TEST(ws_every_verdict_has_a_reason) {
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_ALLOW));
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW));
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_ABSOLUTE));
ASSERT_NOT_NULL(cbm_workspace_verdict_reason(CBM_WS_DENY_SENSITIVE));
/* Reasons are user-facing; a bare enum name would not help anyone. */
ASSERT_TRUE(strlen(cbm_workspace_verdict_reason(CBM_WS_DENY_TOO_SHALLOW)) > 20);
PASS();
}
SUITE(workspace) {
RUN_TEST(ws_depth_counts_components_below_the_volume);
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_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_windows_system_trees_are_sensitive);
RUN_TEST(ws_posix_matching_is_case_sensitive);
RUN_TEST(ws_null_context_disables_dependent_checks);
RUN_TEST(ws_every_verdict_has_a_reason);
}