diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index bdfdae8b..601c8225 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -637,13 +637,19 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { srv->store = NULL; } - /* Open project's .db file */ + /* Open project's .db file — query-only open (no SQLITE_OPEN_CREATE) to + * prevent ghost .db file creation for unknown/unindexed projects. */ char path[1024]; project_db_path(project, path, sizeof(path)); - srv->store = cbm_store_open_path(path); - srv->owns_store = true; - free(srv->current_project); - srv->current_project = heap_strdup(project); + srv->store = cbm_store_open_path_query(path); + if (srv->store) { + /* Only update ownership and cached project name on successful open. + * When the file is absent, store is NULL and current_project retains + * its previous value so the next call correctly retries the open. */ + srv->owns_store = true; + free(srv->current_project); + srv->current_project = heap_strdup(project); + } return srv->store; } @@ -745,11 +751,38 @@ static char *handle_list_projects(cbm_mcp_server_t *srv, const char *args) { return result; } +/* verify_project_indexed — returns a heap-allocated error JSON string when the + * named project has not been indexed yet, or NULL when the project exists. + * resolve_store uses cbm_store_open_path_query (no SQLITE_OPEN_CREATE), so + * store is NULL for missing .db files (REQUIRE_STORE fires first). This + * function catches the remaining case: a .db file exists but has no indexed + * nodes (e.g., an empty or half-initialised project). + * Callers that receive a non-NULL return value must free(project) themselves + * before returning the error string. */ +static char *verify_project_indexed(cbm_store_t *store, const char *project) { + if (!project) { + return NULL; /* default project — always exists */ + } + cbm_project_t proj_check = {0}; + if (cbm_store_get_project(store, project, &proj_check) != CBM_STORE_OK) { + return cbm_mcp_text_result( + "{\"error\":\"project not indexed — run index_repository first\"}", true); + } + cbm_project_free_fields(&proj_check); + return NULL; +} + static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) { char *project = cbm_mcp_get_string_arg(args, "project"); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(project); + return not_indexed; + } + cbm_schema_info_t schema = {0}; cbm_store_get_schema(store, project, &schema); @@ -807,6 +840,13 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { char *project = cbm_mcp_get_string_arg(args, "project"); cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); + + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(project); + return not_indexed; + } + char *label = cbm_mcp_get_string_arg(args, "label"); char *name_pattern = cbm_mcp_get_string_arg(args, "name_pattern"); char *file_pattern = cbm_mcp_get_string_arg(args, "file_pattern"); @@ -882,6 +922,13 @@ static char *handle_query_graph(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result("{\"error\":\"no project loaded\"}", true); } + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(project); + free(query); + return not_indexed; + } + cbm_cypher_result_t result = {0}; int rc = cbm_cypher_execute(store, query, project, max_rows, &result); @@ -1012,6 +1059,12 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { cbm_store_t *store = resolve_store(srv, project); REQUIRE_STORE(store, project); + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(project); + return not_indexed; + } + cbm_schema_info_t schema = {0}; cbm_store_get_schema(store, project, &schema); @@ -1085,6 +1138,15 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { free(direction); return cbm_mcp_text_result("{\"error\":\"no project loaded\"}", true); } + + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(func_name); + free(project); + free(direction); + return not_indexed; + } + if (!direction) { direction = heap_strdup("both"); } @@ -1572,7 +1634,14 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { if (!store) { free(qn); free(project); - return cbm_mcp_text_result("no project loaded — run index_repository first", true); + return cbm_mcp_text_result("{\"error\":\"no project loaded\"}", true); + } + + char *not_indexed = verify_project_indexed(store, project); + if (not_indexed) { + free(qn); + free(project); + return not_indexed; } /* Default to current project (same as all other tools) */ diff --git a/src/store/store.c b/src/store/store.c index 4b00d4e7..37a2de77 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -365,6 +365,45 @@ cbm_store_t *cbm_store_open_path(const char *db_path) { return store_open_internal(db_path, false); } +cbm_store_t *cbm_store_open_path_query(const char *db_path) { + if (!db_path) { + return NULL; + } + + cbm_store_t *s = calloc(1, sizeof(cbm_store_t)); + if (!s) { + return NULL; + } + + /* Open read-write but do NOT create — returns SQLITE_CANTOPEN if absent. */ + int rc = sqlite3_open_v2(db_path, &s->db, SQLITE_OPEN_READWRITE, NULL); + if (rc != SQLITE_OK) { + /* File does not exist or cannot be opened — return NULL without creating. */ + free(s); + return NULL; + } + + s->db_path = heap_strdup(db_path); + + /* Security: block ATTACH/DETACH to prevent file creation via SQL injection. */ + sqlite3_set_authorizer(s->db, store_authorizer, NULL); + + /* Register REGEXP functions. */ + sqlite3_create_function(s->db, "regexp", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, + sqlite_regexp, NULL, NULL); + sqlite3_create_function(s->db, "iregexp", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, + sqlite_iregexp, NULL, NULL); + + if (configure_pragmas(s, false) != CBM_STORE_OK) { + sqlite3_close(s->db); + free((void *)s->db_path); + free(s); + return NULL; + } + + return s; +} + cbm_store_t *cbm_store_open(const char *project) { if (!project) { return NULL; diff --git a/src/store/store.h b/src/store/store.h index 0bf385a8..8cfd4865 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -190,6 +190,10 @@ cbm_store_t *cbm_store_open_memory(void); /* Open a file-backed database at the given path. Creates if needed. */ cbm_store_t *cbm_store_open_path(const char *db_path); +/* Open an existing file-backed database for querying only (no SQLITE_OPEN_CREATE). + * Returns NULL if the file does not exist — never creates a new .db file. */ +cbm_store_t *cbm_store_open_path_query(const char *db_path); + /* Open database for a named project in the default cache dir. */ cbm_store_t *cbm_store_open(const char *project); diff --git a/tests/smoke_guard.sh b/tests/smoke_guard.sh new file mode 100755 index 00000000..2fbeeb93 --- /dev/null +++ b/tests/smoke_guard.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# smoke_guard.sh — Smoke test for guard and ghost-file invariants. +# +# Verifies two properties across all 5 guarded query handlers: +# 1. Each handler returns a guard error for unknown/unindexed projects. +# 2. No ghost .db file is created for the unknown project name. +# +# Usage: bash tests/smoke_guard.sh +# Exit 0 on success, non-zero on failure. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BINARY="$PROJECT_ROOT/build/c/codebase-memory-mcp" +FAKE_PROJECT="nonexistent_smoke_test_xyz" +CACHE_DIR="${HOME}/.cache/codebase-memory-mcp" +GHOST_FILE="$CACHE_DIR/${FAKE_PROJECT}.db" +FAILURES=0 + +# ── Step 1: Build ───────────────────────────────────────────────── +echo "[smoke_guard] Building project..." +make -f "$PROJECT_ROOT/Makefile.cbm" cbm -C "$PROJECT_ROOT" --quiet 2>&1 +if [ ! -x "$BINARY" ]; then + echo "[smoke_guard] FAIL: binary not found at $BINARY after build" >&2 + exit 1 +fi +echo "[smoke_guard] Build OK: $BINARY" + +# ── Step 2: Pre-clean ghost file if somehow present ─────────────── +if [ -f "$GHOST_FILE" ]; then + echo "[smoke_guard] WARNING: ghost file already exists before test; removing: $GHOST_FILE" + rm -f "$GHOST_FILE" +fi + +# ── Helper: assert guard error and no ghost file ────────────────── +check_handler() { + local handler="$1" + local args="$2" + echo "[smoke_guard] Invoking $handler with project='$FAKE_PROJECT'..." + local response + response="$("$BINARY" cli "$handler" "$args" 2>/dev/null)" + echo "[smoke_guard] Response: $response" + + # For a missing .db file, cbm_store_open_path_query returns NULL so + # REQUIRE_STORE fires ("no project loaded"). For an empty .db, + # verify_project_indexed fires ("project not indexed"). Both are valid. + if ! echo "$response" | grep -qE "no project loaded|not indexed"; then + echo "[smoke_guard] FAIL [$handler]: response does not contain guard error" >&2 + echo "[smoke_guard] Got: $response" >&2 + FAILURES=$((FAILURES + 1)) + else + echo "[smoke_guard] PASS [$handler]: guard error present" + fi + + if [ -f "$GHOST_FILE" ]; then + echo "[smoke_guard] FAIL [$handler]: ghost file created at $GHOST_FILE" >&2 + rm -f "$GHOST_FILE" + FAILURES=$((FAILURES + 1)) + else + echo "[smoke_guard] PASS [$handler]: no ghost .db file" + fi +} + +# ── Step 3: Test all 5 guarded handlers ─────────────────────────── +check_handler "search_graph" "{\"project\":\"$FAKE_PROJECT\",\"name_pattern\":\".*\"}" +check_handler "query_graph" "{\"project\":\"$FAKE_PROJECT\",\"query\":\"MATCH (n) RETURN n LIMIT 1\"}" +check_handler "get_graph_schema" "{\"project\":\"$FAKE_PROJECT\"}" +check_handler "trace_call_path" "{\"project\":\"$FAKE_PROJECT\",\"function_name\":\"main\",\"direction\":\"both\",\"depth\":1}" +check_handler "get_code_snippet" "{\"project\":\"$FAKE_PROJECT\",\"qualified_name\":\"main\"}" + +# ── Step 4: Final result ────────────────────────────────────────── +if [ "$FAILURES" -gt 0 ]; then + echo "[smoke_guard] FAILED: $FAILURES check(s) failed." >&2 + exit 1 +fi + +echo "[smoke_guard] All checks passed (5 handlers, guard + ghost-file invariants)." +exit 0