|
4 | 4 | */ |
5 | 5 | #include "foundation/workspace.h" |
6 | 6 |
|
| 7 | +#include "foundation/compat.h" |
| 8 | +#include "foundation/compat_fs.h" |
| 9 | +#include "foundation/platform.h" |
| 10 | + |
| 11 | +#include <stdlib.h> |
| 12 | + |
| 13 | +#include <stdio.h> |
7 | 14 | #include <string.h> |
8 | 15 |
|
9 | 16 | enum { |
@@ -72,6 +79,14 @@ int cbm_workspace_path_depth(const char *canonical_path) { |
72 | 79 | } |
73 | 80 | int depth = 0; |
74 | 81 | const char *p = canonical_path + prefix; |
| 82 | + /* macOS firmlinks /etc, /tmp and /var under /private, so canonicalizing any |
| 83 | + * of them adds a component: "/etc" resolves to "/private/etc" and would count |
| 84 | + * as two deep, sailing past a minimum of two — exactly the path the reports |
| 85 | + * demonstrate. Treat a leading "private" as transparent so depth reflects the |
| 86 | + * tree the user named. "/private/tmp/proj" still counts two and is allowed. */ |
| 87 | + if (strncmp(p, "private", 7) == 0 && (ws_is_sep(p[7]) || p[7] == '\0')) { |
| 88 | + p += 7; |
| 89 | + } |
75 | 90 | while (*p) { |
76 | 91 | while (ws_is_sep(*p)) { |
77 | 92 | p++; |
@@ -222,11 +237,19 @@ cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const c |
222 | 237 | return CBM_WS_DENY_TOO_SHALLOW; |
223 | 238 | } |
224 | 239 |
|
225 | | - /* Indexing a tree that holds the cache would pull every other project's |
226 | | - * graph database into this project's index. Never overridable. */ |
227 | | - if (cache_dir && cache_dir[0] && ws_is_ancestor_or_equal(canonical_path, cache_dir)) { |
228 | | - return CBM_WS_DENY_ABSOLUTE; |
229 | | - } |
| 240 | + /* No rule here for "this root contains the cache directory". |
| 241 | + * |
| 242 | + * An earlier draft refused such roots outright, on the theory that indexing |
| 243 | + * them would absorb every other project's graph database. Two things make |
| 244 | + * that wrong. The indexer only parses recognised source files, and a graph |
| 245 | + * database is binary SQLite it would never extract; and refusing an entire |
| 246 | + * root is the wrong remedy even where the concern holds — the right one is to |
| 247 | + * not walk the cache. Excluding the cache from discovery is tracked |
| 248 | + * separately; classifying the root is not the place for it. |
| 249 | + * |
| 250 | + * cache_dir stays in the signature because the exclusion work will need it |
| 251 | + * and because callers already have it to hand. */ |
| 252 | + (void)cache_dir; |
230 | 253 |
|
231 | 254 | if (ws_any_component_matches(canonical_path, WS_CREDENTIAL_NAMES, |
232 | 255 | sizeof(WS_CREDENTIAL_NAMES) / sizeof(WS_CREDENTIAL_NAMES[0]), |
@@ -263,3 +286,242 @@ const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict) { |
263 | 286 | bool cbm_workspace_verdict_is_overridable(cbm_ws_verdict_t verdict) { |
264 | 287 | return verdict == CBM_WS_DENY_SENSITIVE; |
265 | 288 | } |
| 289 | + |
| 290 | +/* ── Grant store ──────────────────────────────────────────────────────────── */ |
| 291 | + |
| 292 | +enum { WS_LINE_MAX = 4096 }; |
| 293 | + |
| 294 | +/* A line may carry a leading '!' meaning "a person explicitly approved this |
| 295 | + * sensitive root". The marker is stored rather than inferred so re-classifying |
| 296 | + * later (a new credential name is added to the list, say) cannot silently |
| 297 | + * upgrade an ordinary grant into a sensitive one. */ |
| 298 | +static const char WS_SENSITIVE_MARK = '!'; |
| 299 | + |
| 300 | +bool cbm_workspace_grant_path(const char *cache_dir, char *out, size_t out_sz) { |
| 301 | + if (!cache_dir || !cache_dir[0] || !out || out_sz == 0) { |
| 302 | + return false; |
| 303 | + } |
| 304 | + int n = snprintf(out, out_sz, "%s/allowed_roots", cache_dir); |
| 305 | + return n > 0 && (size_t)n < out_sz; |
| 306 | +} |
| 307 | + |
| 308 | +/* Walk the grant file, invoking visit for each entry. visit returns true to stop. |
| 309 | + * Returns the number of entries seen. */ |
| 310 | +static int ws_grant_walk(const char *cache_dir, |
| 311 | + bool (*visit)(const char *root, bool sensitive, void *ctx), void *ctx) { |
| 312 | + char store[WS_LINE_MAX]; |
| 313 | + if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) { |
| 314 | + return 0; |
| 315 | + } |
| 316 | + FILE *f = cbm_fopen(store, "r"); |
| 317 | + if (!f) { |
| 318 | + return 0; |
| 319 | + } |
| 320 | + char line[WS_LINE_MAX]; |
| 321 | + int seen = 0; |
| 322 | + while (fgets(line, (int)sizeof(line), f)) { |
| 323 | + size_t len = strlen(line); |
| 324 | + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { |
| 325 | + line[--len] = '\0'; |
| 326 | + } |
| 327 | + if (len == 0 || line[0] == '#') { |
| 328 | + continue; |
| 329 | + } |
| 330 | + bool sensitive = line[0] == WS_SENSITIVE_MARK; |
| 331 | + const char *root = sensitive ? line + 1 : line; |
| 332 | + if (!root[0]) { |
| 333 | + continue; |
| 334 | + } |
| 335 | + seen++; |
| 336 | + if (visit && visit(root, sensitive, ctx)) { |
| 337 | + break; |
| 338 | + } |
| 339 | + } |
| 340 | + (void)fclose(f); |
| 341 | + return seen; |
| 342 | +} |
| 343 | + |
| 344 | +typedef struct { |
| 345 | + const char *candidate; |
| 346 | + bool contained; /* candidate is at or below some granted root */ |
| 347 | + bool exact_sensitive; /* candidate is itself a grant marked sensitive */ |
| 348 | +} ws_match_t; |
| 349 | + |
| 350 | +static bool ws_match_visit(const char *root, bool sensitive, void *ctx) { |
| 351 | + ws_match_t *m = ctx; |
| 352 | + if (cbm_path_within_root(root, m->candidate)) { |
| 353 | + m->contained = true; |
| 354 | + if (sensitive && ws_paths_equal(root, m->candidate)) { |
| 355 | + m->exact_sensitive = true; |
| 356 | + } |
| 357 | + } |
| 358 | + return false; /* visit every entry: a later one may be the exact match */ |
| 359 | +} |
| 360 | + |
| 361 | +typedef struct { |
| 362 | + char *out; |
| 363 | + size_t out_sz; |
| 364 | + size_t used; |
| 365 | +} ws_list_t; |
| 366 | + |
| 367 | +static bool ws_list_visit(const char *root, bool sensitive, void *ctx) { |
| 368 | + ws_list_t *l = ctx; |
| 369 | + int n = snprintf(l->out + l->used, l->out_sz - l->used, "%s%s\n", |
| 370 | + sensitive ? "(approved) " : "", root); |
| 371 | + if (n > 0 && (size_t)n < l->out_sz - l->used) { |
| 372 | + l->used += (size_t)n; |
| 373 | + } |
| 374 | + return false; |
| 375 | +} |
| 376 | + |
| 377 | +bool cbm_workspace_grant_list(const char *cache_dir, char *out, size_t out_sz) { |
| 378 | + if (!out || out_sz == 0) { |
| 379 | + return false; |
| 380 | + } |
| 381 | + out[0] = '\0'; |
| 382 | + ws_list_t l = {out, out_sz, 0}; |
| 383 | + return ws_grant_walk(cache_dir, ws_list_visit, &l) > 0; |
| 384 | +} |
| 385 | + |
| 386 | +bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir, |
| 387 | + const char *canonical_path, bool approve_sensitive, char *err, |
| 388 | + size_t err_sz) { |
| 389 | + if (err && err_sz) { |
| 390 | + err[0] = '\0'; |
| 391 | + } |
| 392 | + if (!cache_dir || !cache_dir[0] || !canonical_path || !canonical_path[0]) { |
| 393 | + if (err) { |
| 394 | + snprintf(err, err_sz, "no path given"); |
| 395 | + } |
| 396 | + return false; |
| 397 | + } |
| 398 | + |
| 399 | + cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir); |
| 400 | + if (verdict != CBM_WS_ALLOW) { |
| 401 | + if (!cbm_workspace_verdict_is_overridable(verdict)) { |
| 402 | + if (err) { |
| 403 | + snprintf(err, err_sz, "%s", cbm_workspace_verdict_reason(verdict)); |
| 404 | + } |
| 405 | + return false; |
| 406 | + } |
| 407 | + if (!approve_sensitive) { |
| 408 | + /* Refuse by default and name the flag, rather than asking a question |
| 409 | + * whose answer we cannot authenticate. */ |
| 410 | + if (err) { |
| 411 | + snprintf(err, err_sz, "%s; re-run with --approve-sensitive if that is intended", |
| 412 | + cbm_workspace_verdict_reason(verdict)); |
| 413 | + } |
| 414 | + return false; |
| 415 | + } |
| 416 | + } |
| 417 | + |
| 418 | + /* Already present is success, not a duplicate error. */ |
| 419 | + ws_match_t existing = {canonical_path, false, false}; |
| 420 | + (void)ws_grant_walk(cache_dir, ws_match_visit, &existing); |
| 421 | + if (existing.contained) { |
| 422 | + return true; |
| 423 | + } |
| 424 | + |
| 425 | + char store[WS_LINE_MAX]; |
| 426 | + if (!cbm_workspace_grant_path(cache_dir, store, sizeof(store))) { |
| 427 | + if (err) { |
| 428 | + snprintf(err, err_sz, "cache path too long"); |
| 429 | + } |
| 430 | + return false; |
| 431 | + } |
| 432 | + FILE *f = cbm_fopen(store, "a"); |
| 433 | + if (!f) { |
| 434 | + if (err) { |
| 435 | + snprintf(err, err_sz, "cannot write %s", store); |
| 436 | + } |
| 437 | + return false; |
| 438 | + } |
| 439 | + bool sensitive = verdict == CBM_WS_DENY_SENSITIVE; |
| 440 | + (void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path); |
| 441 | + bool ok = fclose(f) == 0; |
| 442 | + if (!ok && err) { |
| 443 | + snprintf(err, err_sz, "cannot write %s", store); |
| 444 | + } |
| 445 | + return ok; |
| 446 | +} |
| 447 | + |
| 448 | +bool cbm_workspace_root_allowed(const char *canonical_path, const char *home_dir, |
| 449 | + const char *cache_dir, const char *configured_root, char *err, |
| 450 | + size_t err_sz) { |
| 451 | + if (err && err_sz) { |
| 452 | + err[0] = '\0'; |
| 453 | + } |
| 454 | + if (!canonical_path || !canonical_path[0]) { |
| 455 | + if (err) { |
| 456 | + snprintf(err, err_sz, "no repository path given"); |
| 457 | + } |
| 458 | + return false; |
| 459 | + } |
| 460 | + |
| 461 | + ws_match_t match = {canonical_path, false, false}; |
| 462 | + int grants = ws_grant_walk(cache_dir, ws_match_visit, &match); |
| 463 | + |
| 464 | + /* A configured root behaves as an additional grant so existing |
| 465 | + * CBM_ALLOWED_ROOT deployments keep working unchanged. */ |
| 466 | + bool configured = configured_root && configured_root[0]; |
| 467 | + bool configured_contains = configured && cbm_path_within_root(configured_root, canonical_path); |
| 468 | + |
| 469 | + /* Containment first when a boundary has actually been declared. A path |
| 470 | + * outside a configured root is best explained as exactly that, and the |
| 471 | + * wording is what callers and the shell contracts already match on. Breadth |
| 472 | + * then applies to paths that ARE inside the declared root but are still too |
| 473 | + * broad to index as one unit. */ |
| 474 | + bool boundary_declared = grants > 0 || configured; |
| 475 | + if (boundary_declared && !match.contained && !configured_contains) { |
| 476 | + if (err) { |
| 477 | + /* Keep the "outside the allowed root" wording: changing it broke an |
| 478 | + * assertion whose early return then leaked CBM_ALLOWED_ROOT into |
| 479 | + * every later test in that suite. Guidance is appended, not |
| 480 | + * substituted. */ |
| 481 | + snprintf(err, err_sz, |
| 482 | + "%s is outside the allowed root. To allow it, run: " |
| 483 | + "codebase-memory-mcp allow-root %s", |
| 484 | + canonical_path, canonical_path); |
| 485 | + } |
| 486 | + return false; |
| 487 | + } |
| 488 | + |
| 489 | + cbm_ws_verdict_t verdict = cbm_workspace_classify_root(canonical_path, home_dir, cache_dir); |
| 490 | + if (verdict == CBM_WS_ALLOW) { |
| 491 | + return true; |
| 492 | + } |
| 493 | + /* An explicit human approval recorded for exactly this path is the only thing |
| 494 | + * that lifts a sensitive refusal. Absolute and shallow refusals cannot be |
| 495 | + * lifted at all. */ |
| 496 | + if (verdict == CBM_WS_DENY_SENSITIVE && match.exact_sensitive) { |
| 497 | + return true; |
| 498 | + } |
| 499 | + if (err) { |
| 500 | + if (cbm_workspace_verdict_is_overridable(verdict)) { |
| 501 | + snprintf(err, err_sz, |
| 502 | + "%s: %s. To index it anyway, run: codebase-memory-mcp allow-root " |
| 503 | + "--approve-sensitive %s", |
| 504 | + canonical_path, cbm_workspace_verdict_reason(verdict), canonical_path); |
| 505 | + } else { |
| 506 | + snprintf(err, err_sz, "%s: %s", canonical_path, cbm_workspace_verdict_reason(verdict)); |
| 507 | + } |
| 508 | + } |
| 509 | + return false; |
| 510 | +} |
| 511 | + |
| 512 | +/* ── Environment helpers ──────────────────────────────────────────────────── */ |
| 513 | + |
| 514 | +/* Callers should not each re-derive these; a caller that resolved the home |
| 515 | + * directory differently would classify the same path differently. */ |
| 516 | +const char *cbm_workspace_home_dir(void) { |
| 517 | + const char *home = getenv("HOME"); |
| 518 | + if (home && home[0]) { |
| 519 | + return home; |
| 520 | + } |
| 521 | + home = getenv("USERPROFILE"); |
| 522 | + return (home && home[0]) ? home : NULL; |
| 523 | +} |
| 524 | + |
| 525 | +const char *cbm_workspace_cache_dir(void) { |
| 526 | + return cbm_resolve_cache_dir(); |
| 527 | +} |
0 commit comments