Skip to content

Commit 8289897

Browse files
committed
feat(hook-augment): augment Bash tool searches via graph
Bash-based searches (rg, grep, ag, ack, ugrep, git grep) bypass hook-augment because the PreToolUse matcher only covers Grep/Glob. Detection lives in hook_augment itself, reusing the existing search_graph path without duplicating the resolver. - ha_tokenize: minimal shell tokenizer (quote/backslash handling, no evaluation) - ha_parse_bash_search_pattern: strips VAR=val prefixes and wrappers (env, nice, time, command, rtk, tokf run), identifies the binary, walks flags, bails on -f/--file and multiple -e, handles rtk grep -l <N> as value-taking - ha_tool_event_supported: Bash added alongside Grep/Glob for PreToolUse in HA_DIALECT_EVENT - ha_process: Bash branch routes tool_input.command through the extractor before the shared ha_extract_token + search_graph path Binaries covered: grep/egrep/fgrep, rg, ag, ack, ugrep/ug, git grep Wrappers covered: env, nice, time, command, rtk, tokf run Closes DeusData#1082 Signed-off-by: Jake Abendroth <contact@jakea.net>
1 parent 72bcc34 commit 8289897

3 files changed

Lines changed: 267 additions & 2 deletions

File tree

‎src/cli/cli.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ bool cbm_hook_augment_invocation_supported_for_testing(const char *dialect,
220220
bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
221221
bool case_insensitive);
222222
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event);
223+
bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz);
223224
bool cbm_mcp_command_path_probe_safe_for_testing(const char *command, bool windows);
224225
void cbm_set_mcp_command_path_probe_counter_for_testing(int *counter);
225226
int cbm_install_editor_mcp_with_previous_for_testing(const char *binary_path,

‎src/cli/hook_augment.c‎

Lines changed: 216 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,204 @@ static bool ha_dialect_event_supported(ha_lifecycle_dialect_t dialect, const cha
10151015
return ha_lifecycle_event_supported(event);
10161016
}
10171017

1018+
/* ── Bash search-command pattern extractor ────────────────────────────────
1019+
* Tokenises and walks a Bash tool command to extract a search pattern for
1020+
* graph augmentation. Returns true and fills out when one clear pattern is
1021+
* found; false on unrecognised binary, -f pattern-file, multiple -e, or any
1022+
* other ambiguity. Never executes or rewrites the command. */
1023+
1024+
#define HA_BASH_TOK_MAX 32
1025+
#define HA_BASH_TOK_SZ 256
1026+
1027+
static int ha_tokenize(const char *cmd, char toks[][HA_BASH_TOK_SZ], int max) {
1028+
int n = 0;
1029+
const char *p = cmd;
1030+
while (*p && n < max) {
1031+
while (*p && isspace((unsigned char)*p))
1032+
p++;
1033+
if (!*p)
1034+
break;
1035+
char *d = toks[n];
1036+
int dlen = 0;
1037+
while (*p && !isspace((unsigned char)*p)) {
1038+
if (*p == '\'') {
1039+
for (p++; *p && *p != '\''; p++)
1040+
if (dlen < HA_BASH_TOK_SZ - 1)
1041+
d[dlen++] = *p;
1042+
if (*p == '\'')
1043+
p++;
1044+
} else if (*p == '"') {
1045+
for (p++; *p && *p != '"'; p++) {
1046+
if (*p == '\\' && p[1] && strchr("\\\"$`", p[1]))
1047+
p++;
1048+
if (dlen < HA_BASH_TOK_SZ - 1)
1049+
d[dlen++] = *p;
1050+
}
1051+
if (*p == '"')
1052+
p++;
1053+
} else if (*p == '\\' && p[1]) {
1054+
p++;
1055+
if (dlen < HA_BASH_TOK_SZ - 1)
1056+
d[dlen++] = *p++;
1057+
} else {
1058+
if (dlen < HA_BASH_TOK_SZ - 1)
1059+
d[dlen++] = *p++;
1060+
}
1061+
}
1062+
d[dlen] = '\0';
1063+
if (dlen > 0)
1064+
n++;
1065+
}
1066+
return n;
1067+
}
1068+
1069+
static bool ha_is_env_assign(const char *t) {
1070+
if (!t || !t[0])
1071+
return false;
1072+
if (!isalpha((unsigned char)t[0]) && t[0] != '_')
1073+
return false;
1074+
const char *p = t + 1;
1075+
while (isalnum((unsigned char)*p) || *p == '_')
1076+
p++;
1077+
return *p == '=';
1078+
}
1079+
1080+
typedef enum { HA_BIN_GREP, HA_BIN_RG, HA_BIN_AG, HA_BIN_ACK, HA_BIN_UGREP } ha_bin_t;
1081+
1082+
static const char *ha_search_bin_val_flags(ha_bin_t bin, bool rtk_grep) {
1083+
switch (bin) {
1084+
case HA_BIN_RG:
1085+
return "ABCmtTgMP";
1086+
case HA_BIN_AG:
1087+
return "ABCmpG";
1088+
default:
1089+
return rtk_grep ? "ABCmdDl" : "ABCmdD";
1090+
}
1091+
}
1092+
1093+
static bool ha_parse_bash_search_pattern(const char *cmd, char *out, size_t out_sz) {
1094+
if (!cmd || !out || out_sz == 0)
1095+
return false;
1096+
char toks[HA_BASH_TOK_MAX][HA_BASH_TOK_SZ];
1097+
int n = ha_tokenize(cmd, toks, HA_BASH_TOK_MAX);
1098+
if (n == 0)
1099+
return false;
1100+
1101+
int i = 0;
1102+
while (i < n && ha_is_env_assign(toks[i]))
1103+
i++;
1104+
if (i >= n)
1105+
return false;
1106+
1107+
bool rtk = false;
1108+
for (;;) {
1109+
const char *t = toks[i];
1110+
if (strcmp(t, "env") == 0 || strcmp(t, "nice") == 0 || strcmp(t, "time") == 0 ||
1111+
strcmp(t, "command") == 0) {
1112+
i++;
1113+
} else if (strcmp(t, "rtk") == 0) {
1114+
rtk = true;
1115+
i++;
1116+
} else if (strcmp(t, "tokf") == 0 && i + 1 < n && strcmp(toks[i + 1], "run") == 0) {
1117+
i += 2;
1118+
} else {
1119+
break;
1120+
}
1121+
while (i < n && ha_is_env_assign(toks[i]))
1122+
i++;
1123+
if (i >= n)
1124+
return false;
1125+
}
1126+
1127+
const char *bin_tok = toks[i++];
1128+
ha_bin_t bin;
1129+
1130+
if (strcmp(bin_tok, "grep") == 0 || strcmp(bin_tok, "egrep") == 0 ||
1131+
strcmp(bin_tok, "fgrep") == 0) {
1132+
bin = HA_BIN_GREP;
1133+
} else if (strcmp(bin_tok, "rg") == 0) {
1134+
bin = HA_BIN_RG;
1135+
} else if (strcmp(bin_tok, "ag") == 0) {
1136+
bin = HA_BIN_AG;
1137+
} else if (strcmp(bin_tok, "ack") == 0) {
1138+
bin = HA_BIN_ACK;
1139+
} else if (strcmp(bin_tok, "ugrep") == 0 || strcmp(bin_tok, "ug") == 0) {
1140+
bin = HA_BIN_UGREP;
1141+
} else if (strcmp(bin_tok, "git") == 0) {
1142+
if (i >= n || strcmp(toks[i], "grep") != 0)
1143+
return false;
1144+
i++;
1145+
bin = HA_BIN_GREP;
1146+
} else {
1147+
return false;
1148+
}
1149+
1150+
const char *val_flags = ha_search_bin_val_flags(bin, rtk && bin == HA_BIN_GREP);
1151+
const char *pattern = NULL;
1152+
int e_count = 0;
1153+
bool end_of_flags = false;
1154+
1155+
for (; i < n; i++) {
1156+
const char *t = toks[i];
1157+
1158+
if (end_of_flags || t[0] != '-' || t[1] == '\0') {
1159+
if (!pattern)
1160+
pattern = t;
1161+
else
1162+
break;
1163+
continue;
1164+
}
1165+
1166+
if (t[1] == '-') {
1167+
if (t[2] == '\0') {
1168+
end_of_flags = true;
1169+
continue;
1170+
}
1171+
const char *name = t + 2;
1172+
const char *eq = strchr(name, '=');
1173+
size_t nlen = eq ? (size_t)(eq - name) : strlen(name);
1174+
if ((nlen == 6 && strncmp(name, "regexp", 6) == 0) ||
1175+
(nlen == 7 && strncmp(name, "pattern", 7) == 0)) {
1176+
pattern = eq ? eq + 1 : (i + 1 < n ? toks[++i] : NULL);
1177+
e_count++;
1178+
} else if (nlen == 4 && strncmp(name, "file", 4) == 0) {
1179+
return false;
1180+
}
1181+
continue;
1182+
}
1183+
1184+
const char *f = t + 1;
1185+
bool consumed_next = false;
1186+
while (*f) {
1187+
char flag = *f++;
1188+
if (flag == 'e') {
1189+
if (*f) {
1190+
pattern = f;
1191+
f += strlen(f);
1192+
} else if (!consumed_next && i + 1 < n) {
1193+
pattern = toks[++i];
1194+
consumed_next = true;
1195+
}
1196+
e_count++;
1197+
} else if (flag == 'f') {
1198+
return false;
1199+
} else if (strchr(val_flags, flag)) {
1200+
if (*f) {
1201+
f += strlen(f);
1202+
} else if (!consumed_next && i + 1 < n) {
1203+
i++;
1204+
consumed_next = true;
1205+
}
1206+
}
1207+
}
1208+
}
1209+
1210+
if (e_count > 1 || !pattern || !pattern[0])
1211+
return false;
1212+
int w = snprintf(out, out_sz, "%s", pattern);
1213+
return w > 0 && (size_t)w < out_sz;
1214+
}
1215+
10181216
static bool ha_tool_event_supported(ha_lifecycle_dialect_t dialect, const char *event,
10191217
const char *tool, bool *coverage) {
10201218
if (coverage) {
@@ -1025,7 +1223,8 @@ static bool ha_tool_event_supported(ha_lifecycle_dialect_t dialect, const char *
10251223
}
10261224
if (dialect == HA_DIALECT_EVENT) {
10271225
if (strcmp(event, "PreToolUse") == 0 &&
1028-
(strcmp(tool, "Grep") == 0 || strcmp(tool, "Glob") == 0)) {
1226+
(strcmp(tool, "Grep") == 0 || strcmp(tool, "Glob") == 0 ||
1227+
strcmp(tool, "Bash") == 0)) {
10291228
return true;
10301229
}
10311230
if (strcmp(event, "PostToolUse") == 0 && strcmp(tool, "Read") == 0) {
@@ -1310,6 +1509,10 @@ bool cbm_hook_path_contains_for_testing(const char *root, const char *candidate,
13101509
const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) {
13111510
return ha_no_project_index_guidance(event);
13121511
}
1512+
1513+
bool cbm_hook_augment_parse_bash_pattern_for_testing(const char *cmd, char *out, size_t out_sz) {
1514+
return ha_parse_bash_search_pattern(cmd, out, out_sz);
1515+
}
13131516
#endif
13141517

13151518
static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const char *forced_event,
@@ -1358,7 +1561,18 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha
13581561
return NULL;
13591562
}
13601563

1361-
const char *pattern = ha_obj_str(tin, "pattern");
1564+
char bash_pattern[HA_BASH_TOK_SZ];
1565+
const char *pattern;
1566+
if (strcmp(tool, "Bash") == 0) {
1567+
const char *cmd = ha_obj_str(tin, "command");
1568+
if (!ha_parse_bash_search_pattern(cmd, bash_pattern, sizeof(bash_pattern))) {
1569+
yyjson_doc_free(doc);
1570+
return NULL;
1571+
}
1572+
pattern = bash_pattern;
1573+
} else {
1574+
pattern = ha_obj_str(tin, "pattern");
1575+
}
13621576
char token[HA_MAX_TOKEN + 1];
13631577
if (!ha_extract_token(pattern, token, sizeof(token))) {
13641578
yyjson_doc_free(doc);

‎tests/test_cli.c‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8791,6 +8791,55 @@ TEST(cli_hook_augment_context_tracks_search_json_shape) {
87918791
PASS();
87928792
}
87938793

8794+
TEST(cli_hook_augment_bash_pattern_extractor) {
8795+
char out[256];
8796+
8797+
/* common forms */
8798+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("rg -n CreateStripeCheckout .", out, sizeof(out)));
8799+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8800+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("grep -rn CreateStripeCheckout .", out, sizeof(out)));
8801+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8802+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("grep -e CreateStripeCheckout .", out, sizeof(out)));
8803+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8804+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("ag CreateStripeCheckout src/", out, sizeof(out)));
8805+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8806+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("git grep CreateStripeCheckout .", out, sizeof(out)));
8807+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8808+
8809+
/* value-taking flags are skipped correctly */
8810+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("grep -A 5 CreateStripeCheckout .", out, sizeof(out)));
8811+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8812+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("rg -t py CreateStripeCheckout .", out, sizeof(out)));
8813+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8814+
8815+
/* env-var prefix and wrappers */
8816+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("FOO=bar rg CreateStripeCheckout .", out, sizeof(out)));
8817+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8818+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("rtk grep -n CreateStripeCheckout .", out, sizeof(out)));
8819+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8820+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("tokf run rg CreateStripeCheckout .", out, sizeof(out)));
8821+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8822+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("env FOO=bar rg CreateStripeCheckout .", out, sizeof(out)));
8823+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8824+
8825+
/* rtk -l <N> consumes N and still finds the pattern */
8826+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("rtk grep -l 80 CreateStripeCheckout .", out, sizeof(out)));
8827+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8828+
8829+
/* bail-out cases */
8830+
ASSERT_FALSE(cbm_hook_augment_parse_bash_pattern_for_testing("grep -f /path/patterns .", out, sizeof(out)));
8831+
ASSERT_FALSE(cbm_hook_augment_parse_bash_pattern_for_testing("grep -e FOO -e BAR .", out, sizeof(out)));
8832+
ASSERT_FALSE(cbm_hook_augment_parse_bash_pattern_for_testing("ls -la", out, sizeof(out)));
8833+
ASSERT_FALSE(cbm_hook_augment_parse_bash_pattern_for_testing("", out, sizeof(out)));
8834+
ASSERT_FALSE(cbm_hook_augment_parse_bash_pattern_for_testing(NULL, out, sizeof(out)));
8835+
8836+
/* -- end-of-flags separator */
8837+
ASSERT_TRUE(cbm_hook_augment_parse_bash_pattern_for_testing("grep -- CreateStripeCheckout .", out, sizeof(out)));
8838+
ASSERT_STR_EQ(out, "CreateStripeCheckout");
8839+
8840+
PASS();
8841+
}
8842+
87948843
TEST(cli_hook_augment_lifecycle_output_contract) {
87958844
static const struct {
87968845
const char *event;
@@ -11976,6 +12025,7 @@ SUITE(cli) {
1197612025
RUN_TEST(cli_claude_hook_commands_shell_quote_custom_config_dir);
1197712026
RUN_TEST(cli_codex_migrates_to_single_hook_representation);
1197812027
RUN_TEST(cli_hook_augment_context_tracks_search_json_shape);
12028+
RUN_TEST(cli_hook_augment_bash_pattern_extractor);
1197912029
RUN_TEST(cli_hook_augment_lifecycle_output_contract);
1198012030
RUN_TEST(cli_hook_augment_subagent_tier_router_contract);
1198112031
RUN_TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only);

0 commit comments

Comments
 (0)