@@ -4670,6 +4670,108 @@ TEST(cli_agent_reinstall_preserves_foreign_policy_entries) {
46704670 PASS();
46714671}
46724672
4673+ #ifndef _WIN32
4674+ /* Regression for #1387: installing over an existing setup whose hook scripts
4675+ * are not byte-owned (manual install with a custom binary location, or a
4676+ * user-modified reminder) must NOT remove the existing, working hook entries
4677+ * from settings.json. The refused script rewrite is reported as an error and
4678+ * both the scripts and the entries survive byte-identically. */
4679+ TEST(cli_install_preserves_hook_entries_when_scripts_unowned_issue1387) {
4680+ char tmpdir[256];
4681+ snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hooks-preserve-XXXXXX");
4682+ if (!cbm_mkdtemp(tmpdir))
4683+ FAIL("cbm_mkdtemp failed");
4684+
4685+ char hooks_dir[512];
4686+ snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir);
4687+ if (!cbm_mkdir_p(hooks_dir, 0755))
4688+ FAIL("mkdir hooks_dir failed");
4689+
4690+ /* Gate script written by a manual install: BIN outside the managed target,
4691+ * so its bytes match no current or released installer-owned shape. */
4692+ static const char unowned_gate[] =
4693+ "#!/usr/bin/env bash\n"
4694+ "# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n"
4695+ "BIN=\"/opt/tools/cbm/codebase-memory-mcp\"\n"
4696+ "[ -x \"$BIN\" ] || exit 0\n"
4697+ "\"$BIN\" hook-augment 2>/dev/null\n"
4698+ "exit 0\n";
4699+ char gate_path[768];
4700+ snprintf(gate_path, sizeof(gate_path), "%s/cbm-code-discovery-gate", hooks_dir);
4701+ write_test_file(gate_path, unowned_gate);
4702+
4703+ /* Session reminder carrying a user-added line. */
4704+ static const char unowned_session[] =
4705+ "#!/usr/bin/env bash\n"
4706+ "# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n"
4707+ "echo my-extra-team-reminder\n";
4708+ char session_path[768];
4709+ snprintf(session_path, sizeof(session_path), "%s/cbm-session-reminder", hooks_dir);
4710+ write_test_file(session_path, unowned_session);
4711+
4712+ static const char settings_before[] =
4713+ "{\n"
4714+ " \"hooks\": {\n"
4715+ " \"PreToolUse\": [\n"
4716+ " {\"matcher\": \"Grep|Glob\", \"hooks\": [{\"type\": \"command\", "
4717+ "\"command\": \"~/.claude/hooks/cbm-code-discovery-gate\", \"timeout\": 5}]},\n"
4718+ " {\"matcher\": \"Bash\", \"hooks\": [{\"type\": \"command\", "
4719+ "\"command\": \"/usr/local/bin/my-own-guard\"}]}\n"
4720+ " ],\n"
4721+ " \"SessionStart\": [\n"
4722+ " {\"matcher\": \"startup\", \"hooks\": [{\"type\": \"command\", "
4723+ "\"command\": \"~/.claude/hooks/cbm-session-reminder\"}]},\n"
4724+ " {\"matcher\": \"resume\", \"hooks\": [{\"type\": \"command\", "
4725+ "\"command\": \"~/.claude/hooks/cbm-session-reminder\"}]},\n"
4726+ " {\"matcher\": \"clear\", \"hooks\": [{\"type\": \"command\", "
4727+ "\"command\": \"~/.claude/hooks/cbm-session-reminder\"}]},\n"
4728+ " {\"matcher\": \"compact\", \"hooks\": [{\"type\": \"command\", "
4729+ "\"command\": \"~/.claude/hooks/cbm-session-reminder\"}]}\n"
4730+ " ]\n"
4731+ " }\n"
4732+ "}\n";
4733+ char settings_path[768];
4734+ snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", tmpdir);
4735+ write_test_file(settings_path, settings_before);
4736+
4737+ const char *const env_names[] = {"HOME", "PATH", "CLAUDE_CONFIG_DIR"};
4738+ char *saved[sizeof(env_names) / sizeof(env_names[0])];
4739+ for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
4740+ saved[i] = save_test_env(env_names[i]);
4741+ cbm_unsetenv(env_names[i]);
4742+ }
4743+ cbm_setenv("HOME", tmpdir, 1);
4744+ cbm_setenv("PATH", tmpdir, 1);
4745+
4746+ int install_rc =
4747+ cbm_install_agent_configs(tmpdir, "/opt/other/codebase-memory-mcp", false, false);
4748+
4749+ char *settings = read_test_file_alloc(settings_path);
4750+ char *gate = read_test_file_alloc(gate_path);
4751+ char *session = read_test_file_alloc(session_path);
4752+ bool preserved =
4753+ install_rc != 0 /* the refused rewrites are reported, not silent */
4754+ && settings && strstr(settings, "~/.claude/hooks/cbm-code-discovery-gate") &&
4755+ strstr(settings, "Grep|Glob") && strstr(settings, "/usr/local/bin/my-own-guard") &&
4756+ strstr(settings, "\"startup\"") && strstr(settings, "\"resume\"") &&
4757+ strstr(settings, "\"clear\"") && strstr(settings, "\"compact\"") &&
4758+ strstr(settings, "~/.claude/hooks/cbm-session-reminder") && gate &&
4759+ strcmp(gate, unowned_gate) == 0 && session && strcmp(session, unowned_session) == 0;
4760+ free(settings);
4761+ free(gate);
4762+ free(session);
4763+
4764+ for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
4765+ restore_test_env(env_names[i], saved[i]);
4766+ }
4767+ test_rmdir_r(tmpdir);
4768+ if (!preserved)
4769+ FAIL("install must preserve existing hook entries and scripts when the on-disk "
4770+ "scripts are unowned (#1387)");
4771+ PASS();
4772+ }
4773+ #endif
4774+
46734775TEST(cli_existing_agents_install_durable_child_context) {
46744776 char tmpdir[256];
46754777 snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-durable-agents-XXXXXX");
@@ -11936,6 +12038,9 @@ SUITE(cli) {
1193612038 RUN_TEST(cli_new_agent_install_plans_use_documented_paths);
1193712039 RUN_TEST(cli_new_agent_configs_use_documented_schemas);
1193812040 RUN_TEST(cli_agent_reinstall_preserves_foreign_policy_entries);
12041+ #ifndef _WIN32
12042+ RUN_TEST(cli_install_preserves_hook_entries_when_scripts_unowned_issue1387);
12043+ #endif
1193912044 RUN_TEST(cli_existing_agents_install_durable_child_context);
1194012045 RUN_TEST(cli_durable_profiles_follow_current_vendor_paths);
1194112046 RUN_TEST(cli_cline_data_dir_only_redirects_data_state);
0 commit comments