diff --git a/src/cli/cli.h b/src/cli/cli.h index 9efe6789..2c3336c6 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -287,6 +287,11 @@ int cbm_cmd_config(int argc, char **argv); * path returns 0 with no stdout output. */ int cbm_cmd_hook_augment(void); +/* True for an absolute path the augmenter can walk up: POSIX "/..." or Windows + * drive "X:/..." (callers normalize '\\' to '/' first). Exposed for tests — + * regression coverage for the Windows drive-letter no-op (#618). */ +bool cbm_hook_path_is_abs(const char *path); + /* Build the agent.install.plan.v1 install receipt for (issue #388): * a machine-readable JSON list of the config/instruction/hook files `install` * would write, produced WITHOUT mutating anything. Returns a heap JSON string diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 026516d0..6d8bdde1 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -243,6 +243,19 @@ static void ha_emit(const char *text) { yyjson_mut_doc_free(doc); } +/* True for an absolute path we can walk up: POSIX "/..." or Windows drive + * "X:/..." (callers normalize '\\' to '/' first). Declared in cli.h so the + * Windows drive-letter handling (#618) has direct regression coverage. */ +bool cbm_hook_path_is_abs(const char *d) { + if (!d || !d[0]) { + return false; + } + if (d[0] == '/') { + return true; + } + return isalpha((unsigned char)d[0]) && d[1] == ':' && (d[2] == '/' || d[2] == '\0'); +} + /* Walk up from `start`, deriving a project name at each level and querying * search_graph until an indexed project is found (or the walk is exhausted). * Stops at the first non-error result: a valid project with zero hits is a @@ -251,7 +264,7 @@ static char *ha_resolve_and_query(cbm_mcp_server_t *srv, const char *start, cons char dir[4096]; snprintf(dir, sizeof(dir), "%s", start); - for (int level = 0; level < HA_MAX_WALKUP && dir[0] == '/'; level++) { + for (int level = 0; level < HA_MAX_WALKUP && cbm_hook_path_is_abs(dir); level++) { char *project = cbm_project_name_from_path(dir); if (project) { char *args = ha_build_args(project, token); @@ -275,7 +288,10 @@ static char *ha_resolve_and_query(cbm_mcp_server_t *srv, const char *start, cons /* Not indexed at this level — climb to the parent. */ char *slash = strrchr(dir, '/'); if (!slash || slash == dir) { - break; + break; /* POSIX root "/" */ + } + if (slash == dir + 2 && dir[1] == ':') { + break; /* Windows drive root "X:/" — don't strip to "X:" */ } *slash = '\0'; } @@ -313,9 +329,9 @@ int cbm_cmd_hook_augment(void) { } const char *cwd = ha_obj_str(root, "cwd"); -#ifndef _WIN32 char cwdbuf[4096]; - if (!cwd || cwd[0] != '/') { +#ifndef _WIN32 + if (!cwd || !cbm_hook_path_is_abs(cwd)) { if (!getcwd(cwdbuf, sizeof(cwdbuf))) { yyjson_doc_free(doc); free(input); @@ -324,10 +340,20 @@ int cbm_cmd_hook_augment(void) { cwd = cwdbuf; } #else - /* Windows: Claude Code passes cwd in the hook payload. The walk-up loop - * below requires POSIX-style absolute paths ('/'-prefixed), so without a - * usable cwd there is nothing to augment — fail open cleanly. */ - if (!cwd || cwd[0] != '/') { + /* Windows: Claude Code passes an absolute drive-letter cwd in the hook + * payload (e.g. C:\repo). Normalize '\\' -> '/' and require an absolute + * path; the walk-up loop handles POSIX and "X:/..." roots alike. Without + * a usable cwd there is nothing to augment — fail open cleanly. */ + if (cwd) { + snprintf(cwdbuf, sizeof(cwdbuf), "%s", cwd); + for (char *p = cwdbuf; *p; p++) { + if (*p == '\\') { + *p = '/'; + } + } + cwd = cwdbuf; + } + if (!cwd || !cbm_hook_path_is_abs(cwd)) { yyjson_doc_free(doc); free(input); return 0; diff --git a/tests/test_cli.c b/tests/test_cli.c index 0b78537c..0fcc4934 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -2215,6 +2215,26 @@ TEST(cli_hook_gate_script_no_predictable_tmp_issue384) { PASS(); } +/* issue #618: hook-augment was a structural no-op on Windows because its path + * guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter + * cwd (C:/repo) was rejected before any search_graph query. The predicate must + * accept POSIX and Windows drive roots alike (callers normalize '\\' to '/'). */ +TEST(cli_hook_augment_path_is_abs) { + /* POSIX absolute (unchanged behavior) */ + ASSERT(cbm_hook_path_is_abs("/home/u/proj")); + /* Windows drive roots — the #618 regression */ + ASSERT(cbm_hook_path_is_abs("C:/Users/me/proj")); + ASSERT(cbm_hook_path_is_abs("C:/")); + ASSERT(cbm_hook_path_is_abs("C:")); + ASSERT(cbm_hook_path_is_abs("d:/lowercase/drive")); + /* Not absolute → augmenter no-ops cleanly */ + ASSERT(!cbm_hook_path_is_abs("relative/path")); + ASSERT(!cbm_hook_path_is_abs("proj")); + ASSERT(!cbm_hook_path_is_abs("")); + ASSERT(!cbm_hook_path_is_abs(NULL)); + PASS(); +} + TEST(cli_upsert_claude_hook_existing) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-XXXXXX"); @@ -2784,6 +2804,7 @@ SUITE(cli) { /* Claude Code hooks (5 tests — group D) */ RUN_TEST(cli_hook_gate_script_no_predictable_tmp_issue384); + RUN_TEST(cli_hook_augment_path_is_abs); RUN_TEST(cli_upsert_claude_hook_fresh); RUN_TEST(cli_upsert_claude_hook_existing); RUN_TEST(cli_upsert_claude_hook_replace);