Skip to content

Commit 2d03bf6

Browse files
authored
Merge branch 'main' into feat/pkl-call-extraction
2 parents e484924 + 0d6f26a commit 2d03bf6

32 files changed

Lines changed: 1612 additions & 118 deletions

‎Makefile.cbm‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ FOUNDATION_SRCS = \
188188
src/foundation/str_intern.c \
189189
src/foundation/log.c \
190190
src/foundation/str_util.c \
191+
src/foundation/workspace.c \
191192
src/foundation/platform.c \
192193
src/foundation/system_info.c \
193194
src/foundation/slab_alloc.c \
@@ -453,6 +454,7 @@ TEST_FOUNDATION_SRCS = \
453454
tests/test_str_intern.c \
454455
tests/test_log.c \
455456
tests/test_str_util.c \
457+
tests/test_workspace.c \
456458
tests/test_platform.c \
457459
tests/test_diagnostics.c \
458460
tests/test_dump_verify.c \

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,7 @@ codebase-memory-mcp config reset auto_index # reset to default
670670

671671
| Variable | Default | Description |
672672
|----------|---------|-------------|
673-
| `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. |
673+
| `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. |
674674
| `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. |
675675
| `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`. |
676676
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |

‎SECURITY.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ sha256sum -c checksums.txt
172172

173173
| Version | Supported |
174174
|---------|-----------|
175-
| Latest `0.8.x` | Yes — security fixes land in the newest release |
176-
| < 0.8 | No — please upgrade to the latest release |
175+
| Latest `0.9.x` | Yes — security fixes land in the newest release |
176+
| < 0.9 | No — please upgrade to the latest release |
177177

178178
Only the latest release is supported. Security fixes are shipped in a new
179179
patched release rather than backported to older versions; upgrading to the

‎docs/CONFIGURATION.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ These environment variables affect runtime behavior:
116116

117117
| Variable | Default | Description |
118118
|---|---|---|
119-
| `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). |
119+
| `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. |
120120
| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. |
121121
| `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. |
122122
| `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. |
@@ -125,6 +125,25 @@ These environment variables affect runtime behavior:
125125

126126
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.
127127

128+
129+
### Roots that are always refused
130+
131+
Independently of `CBM_ALLOWED_ROOT`, some directories are refused as an indexing
132+
root because they are too broad or too sensitive to index as a unit:
133+
134+
- a filesystem root, a Windows drive root, or a UNC share root;
135+
- a top-level system tree — `/etc`, `/var`, `/usr`, `/home`, `/Users`, and on
136+
Windows `C:\Windows`, `C:\Users`, `C:\ProgramData`, `C:\Program Files`;
137+
- your home directory itself (directories *below* it are fine);
138+
- a credential directory at any depth — `.ssh`, `.aws`, `.gnupg`, `.kube`,
139+
`.docker`, `.netrc`, `.git-credentials`, `.password-store`, macOS `Keychains`.
140+
141+
Two limits are worth stating plainly. This constrains *scope*, not
142+
*sensitivity*: inside a root that is allowed, every file the process can read may
143+
be indexed and later returned. And the credential list is a denylist, so it
144+
raises the cost of a mistake rather than closing the class — a directory it does
145+
not name is permitted.
146+
128147
## 5. Agent and Editor Integration Files
129148

130149
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.

‎internal/cbm/extract_calls.c‎

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -326,26 +326,36 @@ static char *extract_callee_from_fields(CBMArena *a, TSNode node, const char *so
326326
}
327327

328328
// Haskell/OCaml: extract callee from apply/infix nodes.
329+
/* Apply-style node kinds whose function head can nest another application. */
330+
static bool fp_is_apply_kind(const char *kind) {
331+
return strcmp(kind, "apply") == 0 || strcmp(kind, "application_expression") == 0 ||
332+
strcmp(kind, "exp_apply") == 0;
333+
}
334+
329335
static char *extract_fp_callee(CBMArena *a, TSNode node, const char *source, const char *nk) {
330-
if (strcmp(nk, "apply") == 0 || strcmp(nk, "application_expression") == 0 ||
331-
strcmp(nk, "exp_apply") == 0) {
332-
if (ts_node_child_count(node) > 0) {
333-
TSNode callee = ts_node_child(node, 0);
334-
const char *ck = ts_node_type(callee);
335-
if (strcmp(ck, "identifier") == 0 || strcmp(ck, "variable") == 0 ||
336-
strcmp(ck, "constructor") == 0 || strcmp(ck, "value_path") == 0 ||
337-
/* PureScript: exp_apply's function head is an `exp_name` whose
338-
* text is the (possibly qualified) function name. */
339-
strcmp(ck, "exp_name") == 0) {
340-
return cbm_node_text(a, callee, source);
341-
}
342-
/* Curried application `f a b` nests exp_apply/apply — descend the
343-
* function head to recover the leftmost callee. */
344-
if (strcmp(ck, "exp_apply") == 0 || strcmp(ck, "apply") == 0 ||
345-
strcmp(ck, "application_expression") == 0) {
346-
return extract_fp_callee(a, callee, source, ck);
347-
}
336+
/* Curried application `f a b …` nests one apply node per argument on the
337+
* function head. Walk that left spine iteratively: recursing once per
338+
* application makes stack use follow the parse-tree depth of the indexed
339+
* file, and a long enough chain runs out. The other descendant finders in
340+
* this tree are already depth-bounded.
341+
*
342+
* Reassigning node/nk before continuing leaves the fall-through below
343+
* operating on the innermost apply node — where the recursive form left it. */
344+
while (fp_is_apply_kind(nk) && ts_node_child_count(node) > 0) {
345+
TSNode callee = ts_node_child(node, 0);
346+
const char *ck = ts_node_type(callee);
347+
if (strcmp(ck, "identifier") == 0 || strcmp(ck, "variable") == 0 ||
348+
strcmp(ck, "constructor") == 0 || strcmp(ck, "value_path") == 0 ||
349+
/* PureScript: exp_apply's function head is an `exp_name` whose
350+
* text is the (possibly qualified) function name. */
351+
strcmp(ck, "exp_name") == 0) {
352+
return cbm_node_text(a, callee, source);
353+
}
354+
if (!fp_is_apply_kind(ck)) {
355+
break;
348356
}
357+
node = callee;
358+
nk = ck;
349359
}
350360
if (strcmp(nk, "infix") == 0 || strcmp(nk, "infix_expression") == 0) {
351361
TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator"));

‎scripts/test.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_watchdog.sh"
280280
# still exits nonzero for the user-facing tool error, but the supervisor must
281281
# preserve that response instead of misreporting exit_nonzero as a file crash.
282282
echo "=== Step 5c: worker error-response transport regression ==="
283-
bash "$ROOT/tests/test_worker_error_response.sh"
283+
CBM_TEST_BINARY="$WATCHDOG_BINARY" bash "$ROOT/tests/test_worker_error_response.sh"
284284

285285
# Step 5d (#1388) is DELIBERATELY NOT GATING HERE — see
286286
# tests/test_hook_conflict_notice.sh for the full what-was-tried record.

‎src/cypher/cypher.c‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,13 +432,36 @@ void cbm_lex_free(cbm_lex_result_t *r) {
432432
* PARSER
433433
* ══════════════════════════════════════════════════════════════════ */
434434

435+
/* The WHERE grammar descends once per nested '(' and once per NOT, so parse
436+
* depth follows the query text rather than anything bounded. A few tens of KB of
437+
* either exhausts the stack before any semantic limit applies. 256 is far past
438+
* any hand-written or generated query while leaving the stack untouched. */
439+
enum { CYPHER_MAX_PARSE_DEPTH = 256 };
440+
435441
typedef struct {
436442
const cbm_token_t *tokens;
437443
int count;
438444
int pos;
445+
int depth; /* current recursive-descent depth; see CYPHER_MAX_PARSE_DEPTH */
439446
char error[CBM_SZ_512];
440447
} parser_t;
441448

449+
/* Enter one level of recursive descent. Returns false when the cap is hit, in
450+
* which case the caller must return NULL without recursing. */
451+
static bool parse_depth_enter(parser_t *p) {
452+
if (p->depth >= CYPHER_MAX_PARSE_DEPTH) {
453+
snprintf(p->error, sizeof(p->error), "expression nested deeper than %d levels",
454+
CYPHER_MAX_PARSE_DEPTH);
455+
return false;
456+
}
457+
p->depth++;
458+
return true;
459+
}
460+
461+
static void parse_depth_leave(parser_t *p) {
462+
p->depth--;
463+
}
464+
442465
static const cbm_token_t *peek(parser_t *p) {
443466
if (p->pos >= p->count) {
444467
return &p->tokens[p->count - SKIP_ONE]; /* EOF */
@@ -1203,7 +1226,11 @@ static cbm_expr_t *parse_condition_expr(parser_t *p) {
12031226
/* Atom: ( expr ) | condition */
12041227
static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
12051228
if (match(p, TOK_LPAREN)) {
1229+
if (!parse_depth_enter(p)) {
1230+
return NULL;
1231+
}
12061232
cbm_expr_t *e = parse_or_expr(p);
1233+
parse_depth_leave(p);
12071234
expect(p, TOK_RPAREN);
12081235
return e;
12091236
}
@@ -1213,7 +1240,11 @@ static cbm_expr_t *parse_atom_expr(parser_t *p) { // NOLINT(misc-no-recursion)
12131240
/* NOT: NOT atom | atom */
12141241
static cbm_expr_t *parse_not_expr(parser_t *p) { // NOLINT(misc-no-recursion)
12151242
if (match(p, TOK_NOT)) {
1243+
if (!parse_depth_enter(p)) {
1244+
return NULL;
1245+
}
12161246
cbm_expr_t *child = parse_not_expr(p);
1247+
parse_depth_leave(p);
12171248
return child ? expr_not(child) : NULL;
12181249
}
12191250
return parse_atom_expr(p);

‎src/discover/gitignore.c‎

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,31 @@ struct cbm_gitignore {
3939

4040
/* ── Pattern matching engine ─────────────────────────────────────── */
4141

42+
/* Backtracking budget. `**` retries the remainder at every position, and
43+
* consecutive literal + `**` groups multiply, so cost is exponential in the
44+
* number of groups. Patterns come from a committed .gitignore / .cbmignore and
45+
* every discovered path is matched against every pattern, so a small ignore file
46+
* can make discovery take unbounded time. Cap the match steps per
47+
* (pattern, path) pair.
48+
*
49+
* Exhausting the budget reports "no match", so a pathological pattern fails to
50+
* ignore rather than ignoring the wrong files: the file gets indexed, which is
51+
* the recoverable direction. The cap is far above any real pattern — ordinary
52+
* globs finish in tens of steps. */
53+
enum { GI_MATCH_MAX_STEPS = 20000 };
54+
4255
/* Forward declaration for recursive calls. */
43-
static bool glob_match(const char *pat, const char *str); // NOLINT(misc-no-recursion)
56+
static bool glob_match(const char *pat, const char *str,
57+
int *budget); // NOLINT(misc-no-recursion)
4458

4559
/* Match a ** (doublestar-slash) pattern: try rest at every / boundary. */
4660
static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recursion)
47-
const char *str) {
48-
if (glob_match(pat, str)) {
61+
const char *str, int *budget) {
62+
if (glob_match(pat, str, budget)) {
4963
return true;
5064
}
5165
for (const char *s = str; *s; s++) {
52-
if (*s == '/' && glob_match(pat, s + SKIP_ONE)) {
66+
if (*s == '/' && glob_match(pat, s + SKIP_ONE, budget)) {
5367
return true;
5468
}
5569
}
@@ -58,9 +72,9 @@ static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recur
5872

5973
/* Match a ** (doublestar) followed by non-slash: try at every position. */
6074
static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursion)
61-
const char *str) {
75+
const char *str, int *budget) {
6276
for (const char *s = str;; s++) {
63-
if (glob_match(pat, s)) {
77+
if (glob_match(pat, s, budget)) {
6478
return true;
6579
}
6680
if (!*s) {
@@ -70,9 +84,10 @@ static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursi
7084
}
7185

7286
/* Match a * (single star): match any sequence not containing /. */
73-
static bool glob_match_star(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
87+
static bool glob_match_star(const char *pat, const char *str,
88+
int *budget) { // NOLINT(misc-no-recursion)
7489
for (const char *s = str;; s++) {
75-
if (glob_match(pat, s)) {
90+
if (glob_match(pat, s, budget)) {
7691
return true;
7792
}
7893
if (!*s || *s == '/') {
@@ -119,24 +134,29 @@ static bool glob_match_charclass(const char *pat, char ch, const char **pat_out)
119134
* Handles: * (non-slash), ** (any path), ? (single non-slash), [class]
120135
*/
121136
/* Handle ** at current position. Returns match result. */
122-
static bool glob_match_doublestar(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
137+
static bool glob_match_doublestar(const char *pat, const char *str,
138+
int *budget) { // NOLINT(misc-no-recursion)
123139
if (pat[GI_CHAR_IDX2] == '/') {
124-
return glob_match_doublestar_slash(pat + GI_SKIP3, str);
140+
return glob_match_doublestar_slash(pat + GI_SKIP3, str, budget);
125141
}
126142
if (pat[GI_CHAR_IDX2] == '\0') {
127143
return true;
128144
}
129-
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str);
145+
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str, budget);
130146
}
131147

132-
static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
148+
static bool glob_match(const char *pat, const char *str,
149+
int *budget) { // NOLINT(misc-no-recursion)
150+
if (--*budget <= 0) {
151+
return false; /* budget exhausted — see GI_MATCH_MAX_STEPS */
152+
}
133153
while (*pat && *str) {
134154
if (pat[0] == '*' && pat[GI_CHAR_IDX1] == '*') {
135-
return glob_match_doublestar(pat, str);
155+
return glob_match_doublestar(pat, str, budget);
136156
}
137157

138158
if (*pat == '*') {
139-
return glob_match_star(pat + SKIP_ONE, str);
159+
return glob_match_star(pat + SKIP_ONE, str, budget);
140160
}
141161

142162
if (*pat == '?') {
@@ -171,6 +191,13 @@ static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-rec
171191
return *pat == '\0' && *str == '\0';
172192
}
173193

194+
/* Match one pattern against one path with a fresh backtracking budget. Not
195+
* recursive itself — it seeds the budget and hands off to the engine. */
196+
static bool glob_match_bounded(const char *pat, const char *str) {
197+
int budget = GI_MATCH_MAX_STEPS;
198+
return glob_match(pat, str, &budget);
199+
}
200+
174201
/* ── Pattern parsing ─────────────────────────────────────────────── */
175202

176203
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) {
320347

321348
/* Match a non-rooted pattern against basename and path suffixes. */
322349
static bool match_unrooted(const char *pattern, const char *rel_path, const char *basename) {
323-
if (glob_match(pattern, basename)) {
350+
if (glob_match_bounded(pattern, basename)) {
324351
return true;
325352
}
326353
if (!strchr(rel_path, '/')) {
@@ -329,7 +356,7 @@ static bool match_unrooted(const char *pattern, const char *rel_path, const char
329356
/* Try matching at every / boundary */
330357
const char *s = rel_path;
331358
while (*s) {
332-
if (glob_match(pattern, s)) {
359+
if (glob_match_bounded(pattern, s)) {
333360
return true;
334361
}
335362
const char *next = strchr(s, '/');
@@ -359,7 +386,7 @@ int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path,
359386
continue;
360387
}
361388

362-
bool this_match = p->rooted ? glob_match(p->pattern, rel_path)
389+
bool this_match = p->rooted ? glob_match_bounded(p->pattern, rel_path)
363390
: match_unrooted(p->pattern, rel_path, basename);
364391

365392
if (this_match) {

‎src/foundation/str_util.c‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,23 @@ bool cbm_validate_shell_arg(const char *s) {
271271
return true;
272272
}
273273

274+
bool cbm_validate_shell_path_arg(const char *path) {
275+
if (!cbm_validate_shell_arg(path)) {
276+
return false;
277+
}
278+
#ifdef _WIN32
279+
/* cmd.exe expands %VAR% and, with delayed expansion, !VAR! inside the
280+
* double quotes these commands use; ^ is its escape character. None of the
281+
* three can be neutralised by quoting, so reject them outright. */
282+
for (const char *p = path; *p; p++) {
283+
if (*p == '%' || *p == '!' || *p == '^') {
284+
return false;
285+
}
286+
}
287+
#endif
288+
return true;
289+
}
290+
274291
bool cbm_validate_project_name(const char *name) {
275292
if (!name || !*name)
276293
return false;

‎src/foundation/str_util.h‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
5757
* Returns true if safe, false if the string contains shell metacharacters. */
5858
bool cbm_validate_shell_arg(const char *s);
5959

60+
/* Validate a filesystem path that will be interpolated into a shell command.
61+
* Everything cbm_validate_shell_arg rejects, plus the cmd.exe expansion
62+
* metacharacters % ! ^ on Windows: a path reaching `git -C "%s"` through
63+
* cmd.exe would otherwise get %VAR% / delayed-!VAR! expansion applied to it.
64+
*
65+
* Use this — not cbm_validate_shell_arg — for every path that crosses into a
66+
* shell command, so the three git shell-out sites cannot drift apart again.
67+
* Returns true if safe. */
68+
bool cbm_validate_shell_path_arg(const char *path);
69+
6070
/* Validate a project name is safe for file path construction.
6171
* Allows: alphanumeric, dash, underscore, dot (but not leading dot or dot-dot).
6272
* Rejects: path separators (/ \), directory traversal (..), and control chars.

0 commit comments

Comments
 (0)