From 744cde35aa112079ba2f321035c6412b134252f0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 03:26:52 -0300 Subject: [PATCH 1/2] fix(providers): fail closed when Anthropic parse realloc fails Publish text_cap/tool_cap only after realloc succeeds. On grow failure, free parse scratch and return an error instead of memcpy/index against an inflated cap. Refs: #80 --- src/providers/anthropic.c | 194 +++++++++++++++++++++++++++++--------- tests/test_anthropic.c | 101 ++++++++++++++++++++ 2 files changed, 251 insertions(+), 44 deletions(-) diff --git a/src/providers/anthropic.c b/src/providers/anthropic.c index c8b1b65..2801427 100644 --- a/src/providers/anthropic.c +++ b/src/providers/anthropic.c @@ -18,18 +18,151 @@ #define ANTHROPIC_VERSION "2023-06-01" #define REQUEST_TIMEOUT_SEC 120 #define CONNECT_TIMEOUT_SEC 30 +#define ANTHROPIC_TEXT_INIT_CAP 256 +#define ANTHROPIC_TOOL_INIT_CAP 4 static char *s_anth_api_key; static const config_t *s_anth_cfg; +#ifdef SHELLCLAW_TEST +static int s_fail_next_reallocs; + +void anthropic_test_fail_next_reallocs(int n) +{ + s_fail_next_reallocs = n < 0 ? 0 : n; +} +#endif + +static void *anth_realloc(void *ptr, size_t size) +{ +#ifdef SHELLCLAW_TEST + if (s_fail_next_reallocs > 0) { + s_fail_next_reallocs--; + return NULL; + } +#endif + return realloc(ptr, size); +} + +static int parse_fail(cJSON *root, char *text, provider_tool_call_t *tool_calls, + size_t tool_count, provider_response_t *response, const char *msg) +{ + if (tool_calls) { + for (size_t i = 0; i < tool_count; i++) { + free(tool_calls[i].id); + free(tool_calls[i].name); + free(tool_calls[i].arguments); + } + free(tool_calls); + } + free(text); + cJSON_Delete(root); + provider_set_error(response, msg); + return -1; +} + +/* Cap is published only after realloc succeeds. Publishing first made + * memcpy/index use a size the heap block did not have (#80). */ +static int ensure_text_cap(char **text, size_t *text_cap, size_t need) +{ + while (*text_cap < need) { + size_t new_cap = *text_cap ? *text_cap : ANTHROPIC_TEXT_INIT_CAP; + char *grown; + if (new_cap > SIZE_MAX / 2) + return -1; + new_cap *= 2; + grown = anth_realloc(*text, new_cap); + if (!grown) + return -1; + *text = grown; + *text_cap = new_cap; + } + return 0; +} + +static int ensure_tool_cap(provider_tool_call_t **tool_calls, size_t *tool_cap, + size_t tool_count) +{ + size_t new_cap; + provider_tool_call_t *grown; + if (tool_count < *tool_cap) + return 0; + new_cap = *tool_cap ? *tool_cap : ANTHROPIC_TOOL_INIT_CAP; + if (*tool_cap) { + if (new_cap > SIZE_MAX / (2u * sizeof(*grown))) + return -1; + new_cap *= 2; + } + if (new_cap > SIZE_MAX / sizeof(*grown)) + return -1; + grown = anth_realloc(*tool_calls, new_cap * sizeof(*grown)); + if (!grown) + return -1; + *tool_calls = grown; + *tool_cap = new_cap; + return 0; +} + +static int append_text_block(char **text, size_t *text_len, size_t *text_cap, const char *t) +{ + size_t tlen; + size_t need; + if (!t) + return 0; + tlen = strlen(t); + if (tlen > SIZE_MAX - 1 || *text_len > SIZE_MAX - tlen - 1) + return -1; + need = *text_len + tlen + 1; + if (ensure_text_cap(text, text_cap, need) != 0) + return -1; + memcpy(*text + *text_len, t, tlen + 1); + *text_len += tlen; + return 0; +} + +static int append_tool_use(provider_tool_call_t **tool_calls, size_t *tool_count, + size_t *tool_cap, cJSON *block) +{ + provider_tool_call_t *tc; + cJSON *id_item; + cJSON *name_item; + cJSON *input_item; + if (ensure_tool_cap(tool_calls, tool_cap, *tool_count) != 0) + return -1; + tc = &(*tool_calls)[*tool_count]; + tc->id = NULL; + tc->name = NULL; + tc->arguments = NULL; + id_item = cJSON_GetObjectItem(block, "id"); + name_item = cJSON_GetObjectItem(block, "name"); + input_item = cJSON_GetObjectItem(block, "input"); + if (cJSON_IsString(id_item)) + tc->id = provider_dup_str(id_item->valuestring); + if (cJSON_IsString(name_item)) + tc->name = provider_dup_str(name_item->valuestring); + if (input_item) + tc->arguments = cJSON_PrintUnformatted(input_item); + (*tool_count)++; + return 0; +} + static int parse_response_body(const char *response_buf, provider_response_t *response) { cJSON *root = cJSON_Parse(response_buf); + cJSON *err_obj; + cJSON *content; + cJSON *block; + size_t text_len = 0; + size_t text_cap = ANTHROPIC_TEXT_INIT_CAP; + size_t tool_cap = ANTHROPIC_TOOL_INIT_CAP; + size_t tool_count = 0; + char *text; + provider_tool_call_t *tool_calls; if (!root) { provider_set_error(response, "Failed to parse Anthropic response JSON"); return -1; } - cJSON *err_obj = cJSON_GetObjectItem(root, "error"); + err_obj = cJSON_GetObjectItem(root, "error"); if (cJSON_IsObject(err_obj)) { cJSON *msg = cJSON_GetObjectItem(err_obj, "message"); const char *errmsg = cJSON_IsString(msg) ? msg->valuestring : "Anthropic API error"; @@ -37,60 +170,33 @@ static int parse_response_body(const char *response_buf, provider_response_t *re cJSON_Delete(root); return -1; } - cJSON *content = cJSON_GetObjectItem(root, "content"); + content = cJSON_GetObjectItem(root, "content"); if (!cJSON_IsArray(content)) { cJSON_Delete(root); return 0; } - size_t text_len = 0; - size_t text_cap = 256; - char *text = malloc(text_cap); - if (text) text[0] = '\0'; - size_t tool_cap = 4; - size_t tool_count = 0; - provider_tool_call_t *tool_calls = malloc(tool_cap * sizeof(provider_tool_call_t)); - if (!tool_calls) tool_cap = 0; - cJSON *block; + text = malloc(text_cap); + if (!text) + return parse_fail(root, NULL, NULL, 0, response, + "Out of memory growing Anthropic text buffer"); + text[0] = '\0'; + tool_calls = malloc(tool_cap * sizeof(*tool_calls)); + if (!tool_calls) + return parse_fail(root, text, NULL, 0, response, + "Out of memory growing Anthropic tool_use array"); cJSON_ArrayForEach(block, content) { cJSON *type_item = cJSON_GetObjectItem(block, "type"); const char *type = cJSON_IsString(type_item) ? type_item->valuestring : NULL; if (type && strcmp(type, "text") == 0) { cJSON *text_item = cJSON_GetObjectItem(block, "text"); const char *t = cJSON_IsString(text_item) ? text_item->valuestring : ""; - if (t) { - size_t tlen = strlen(t); - while (text_len + tlen + 1 >= text_cap) { - text_cap *= 2; - char *n = realloc(text, text_cap); - if (!n) break; - text = n; - } - if (text && text_len + tlen + 1 < text_cap) { - memcpy(text + text_len, t, tlen + 1); - text_len += tlen; - } - } + if (append_text_block(&text, &text_len, &text_cap, t) != 0) + return parse_fail(root, text, tool_calls, tool_count, response, + "Out of memory growing Anthropic text buffer"); } else if (type && strcmp(type, "tool_use") == 0) { - if (tool_count >= tool_cap) { - tool_cap *= 2; - provider_tool_call_t *n = realloc(tool_calls, tool_cap * sizeof(provider_tool_call_t)); - if (!n) continue; - tool_calls = n; - } - provider_tool_call_t *tc = &tool_calls[tool_count]; - tc->id = NULL; - tc->name = NULL; - tc->arguments = NULL; - cJSON *id_item = cJSON_GetObjectItem(block, "id"); - cJSON *name_item = cJSON_GetObjectItem(block, "name"); - cJSON *input_item = cJSON_GetObjectItem(block, "input"); - if (cJSON_IsString(id_item)) tc->id = provider_dup_str(id_item->valuestring); - if (cJSON_IsString(name_item)) tc->name = provider_dup_str(name_item->valuestring); - if (input_item) { - char *printed = cJSON_PrintUnformatted(input_item); - tc->arguments = printed; - } - tool_count++; + if (append_tool_use(&tool_calls, &tool_count, &tool_cap, block) != 0) + return parse_fail(root, text, tool_calls, tool_count, response, + "Out of memory growing Anthropic tool_use array"); } } cJSON_Delete(root); diff --git a/tests/test_anthropic.c b/tests/test_anthropic.c index ae3a564..a82b28f 100644 --- a/tests/test_anthropic.c +++ b/tests/test_anthropic.c @@ -15,6 +15,7 @@ #ifdef SHELLCLAW_TEST extern int anthropic_parse_response_for_test(const char *json, provider_response_t *response); +extern void anthropic_test_fail_next_reallocs(int n); #endif static const char *TMP_CONFIG = "/tmp/shellclaw_test_anthropic_config.toml"; @@ -154,6 +155,102 @@ static int test_parse_null_json_returns_error(void) provider_response_clear(&response); return 0; } + +static int test_parse_text_past_initial_cap(void) +{ + char text[301]; + char body[384]; + provider_response_t response = {0}; + memset(text, 'A', 300); + text[300] = '\0'; + ASSERT(snprintf(body, sizeof(body), + "{\"content\":[{\"type\":\"text\",\"text\":\"%s\"}]}", text) < (int)sizeof(body)); + ASSERT(anthropic_parse_response_for_test(body, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.content != NULL); + ASSERT(strlen(response.content) == 300); + ASSERT(response.content[0] == 'A' && response.content[299] == 'A'); + provider_response_clear(&response); + return 0; +} + +static int fill_tool_use_body(char *dst, size_t dst_sz, size_t n) +{ + size_t off = 0; + int w; + size_t i; + w = snprintf(dst, dst_sz, "{\"content\":["); + if (w < 0 || (size_t)w >= dst_sz) + return -1; + off = (size_t)w; + for (i = 0; i < n; i++) { + w = snprintf(dst + off, dst_sz - off, + "%s{\"type\":\"tool_use\",\"id\":\"t%zu\",\"name\":\"shell\",\"input\":{}}", + i ? "," : "", i); + if (w < 0 || (size_t)w >= dst_sz - off) + return -1; + off += (size_t)w; + } + w = snprintf(dst + off, dst_sz - off, "]}"); + if (w < 0 || (size_t)w >= dst_sz - off) + return -1; + return 0; +} + +static int test_parse_six_tool_use_blocks(void) +{ + char body[1024]; + provider_response_t response = {0}; + ASSERT(fill_tool_use_body(body, sizeof(body), 6) == 0); + ASSERT(anthropic_parse_response_for_test(body, &response) == 0); + ASSERT(response.error == 0); + ASSERT(response.tool_calls_count == 6); + ASSERT(response.tool_calls != NULL); + ASSERT(response.tool_calls[0].id != NULL && strcmp(response.tool_calls[0].id, "t0") == 0); + ASSERT(response.tool_calls[5].id != NULL && strcmp(response.tool_calls[5].id, "t5") == 0); + provider_response_clear(&response); + return 0; +} + +static int test_parse_text_realloc_failure_is_error(void) +{ + char text[301]; + char body[384]; + provider_response_t response = {0}; + int ret; + memset(text, 'B', 300); + text[300] = '\0'; + ASSERT(snprintf(body, sizeof(body), + "{\"content\":[{\"type\":\"text\",\"text\":\"%s\"}]}", text) < (int)sizeof(body)); + anthropic_test_fail_next_reallocs(1); + ret = anthropic_parse_response_for_test(body, &response); + anthropic_test_fail_next_reallocs(0); + ASSERT(ret == -1); + ASSERT(response.error != 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "Out of memory") != NULL); + provider_response_clear(&response); + return 0; +} + +static int test_parse_tool_realloc_failure_is_error(void) +{ + char body[1024]; + provider_response_t response = {0}; + int ret; + ASSERT(fill_tool_use_body(body, sizeof(body), 6) == 0); + anthropic_test_fail_next_reallocs(1); + ret = anthropic_parse_response_for_test(body, &response); + anthropic_test_fail_next_reallocs(0); + ASSERT(ret == -1); + ASSERT(response.error != 0); + ASSERT(response.content != NULL); + ASSERT(strstr(response.content, "Out of memory") != NULL); + ASSERT(response.tool_calls == NULL); + ASSERT(response.tool_calls_count == 0); + provider_response_clear(&response); + return 0; +} #endif int main(void) @@ -169,6 +266,10 @@ int main(void) RUN(test_parse_empty_content_array_returns_zero()); RUN(test_parse_valid_text_block_returns_content()); RUN(test_parse_null_json_returns_error()); + RUN(test_parse_text_past_initial_cap()); + RUN(test_parse_six_tool_use_blocks()); + RUN(test_parse_text_realloc_failure_is_error()); + RUN(test_parse_tool_realloc_failure_is_error()); #endif printf("test_anthropic: all tests passed\n"); return 0; From e489f0b1f6414e7eca22c16c61be661f480fdf0d Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 03:26:52 -0300 Subject: [PATCH 2/2] docs(providers): changelog Anthropic realloc fail-closed parse Refs: #80 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc6c1c..bc52426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Anthropic `content` parse fails closed when growing the text buffer or `tool_use` array cannot `realloc`, instead of copying against an inflated cap. - HTTP 200 JSON-RPC results with a malformed ASAP envelope no longer double-free the duplicated request id. - Inbound ASAP `mcp.tool_call` and `state.query` now hold `agent_lock()` around tool execute and SQLite `g_db` reads, matching `task.request`. - Inbound `POST /asap` now wires the process provider and tool table into `asap_ctx`, so `task.request` and `mcp.tool_call` dispatch instead of failing with `server missing cfg or provider`.