From 4a7d91dcac08293548f7e9e79ca57c84ab93883b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 11:39:58 +0000 Subject: [PATCH 01/92] fix(gateway): stop HTTP thread before freeing auth context The lws thread still calls auth_validate_token after SIGTERM. Freeing auth_ctx first raced with in-flight /api and WebSocket auth checks. Co-authored-by: esadrianno --- src/core/bootstrap.c | 4 +++- src/core/bootstrap.h | 2 +- tests/test_gateway_http.c | 33 +++++++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index e7fe294..f0a17d1 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -294,12 +294,14 @@ int init_subsystems(config_t *cfg) void cleanup_subsystems(void) { #ifdef SHELLCLAW_GATEWAY + /* HTTP/WS callbacks still call auth_validate_token(ctx->auth). Join the + * lws thread before freeing auth_ctx (same order as tools_init failure). */ ws_shutdown_signal(); + http_stop(); if (g_auth_ctx) { auth_cleanup(g_auth_ctx); g_auth_ctx = NULL; } - http_stop(); ws_cleanup(); #endif tools_cleanup(); diff --git a/src/core/bootstrap.h b/src/core/bootstrap.h index e529492..0a89a54 100644 --- a/src/core/bootstrap.h +++ b/src/core/bootstrap.h @@ -27,7 +27,7 @@ int init_subsystems(config_t *cfg); /** Register tools from config (called from init_subsystems). */ int tools_init(const config_t *cfg); -/** Tear down all subsystems in reverse init order. */ +/** Tear down all subsystems in reverse init order (HTTP thread before auth_ctx). */ void cleanup_subsystems(void); config_t *bootstrap_get_cfg(void); diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 5769a73..8001da1 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -562,6 +562,31 @@ static int test_api_asap_log_401(void) return 0; } +static int test_shutdown_does_not_crash(pid_t pid, const char *token) +{ + int i; + int status = 0; + + if (token && token[0]) { + for (i = 0; i < 16; i++) { + long code = 0; + char *body = NULL; + (void)http_get_auth(gw_url("/api/status"), token, &code, &body); + free(body); + } + } + ASSERT(kill(pid, SIGTERM) == 0); + ASSERT(waitpid(pid, &status, 0) == pid); + if (WIFSIGNALED(status)) { + int sig = WTERMSIG(status); + if (sig == SIGSEGV || sig == SIGABRT || sig == SIGBUS || sig == SIGILL) { + fprintf(stderr, "FAIL: gateway crashed on shutdown with signal %d\n", sig); + return 1; + } + } + return 0; +} + static int test_api_asap_log(const char *token) { long code; @@ -690,8 +715,12 @@ int main(int argc, char **argv) if (test_api_sessions(token) != 0) { fprintf(stderr, "test_api_sessions failed\n"); failed++; } if (test_api_asap_log(token) != 0) { fprintf(stderr, "test_api_asap_log failed\n"); failed++; } } - kill(pid, SIGTERM); - waitpid(pid, NULL, 0); + if (test_shutdown_does_not_crash(pid, token) != 0) { + fprintf(stderr, "test_shutdown_does_not_crash failed\n"); + failed++; + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } unlink(config_path); unlink(tokens_path); unlink(pairing_file); From 48c03f511d76c0cbc2ca9722cb1c1754048b83aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 11:30:19 +0000 Subject: [PATCH 02/92] fix(sandbox): keep agent workspace off gateway state files Default workspace_path was ~/.shellclaw, the same tree as pairing tokens, memory.db, and config.toml. With workspace_only on, inbound Discord/webchat/cron file tools could read or overwrite those files. Point the default at ~/.shellclaw/workspace and deny runtime state paths even when an operator keeps the old workspace root. Co-authored-by: esadrianno --- Makefile | 4 +- config.example.toml | 2 + src/core/bootstrap.c | 28 ++++++++++++ src/core/config.c | 4 +- src/sandbox/allowlist.c | 70 +++++++++++++++++++++++------ src/sandbox/allowlist.h | 13 ++++++ src/tools/file.c | 35 ++++++++++++++- src/tools/shell.c | 1 + tests/test_allowlist.c | 74 +++++++++++++++++++++++++++++++ tests/test_config.c | 8 ++++ tests/test_file.c | 97 +++++++++++++++++++++++++++++++++++++++++ tests/test_shell.c | 11 +++++ 12 files changed, 330 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 2a2d541..4eaa717 100644 --- a/Makefile +++ b/Makefile @@ -434,9 +434,9 @@ test_shell: tests/test_shell.c $(SHELL_O) $(SANDBOX_O) $(ALLOWLIST_O) $(CONFIG_O $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_shell.c $(SHELL_O) $(SANDBOX_O) $(ALLOWLIST_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) $(DSYM_SCRIPT) -test_file: tests/test_file.c $(FILE_O) $(REGISTRY_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) +test_file: tests/test_file.c $(FILE_O) $(REGISTRY_O) $(ALLOWLIST_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) @mkdir -p $(BINDIR) - $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_file.c $(FILE_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) + $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_file.c $(FILE_O) $(ALLOWLIST_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) $(DSYM_SCRIPT) $(CHANNEL_TG_TEST_O): src/channels/telegram.c src/channels/channel.h src/core/config.h diff --git a/config.example.toml b/config.example.toml index 267c89c..142fd37 100644 --- a/config.example.toml +++ b/config.example.toml @@ -52,6 +52,8 @@ memory_limit_mb = 64 cpu_limit_percent = 50 network = false workspace_only = true +# Separate from ~/.shellclaw so file/shell tools cannot read pairing tokens or memory.db. +workspace_path = "~/.shellclaw/workspace" [heartbeat] enabled = true diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index e7fe294..cbc3a59 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -15,8 +15,12 @@ #include "gateway/http.h" #include "gateway/ws.h" #endif +#include +#include #include #include +#include +#include #define SKILLS_BUF_SIZE (256 * 1024) #define SYSTEM_PROMPT_BUF_SIZE (256 * 1024) @@ -211,8 +215,32 @@ static void channels_cleanup(void) g_cfg = NULL; } +static void ensure_workspace_directory(const char *workspace) +{ + char parent[PATH_MAX]; + const char *slash; + size_t parent_len; + + if (!workspace || !workspace[0]) return; + slash = strrchr(workspace, '/'); + if (slash && slash != workspace) { + parent_len = (size_t)(slash - workspace); + if (parent_len < sizeof(parent)) { + memcpy(parent, workspace, parent_len); + parent[parent_len] = '\0'; + if (mkdir(parent, 0700) != 0 && errno != EEXIST) + fprintf(stderr, "shellclaw: mkdir %s: %s\n", + parent, strerror(errno)); + } + } + if (mkdir(workspace, 0700) != 0 && errno != EEXIST) + fprintf(stderr, "shellclaw: mkdir workspace %s: %s\n", + workspace, strerror(errno)); +} + int tools_init(const config_t *cfg) { + ensure_workspace_directory(config_workspace_path(cfg)); tool_set_config(cfg); g_tool_count = tool_get_all(g_tools, MAX_TOOLS); return 0; diff --git a/src/core/config.c b/src/core/config.c index 440ffb5..4af729d 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -739,7 +739,9 @@ int config_load(const char *path, config_t **out, char *errbuf, size_t errbufsz) cfg->workspace_only = 1; cfg->gateway_port = DEFAULT_GATEWAY_PORT; set_string(&cfg->gateway_host, "127.0.0.1"); - set_string(&cfg->workspace_path, "~/.shellclaw"); + /* Keep tool workspace off the state dir (~/.shellclaw) so pairing tokens, + * memory.db, and config.toml are outside workspace_only by default. */ + set_string(&cfg->workspace_path, "~/.shellclaw/workspace"); set_string(&cfg->asap_agent_urn, "urn:asap:agent:shellclaw"); set_string(&cfg->asap_agent_name, "ShellClaw"); cfg->heartbeat_interval_minutes = 30; diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index c3033ce..79318c5 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -53,6 +53,9 @@ static const char *const BLOCK_SUBSTRINGS[] = { "~/.ssh/id_", "id_rsa", "id_ed25519", + "auth_tokens.json", + "shellclaw.pid", + "shellclaw.log", NULL }; @@ -113,6 +116,40 @@ int allowlist_path_is_under_workspace(const char *path, const char *workspace_ro return 0; } +int allowlist_path_is_runtime_state_file(const char *path) +{ + char resolved[PATH_MAX]; + const char *use = path; + const char *base; + const char *slash; + char parent[PATH_MAX]; + size_t parent_len; + + if (!path || !path[0]) + return 0; + if (realpath(path, resolved) != NULL) + use = resolved; + base = strrchr(use, '/'); + base = base ? base + 1 : use; + if (strcmp(base, "auth_tokens.json") == 0 || + strcmp(base, "shellclaw.pid") == 0 || + strcmp(base, "shellclaw.log") == 0) + return 1; + if (strcmp(base, "config.toml") != 0 && strcmp(base, "memory.db") != 0) + return 0; + slash = strrchr(use, '/'); + if (!slash || slash == use) + return 0; + parent_len = (size_t)(slash - use); + if (parent_len >= sizeof(parent)) + return 0; + memcpy(parent, use, parent_len); + parent[parent_len] = '\0'; + slash = strrchr(parent, '/'); + slash = slash ? slash + 1 : parent; + return strcmp(slash, ".shellclaw") == 0; +} + /* ------------------------------------------------------------------ */ /* Public: combined check */ /* ------------------------------------------------------------------ */ @@ -166,21 +203,28 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg if (!cmd_copy) return 0; /* fail-open on OOM */ tok = strtok_r(cmd_copy, " \t\n;|&><", &saveptr); while (tok) { + char expanded[PATH_MAX]; + const char *check = tok; + if (tok[0] == '~') { + const char *home = getenv("HOME"); + if (home) + snprintf(expanded, sizeof(expanded), "%s%s", home, tok + 1); + else + snprintf(expanded, sizeof(expanded), "%s", tok); + check = expanded; + } + if (allowlist_path_is_runtime_state_file(check)) { + set_reason(reason_buf, reason_cap, + "command blocked: runtime state file: ", check); + fprintf(stderr, "allowlist: blocked runtime state file: %s\n", check); + free(cmd_copy); + return 1; + } if (has_path_chars(tok)) { - /* Expand a leading tilde naively */ - char expanded[PATH_MAX]; - if (tok[0] == '~') { - const char *home = getenv("HOME"); - if (home) - snprintf(expanded, sizeof(expanded), "%s%s", home, tok + 1); - else - snprintf(expanded, sizeof(expanded), "%s", tok); - tok = expanded; - } - if (!allowlist_path_is_under_workspace(tok, workspace_root)) { + if (!allowlist_path_is_under_workspace(check, workspace_root)) { set_reason(reason_buf, reason_cap, - "command blocked: path escapes workspace: ", tok); - fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", tok); + "command blocked: path escapes workspace: ", check); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", check); free(cmd_copy); return 1; } diff --git a/src/sandbox/allowlist.h b/src/sandbox/allowlist.h index 76a4e4f..5f64c2e 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -66,6 +66,19 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg */ int allowlist_path_is_under_workspace(const char *path, const char *workspace_root); +/** + * Return 1 if @p path is a ShellClaw runtime state file that tools must not touch. + * + * Always reserved by basename: auth_tokens.json, shellclaw.pid, shellclaw.log. + * Also reserved when the parent directory is named `.shellclaw`: config.toml, memory.db. + * + * @param path Absolute, relative, or unresolved path (realpath used when the file exists). + * @return 1 if reserved, 0 otherwise. + * + * Example: allowlist_path_is_runtime_state_file("/home/me/.shellclaw/auth_tokens.json") == 1 + */ +int allowlist_path_is_runtime_state_file(const char *path); + #ifdef __cplusplus } #endif diff --git a/src/tools/file.c b/src/tools/file.c index e8170b1..c491aa6 100644 --- a/src/tools/file.c +++ b/src/tools/file.c @@ -8,6 +8,7 @@ #include "tools/tool.h" #include "tools/file.h" #include "core/config.h" +#include "sandbox/allowlist.h" #include "cJSON.h" #include #include @@ -31,11 +32,41 @@ void tool_file_set_config(const config_t *cfg) g_file_cfg = cfg; } +static int path_matches_memory_db(const char *candidate) +{ + const char *db; + char db_resolved[PATH_MAX]; + char cand_resolved[PATH_MAX]; + + if (!g_file_cfg || !candidate || !candidate[0]) return 0; + db = config_memory_db_path(g_file_cfg); + if (!db || !db[0]) return 0; + if (strcmp(candidate, db) == 0) return 1; + if (realpath(db, db_resolved) == NULL) return 0; + if (strcmp(candidate, db_resolved) == 0) return 1; + if (realpath(candidate, cand_resolved) != NULL && + strcmp(cand_resolved, db_resolved) == 0) + return 1; + return 0; +} + +static int path_is_reserved_runtime_state(const char *path, const char *resolved) +{ + if (path && allowlist_path_is_runtime_state_file(path)) return 1; + if (resolved && resolved[0] && allowlist_path_is_runtime_state_file(resolved)) + return 1; + if (path_matches_memory_db(path) || path_matches_memory_db(resolved)) + return 1; + return 0; +} + static int path_within_workspace(const char *path, char *resolved, size_t resolved_size) { if (!path || path[0] == '\0') return 0; if (!g_file_cfg || !config_workspace_only(g_file_cfg)) { - snprintf(resolved, resolved_size, "%s", path); + if (realpath(path, resolved) == NULL) + snprintf(resolved, resolved_size, "%s", path); + if (path_is_reserved_runtime_state(path, resolved)) return 0; return 1; } const char *workspace = config_workspace_path(g_file_cfg); @@ -48,6 +79,7 @@ static int path_within_workspace(const char *path, char *resolved, size_t resolv size_t ws_len = strlen(ws_resolved); if (strncmp(resolved, ws_resolved, ws_len) != 0) return 0; if (resolved[ws_len] != '\0' && resolved[ws_len] != '/') return 0; + if (path_is_reserved_runtime_state(path, resolved)) return 0; return 1; } char path_copy[PATH_MAX]; @@ -59,6 +91,7 @@ static int path_within_workspace(const char *path, char *resolved, size_t resolv size_t ws_len = strlen(ws_resolved); if (strncmp(resolved, ws_resolved, ws_len) != 0) return 0; if (resolved[ws_len] != '\0' && resolved[ws_len] != '/') return 0; + if (path_is_reserved_runtime_state(path, resolved)) return 0; return 1; } if (strcmp(dir, ".") == 0 || strcmp(dir, "/") == 0) break; diff --git a/src/tools/shell.c b/src/tools/shell.c index 89f125d..4197928 100644 --- a/src/tools/shell.c +++ b/src/tools/shell.c @@ -41,6 +41,7 @@ static const char *const FALLBACK_BLOCKLIST[] = { "rm -rf /", "rm -rf / ", "rm -rf /$", "rm -rf /*", "mkfs", "dd if=", "dd of=", "shutdown", "reboot", ":(){ :|:& };:", "fork()", "> /dev/sd", + "auth_tokens.json", "shellclaw.pid", "shellclaw.log", NULL }; diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index 5d2ac74..77211ed 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -89,6 +89,77 @@ static int test_null_command_blocked(void) return 0; } +static int test_block_auth_tokens_json(void) +{ + char reason[256]; + ASSERT(allowlist_check_shell_command("cat ~/.shellclaw/auth_tokens.json", + NULL, reason, sizeof(reason)) == 1); + ASSERT(allowlist_path_is_runtime_state_file("auth_tokens.json") == 1); + return 0; +} + +static int test_block_state_dir_config_and_memory(void) +{ + char dir[] = "/tmp/sc_al_state_XXXXXX"; + char state[256]; + char cfg_path[256]; + char db_path[256]; + char *tmp; + FILE *f; + allowlist_config_t acfg; + char cmd[512]; + + tmp = mkdtemp(dir); + if (!tmp) { + fprintf(stderr, "test_block_state_dir_config_and_memory: mkdtemp failed\n"); + return 1; + } + snprintf(state, sizeof(state), "%s/.shellclaw", tmp); + if (mkdir(state, 0755) != 0) { + rmdir(tmp); + return 1; + } + snprintf(cfg_path, sizeof(cfg_path), "%s/config.toml", state); + snprintf(db_path, sizeof(db_path), "%s/memory.db", state); + f = fopen(cfg_path, "w"); + if (!f) { + rmdir(state); + rmdir(tmp); + return 1; + } + fputs("x=1\n", f); + fclose(f); + f = fopen(db_path, "w"); + if (!f) { + unlink(cfg_path); + rmdir(state); + rmdir(tmp); + return 1; + } + fputs("db", f); + fclose(f); + ASSERT(allowlist_path_is_runtime_state_file(cfg_path) == 1); + ASSERT(allowlist_path_is_runtime_state_file(db_path) == 1); + acfg.workspace_path = state; + acfg.workspace_only = 1; + snprintf(cmd, sizeof(cmd), "cat %s", cfg_path); + ASSERT(allowlist_check_shell_command(cmd, &acfg, NULL, 0) == 1); + snprintf(cmd, sizeof(cmd), "cat %s", db_path); + ASSERT(allowlist_check_shell_command(cmd, &acfg, NULL, 0) == 1); + unlink(cfg_path); + unlink(db_path); + rmdir(state); + rmdir(tmp); + return 0; +} + +static int test_allow_project_config_toml(void) +{ + ASSERT(allowlist_path_is_runtime_state_file("/tmp/project/config.toml") == 0); + ASSERT(allowlist_path_is_runtime_state_file("/tmp/project/memory.db") == 0); + return 0; +} + /* ------------------------------------------------------------------ */ /* Workspace path containment */ /* ------------------------------------------------------------------ */ @@ -200,6 +271,9 @@ int main(void) RUN(test_allow_safe_command()); RUN(test_allow_echo()); RUN(test_null_command_blocked()); + RUN(test_block_auth_tokens_json()); + RUN(test_block_state_dir_config_and_memory()); + RUN(test_allow_project_config_toml()); RUN(test_path_inside_workspace()); RUN(test_path_outside_workspace()); RUN(test_path_prefix_no_slash()); diff --git a/tests/test_config.c b/tests/test_config.c index 6a27aad..a065386 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -106,6 +106,14 @@ static int test_defaults(void) ASSERT(ret == 0); ASSERT(config_agent_max_tool_iterations(cfg) == 20); ASSERT(config_agent_max_context_messages(cfg) == 40); + { + const char *ws = config_workspace_path(cfg); + size_t n; + ASSERT(ws != NULL); + n = strlen(ws); + ASSERT(n >= 10); + ASSERT(strcmp(ws + n - 10, "/workspace") == 0); + } config_free(cfg); remove(path); return 0; diff --git a/tests/test_file.c b/tests/test_file.c index b4087ac..33e6074 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -196,6 +196,102 @@ static void test_symlink_escape_rejected(void) rmdir(tmpdir); } +static int write_text_file(const char *path, const char *content) +{ + FILE *f = fopen(path, "w"); + if (!f) return -1; + if (fputs(content, f) == EOF) { + fclose(f); + return -1; + } + fclose(f); + return 0; +} + +static void test_runtime_state_files_rejected_inside_workspace(void) +{ + char tmpdir[PATH_MAX]; + char state_dir[PATH_MAX]; + char token_path[PATH_MAX]; + char config_toml[PATH_MAX]; + char memory_db[PATH_MAX]; + char ok_path[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 80]; + char buf[256]; + config_t *cfg; + const tool_t *t; + int r; + + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_state_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(state_dir, sizeof(state_dir), "%s/.shellclaw", tmpdir); + if (mkdir(state_dir, 0755) != 0 && errno != EEXIST) { + rmdir(tmpdir); + return; + } + snprintf(token_path, sizeof(token_path), "%s/auth_tokens.json", state_dir); + snprintf(config_toml, sizeof(config_toml), "%s/config.toml", state_dir); + snprintf(memory_db, sizeof(memory_db), "%s/memory.db", state_dir); + snprintf(ok_path, sizeof(ok_path), "%s/notes.txt", state_dir); + MU_ASSERT(write_text_file(token_path, "[{\"token\":\"secret-pair\"}]") == 0, + "write auth_tokens.json"); + MU_ASSERT(write_text_file(config_toml, "model=\"x\"\n") == 0, "write config.toml"); + MU_ASSERT(write_text_file(memory_db, "sqlite") == 0, "write memory.db"); + MU_ASSERT(write_text_file(ok_path, "ok") == 0, "write notes.txt"); + + { + char cwd[PATH_MAX]; + FILE *f; + MU_ASSERT(getcwd(cwd, sizeof(cwd)) != NULL, "getcwd"); + snprintf(config_path, sizeof(config_path), "%s/build/test_file_state.toml", cwd); + f = fopen(config_path, "w"); + MU_ASSERT(f != NULL, "create config"); + fprintf(f, + "[agent]\nmodel=\"x\"\n[memory]\ndb_path=\"%s\"\n" + "[sandbox]\nworkspace_only=true\nworkspace_path=\"%s\"\n", + memory_db, state_dir); + fclose(f); + } + cfg = NULL; + config_load(config_path, &cfg, NULL, 0); + MU_ASSERT(cfg != NULL, "load config with state-dir workspace"); + tool_file_set_config(cfg); + t = tool_file_get(); + + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", token_path); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "read auth_tokens.json rejected"); + MU_ASSERT(strstr(buf, "secret-pair") == NULL, "token secret not returned"); + + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"[]\"}", token_path); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "write auth_tokens.json rejected"); + + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", config_toml); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "read state config.toml rejected"); + + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", memory_db); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "read memory.db rejected"); + + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", ok_path); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == 0, "read notes.txt in workspace still allowed"); + MU_ASSERT(strcmp(buf, "ok") == 0, "notes.txt content matches"); + + config_free(cfg); + unlink(config_path); + unlink(token_path); + unlink(config_toml); + unlink(memory_db); + unlink(ok_path); + rmdir(state_dir); + rmdir(tmpdir); +} + int main(void) { MU_RUN(test_file_read_write_list); @@ -203,6 +299,7 @@ int main(void) MU_RUN(test_file_outside_workspace_rejected); MU_RUN(test_path_traversal_rejected); MU_RUN(test_symlink_escape_rejected); + MU_RUN(test_runtime_state_files_rejected_inside_workspace); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } diff --git a/tests/test_shell.c b/tests/test_shell.c index 8b43d94..2f52b2f 100644 --- a/tests/test_shell.c +++ b/tests/test_shell.c @@ -60,6 +60,16 @@ static void test_shell_invalid_json(void) MU_ASSERT(strstr(buf, "error") != NULL, "error in output"); } +static void test_shell_blocked_auth_tokens(void) +{ + const tool_t *t = tool_shell_get(); + char buf[256]; + buf[0] = '\0'; + tool_shell_set_config(NULL); + (void)t->execute("{\"command\":\"cat ~/.shellclaw/auth_tokens.json\"}", buf, sizeof(buf)); + MU_ASSERT(strstr(buf, "blocked") != NULL, "cat auth_tokens.json blocked"); +} + static void test_shell_missing_command(void) { const tool_t *t = tool_shell_get(); @@ -72,6 +82,7 @@ int main(void) { MU_RUN(test_shell_blocked_rm_rf); MU_RUN(test_shell_blocked_mkfs); + MU_RUN(test_shell_blocked_auth_tokens); MU_RUN(test_shell_ls_succeeds); MU_RUN(test_shell_invalid_json); MU_RUN(test_shell_missing_command); From 7e5eda6bdc7ebfbe550ad70e373d68f8935fdf45 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 00:58:18 -0300 Subject: [PATCH 03/92] fix(dispatch): acquire agent mutex around main-loop agent_run Refs: #54 --- src/core/agent.c | 10 ++++++++ src/core/agent.h | 9 ++++++++ src/core/dispatch.c | 2 ++ tests/test_dispatch.c | 54 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/src/core/agent.c b/src/core/agent.c index 892f935..f536687 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -36,6 +36,16 @@ void agent_unlock(void) pthread_mutex_unlock(&g_agent_mutex); } +int agent_mutex_is_locked_for_test(void) +{ + int rc = pthread_mutex_trylock(&g_agent_mutex); + if (rc == 0) { + pthread_mutex_unlock(&g_agent_mutex); + return 0; + } + return 1; +} + #define SYSTEM_PROMPT_MAX 65536 #define SKILLS_BUF_SIZE 32768 #define SESSION_JSON_MAX (128 * 1024) diff --git a/src/core/agent.h b/src/core/agent.h index 21c19d8..e85f1c7 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -67,6 +67,15 @@ void agent_unlock(void); /** Test-only: non-empty @p name_or_null forces “active backend” for local/offline prompt suffix; NULL uses router. */ void shellclaw_agent_set_test_active_backend_name(const char *name_or_null); +/** + * Test-only: probe whether the global agent mutex is currently held. + * @return 1 if locked, 0 if free. + * + * Used by dispatch tests to assert handle_message() holds the mutex + * for the duration of agent_run() (see #54). + */ +int agent_mutex_is_locked_for_test(void); + #ifdef __cplusplus } #endif diff --git a/src/core/dispatch.c b/src/core/dispatch.c index 6b1dcfa..cbf39c6 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -41,9 +41,11 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) flat_tools[i].parameters_json = t->parameters_json; flat_tools[i].execute = t->execute; } + agent_lock(); int err = agent_run(bootstrap_get_cfg(), msg->session_id, text, bootstrap_get_provider(), flat_tools, tool_count, resp_buf, sizeof(resp_buf)); + agent_unlock(); if (err != 0 && resp_buf[0] == '\0') snprintf(resp_buf, sizeof(resp_buf), "Error: agent failed (code %d)", err); return ch->send(msg->session_id, resp_buf, NULL, 0); diff --git a/tests/test_dispatch.c b/tests/test_dispatch.c index 998bb99..0f3b163 100644 --- a/tests/test_dispatch.c +++ b/tests/test_dispatch.c @@ -10,6 +10,7 @@ void bootstrap_set_provider_for_test(const provider_t *provider); void bootstrap_reset_tools_for_test(void); void bootstrap_add_tool_for_test(const tool_t *tool); +#include "core/agent.h" #include "core/config.h" #include "core/dispatch.h" #include "core/memory.h" @@ -40,6 +41,7 @@ static char g_last_session[SEND_BUF_SIZE]; static char g_last_text[SEND_BUF_SIZE]; static int g_send_calls; static size_t g_last_provider_tool_count; +static int g_agent_mutex_held_during_chat; static int mock_send(const char *session_id, const char *text, const channel_attachment_t *attachments, size_t attachments_count) @@ -114,6 +116,29 @@ static const provider_t fail_provider = { .cleanup = spy_cleanup, }; +static int lockcheck_chat(const provider_message_t *messages, size_t message_count, + const provider_tool_def_t *tools, size_t tool_count, + provider_response_t *response) +{ + (void)messages; + (void)message_count; + (void)tools; + (void)tool_count; + g_agent_mutex_held_during_chat = agent_mutex_is_locked_for_test(); + response->error = 0; + response->content = strdup("agent-ok"); + response->tool_calls = NULL; + response->tool_calls_count = 0; + return 0; +} + +static const provider_t lockcheck_provider = { + .name = "lockcheck", + .init = spy_init, + .chat = lockcheck_chat, + .cleanup = spy_cleanup, +}; + static config_t *load_minimal_cfg(const char *path) { config_t *cfg = NULL; @@ -303,6 +328,34 @@ static int test_dispatch_forwards_full_hardware_tool_table(void) return 0; } +static int test_handle_message_holds_agent_mutex(void) +{ + channel_incoming_msg_t msg = {0}; + char tmpl[] = "/tmp/shellclaw_test_dispatch_lock_XXXXXX"; + config_t *cfg = NULL; + int fd; + + reset_send_spy(); + g_agent_mutex_held_during_chat = 0; + fd = mkstemp(tmpl); + ASSERT(fd >= 0); + close(fd); + ASSERT(write_minimal_toml(tmpl) == 0); + cfg = load_minimal_cfg(tmpl); + ASSERT(cfg != NULL); + bootstrap_set_cfg(cfg); + bootstrap_set_provider_for_test(&lockcheck_provider); + bootstrap_reset_tools_for_test(); + msg.session_id = "cli:lock"; + msg.text = "ping"; + ASSERT(handle_message(&mock_channel, &msg) == 0); + ASSERT(g_send_calls == 1); + ASSERT(g_agent_mutex_held_during_chat == 1); + config_free(cfg); + unlink(tmpl); + return 0; +} + int main(void) { RUN(test_reset_clears_session()); @@ -310,6 +363,7 @@ int main(void) RUN(test_agent_failure_fallback_message()); RUN(test_normal_message_uses_provider()); RUN(test_dispatch_forwards_full_hardware_tool_table()); + RUN(test_handle_message_holds_agent_mutex()); puts("test_dispatch OK"); return 0; } From 1c42d58a46f8c40a220a1c1b6b882cef4e026e34 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 01:12:54 -0300 Subject: [PATCH 04/92] fix(dispatch): serialize /reset and assert mutex release /reset raced concurrent ASAP agent_run on session_delete. Hold the same agent mutex and drop it before ch->send. Tests now probe the lock during delete and assert it is free after handle_message returns. Refs: #85 Refs: #54 --- src/core/agent.h | 17 ++++++++++------- src/core/dispatch.c | 5 +++++ src/core/memory.c | 8 ++++++++ src/core/memory.h | 7 +++++++ tests/test_dispatch.c | 13 +++++++++++++ 5 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/core/agent.h b/src/core/agent.h index e85f1c7..a81c6e3 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -49,18 +49,17 @@ int agent_run(const config_t *cfg, const char *session_id, const char *user_mess char *response_buf, size_t response_size); /** - * Acquire the global agent mutex before calling agent_run() from a non-main thread. + * Acquire the global agent mutex before touching shared session/memory state. * - * Ordering rule: threads that call agent_run() (e.g. the inbound ASAP HTTP - * thread and the WebSocket dispatcher) must acquire this mutex first to - * prevent concurrent re-entrant access to shared session/memory state. - * The main-loop thread is the canonical owner; all other callers must use - * agent_lock() / agent_unlock() around every agent_run() invocation. + * Every agent_run() caller (main-loop handle_message, inbound ASAP HTTP, + * WebSocket dispatcher) must hold this mutex for the duration of agent_run(). + * /reset in handle_message must also hold it around session_delete(). + * Release before channel I/O (ch->send). The mutex is not recursive. */ void agent_lock(void); /** - * Release the global agent mutex after agent_run() returns. + * Release the global agent mutex after agent_run() or a locked session_delete(). */ void agent_unlock(void); @@ -71,6 +70,10 @@ void shellclaw_agent_set_test_active_backend_name(const char *name_or_null); * Test-only: probe whether the global agent mutex is currently held. * @return 1 if locked, 0 if free. * + * Relies on a non-recursive mutex: pthread_mutex_trylock from the owning + * thread returns EBUSY. A recursive mutex would make this helper lie + * (trylock succeeds, this function unlocks once, returns 0). + * * Used by dispatch tests to assert handle_message() holds the mutex * for the duration of agent_run() (see #54). */ diff --git a/src/core/dispatch.c b/src/core/dispatch.c index cbf39c6..0639132 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -18,7 +18,12 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) { const char *text = msg->text ? msg->text : ""; if (strcmp(text, "/reset") == 0) { + /* Serialize with ASAP agent_run so session_delete cannot race + * the same session (see #54, review on #85). Drop the lock + * before ch->send so channel I/O does not pin the mutex. */ + agent_lock(); session_delete(msg->session_id); + agent_unlock(); return ch->send(msg->session_id, "Session cleared.", NULL, 0); } if (strcmp(text, "/status") == 0) { diff --git a/src/core/memory.c b/src/core/memory.c index cb763ab..e29eeb2 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -15,6 +15,12 @@ #define WARN_RECREATED "Warning: memory DB invalid or corrupted, recreated at %s\n" static sqlite3 *g_db; +static void (*g_session_delete_hook_for_test)(const char *session_id); + +void session_delete_set_hook_for_test(void (*hook)(const char *session_id)) +{ + g_session_delete_hook_for_test = hook; +} static const char *SCHEMA_MEMORIES = "CREATE TABLE IF NOT EXISTS memories (" @@ -253,6 +259,8 @@ int session_save(const char *session_id, const char *messages) int session_delete(const char *session_id) { + if (g_session_delete_hook_for_test) + g_session_delete_hook_for_test(session_id); if (!g_db || !session_id) return -1; const char *sql = "DELETE FROM sessions WHERE id = ?1"; sqlite3_stmt *stmt = NULL; diff --git a/src/core/memory.h b/src/core/memory.h index 44aac17..62f16eb 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -69,6 +69,13 @@ int session_save(const char *session_id, const char *messages); */ int session_delete(const char *session_id); +/** + * Test-only: invoke @p hook from session_delete before the SQL DELETE. + * Pass NULL to clear. Used by dispatch tests to assert /reset holds the + * agent mutex (see #54). + */ +void session_delete_set_hook_for_test(void (*hook)(const char *session_id)); + /** * List session IDs from the database. * diff --git a/tests/test_dispatch.c b/tests/test_dispatch.c index 0f3b163..074d78c 100644 --- a/tests/test_dispatch.c +++ b/tests/test_dispatch.c @@ -42,6 +42,7 @@ static char g_last_text[SEND_BUF_SIZE]; static int g_send_calls; static size_t g_last_provider_tool_count; static int g_agent_mutex_held_during_chat; +static int g_agent_mutex_held_during_reset; static int mock_send(const char *session_id, const char *text, const channel_attachment_t *attachments, size_t attachments_count) @@ -139,6 +140,12 @@ static const provider_t lockcheck_provider = { .cleanup = spy_cleanup, }; +static void reset_lock_probe(const char *session_id) +{ + (void)session_id; + g_agent_mutex_held_during_reset = agent_mutex_is_locked_for_test(); +} + static config_t *load_minimal_cfg(const char *path) { config_t *cfg = NULL; @@ -199,9 +206,14 @@ static int test_reset_clears_session(void) msg.session_id = "ws:test"; msg.text = "/reset"; + g_agent_mutex_held_during_reset = 0; + session_delete_set_hook_for_test(reset_lock_probe); ASSERT(handle_message(&mock_channel, &msg) == 0); + session_delete_set_hook_for_test(NULL); ASSERT(g_send_calls == 1); ASSERT(strstr(g_last_text, "Session cleared") != NULL); + ASSERT(g_agent_mutex_held_during_reset == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); ASSERT(session_load("ws:test", history, sizeof(history)) != 0); config_free(cfg); @@ -351,6 +363,7 @@ static int test_handle_message_holds_agent_mutex(void) ASSERT(handle_message(&mock_channel, &msg) == 0); ASSERT(g_send_calls == 1); ASSERT(g_agent_mutex_held_during_chat == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); config_free(cfg); unlink(tmpl); return 0; From 7d5951429ad38aa5d55e28d1d38e828b058e0105 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 12:34:16 -0300 Subject: [PATCH 05/92] fix(asap): acquire agent mutex around inbound mcp.tool_call Inbound mcp.tool_call dispatched tools without the global agent mutex while task.request already held it, allowing concurrent session/memory access from HTTP threads during agent_run. Refs: #60 --- src/asap/server.c | 4 ++++ src/core/agent.h | 4 +++- tests/test_asap_server.c | 46 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/asap/server.c b/src/asap/server.c index d681f9b..ae102d5 100644 --- a/src/asap/server.c +++ b/src/asap/server.c @@ -282,11 +282,15 @@ static int handle_mcp_tool_call(const asap_envelope_t *in, asap_envelope_t *out, return -32603; } result_buf[0] = '\0'; + /* Same mutex as task.request / handle_message: inbound tools may + * touch session/memory while another thread is in agent_run (#60). */ + agent_lock(); if (ctx->tool_call_hook) { int hr = ctx->tool_call_hook(ctx, tool_name, args_json, result_buf, RESULT_CAP); exec_rc = hr == 0 ? 0 : 2; } else exec_rc = dispatch_tool_by_name(ctx, tool_name, args_json, result_buf, RESULT_CAP); + agent_unlock(); free(args_json); if (exec_rc == 1) { free(result_buf); diff --git a/src/core/agent.h b/src/core/agent.h index a81c6e3..435ec80 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -54,6 +54,7 @@ int agent_run(const config_t *cfg, const char *session_id, const char *user_mess * Every agent_run() caller (main-loop handle_message, inbound ASAP HTTP, * WebSocket dispatcher) must hold this mutex for the duration of agent_run(). * /reset in handle_message must also hold it around session_delete(). + * Inbound mcp.tool_call must hold it around tool execute (see #60). * Release before channel I/O (ch->send). The mutex is not recursive. */ void agent_lock(void); @@ -75,7 +76,8 @@ void shellclaw_agent_set_test_active_backend_name(const char *name_or_null); * (trylock succeeds, this function unlocks once, returns 0). * * Used by dispatch tests to assert handle_message() holds the mutex - * for the duration of agent_run() (see #54). + * for the duration of agent_run() (see #54), and by ASAP server tests + * for inbound mcp.tool_call (#60). */ int agent_mutex_is_locked_for_test(void); diff --git a/tests/test_asap_server.c b/tests/test_asap_server.c index 1669864..0fe40d4 100644 --- a/tests/test_asap_server.c +++ b/tests/test_asap_server.c @@ -46,6 +46,8 @@ static int test_hook_state_fail(const asap_server_ctx_t *ctx, cJSON **payload_ou return -1; } +static int g_agent_mutex_held_during_mcp_tool; + static int echo_tool_execute(const char *args_json, char *result_buf, size_t max_len) { (void)args_json; @@ -53,6 +55,14 @@ static int echo_tool_execute(const char *args_json, char *result_buf, size_t max return 0; } +static int mutex_probe_tool_execute(const char *args_json, char *result_buf, size_t max_len) +{ + (void)args_json; + g_agent_mutex_held_during_mcp_tool = agent_mutex_is_locked_for_test(); + snprintf(result_buf, max_len, "probe-ok"); + return 0; +} + static int failing_tool_execute(const char *args_json, char *result_buf, size_t max_len) { (void)args_json; @@ -80,6 +90,13 @@ static agent_tool_t s_echo_tool = { .execute = echo_tool_execute, }; +static agent_tool_t s_mutex_probe_tool = { + .name = "mutex_probe", + .description = "", + .parameters_json = "{}", + .execute = mutex_probe_tool_execute, +}; + static agent_tool_t s_flaky_tool = { .name = "flaky", .description = "", @@ -720,6 +737,34 @@ static int test_trust_sender_rejects_blank_sender_when_list_nonempty(void) return 0; } +static int test_mcp_tool_call_holds_agent_mutex(void) +{ + asap_envelope_t in; + asap_envelope_t out; + asap_server_ctx_t ctx; + char err[128]; + cJSON *pl; + int rc; + asap_envelope_init(&in); + asap_envelope_init(&out); + pl = cJSON_CreateObject(); + ASSERT(pl != NULL); + ASSERT(cJSON_AddStringToObject(pl, "name", "mutex_probe") != NULL); + ASSERT(cJSON_AddObjectToObject(pl, "arguments") != NULL); + ASSERT(wrap_build(&in, "mcp.tool_call", pl) == 0); + memset(&ctx, 0, sizeof ctx); + ctx.tools = &s_mutex_probe_tool; + ctx.tool_count = 1; + g_agent_mutex_held_during_mcp_tool = 0; + rc = asap_server_handle(&in, &out, &ctx, err, sizeof err); + ASSERT(rc == 0); + ASSERT(g_agent_mutex_held_during_mcp_tool == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); + teardown_env(&in); + teardown_env(&out); + return 0; +} + int main(void) { int r = 0; @@ -747,5 +792,6 @@ int main(void) r |= test_tool_call_hook_overrides_builtin_dispatch(); r |= test_tool_execute_nonzero_reports_error(); r |= test_trust_sender_rejects_blank_sender_when_list_nonempty(); + r |= test_mcp_tool_call_holds_agent_mutex(); return r; } From cc08b76088abc88f379ec4f3af978973441b28e5 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 13:03:35 -0300 Subject: [PATCH 06/92] fix(asap): hold agent mutex around inbound state.query state.query read SQLite g_db from HTTP threads without agent_lock, the same cross-thread contract as mcp.tool_call. Lock around hook and row-count reads, unlock before envelope JSON, and probe both paths in tests. Refs: #60 --- CHANGELOG.md | 3 + src/asap/server.c | 18 +++++- src/core/agent.h | 6 +- src/core/memory.c | 8 +++ src/core/memory.h | 7 +++ tests/test_asap_server.c | 116 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901fb38..0640027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] +### Fixed +- Inbound ASAP `mcp.tool_call` and `state.query` now hold `agent_lock()` around tool execute and SQLite `g_db` reads, matching `task.request`. + ### Added - Phase 5 documentation suite (`docs/SECURITY.md`, `docs/ASAP.md`, and related guides). - `CONTRIBUTING.md` with PR workflow and pre-tag `gpio-mockup` ritual. diff --git a/src/asap/server.c b/src/asap/server.c index ae102d5..d9e6a47 100644 --- a/src/asap/server.c +++ b/src/asap/server.c @@ -196,13 +196,24 @@ static int handle_state_query(const asap_envelope_t *in, asap_envelope_t *out, int sess = 0; int mem = 0; int cron = 0; + int hook_rc = 0; + int counts_rc = 0; + /* Same mutex as mcp.tool_call / task.request: g_db is not safe from an + * HTTP thread while another thread is in agent_run (#60). */ + agent_lock(); if (ctx->state_query_hook) { - if (ctx->state_query_hook(ctx, &pl) != 0 || !pl) { + hook_rc = ctx->state_query_hook(ctx, &pl); + } else { + counts_rc = memory_get_row_counts(&sess, &mem, &cron); + } + agent_unlock(); + if (ctx->state_query_hook) { + if (hook_rc != 0 || !pl) { set_err(err_message, err_message_size, "state.query: hook failed"); return -32603; } } else { - if (memory_get_row_counts(&sess, &mem, &cron) != 0) { + if (counts_rc != 0) { set_err(err_message, err_message_size, "state.query: memory store unavailable"); return -32603; } @@ -288,8 +299,9 @@ static int handle_mcp_tool_call(const asap_envelope_t *in, asap_envelope_t *out, if (ctx->tool_call_hook) { int hr = ctx->tool_call_hook(ctx, tool_name, args_json, result_buf, RESULT_CAP); exec_rc = hr == 0 ? 0 : 2; - } else + } else { exec_rc = dispatch_tool_by_name(ctx, tool_name, args_json, result_buf, RESULT_CAP); + } agent_unlock(); free(args_json); if (exec_rc == 1) { diff --git a/src/core/agent.h b/src/core/agent.h index 435ec80..41d8373 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -55,12 +55,14 @@ int agent_run(const config_t *cfg, const char *session_id, const char *user_mess * WebSocket dispatcher) must hold this mutex for the duration of agent_run(). * /reset in handle_message must also hold it around session_delete(). * Inbound mcp.tool_call must hold it around tool execute (see #60). + * Inbound state.query must hold it around memory_get_row_counts() (see #60). * Release before channel I/O (ch->send). The mutex is not recursive. */ void agent_lock(void); /** - * Release the global agent mutex after agent_run() or a locked session_delete(). + * Release the global agent mutex after agent_run(), a locked session_delete(), + * inbound mcp.tool_call execute, or a locked state.query memory read. */ void agent_unlock(void); @@ -77,7 +79,7 @@ void shellclaw_agent_set_test_active_backend_name(const char *name_or_null); * * Used by dispatch tests to assert handle_message() holds the mutex * for the duration of agent_run() (see #54), and by ASAP server tests - * for inbound mcp.tool_call (#60). + * for inbound mcp.tool_call and state.query (#60). */ int agent_mutex_is_locked_for_test(void); diff --git a/src/core/memory.c b/src/core/memory.c index e29eeb2..a949f93 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -16,12 +16,18 @@ static sqlite3 *g_db; static void (*g_session_delete_hook_for_test)(const char *session_id); +static void (*g_memory_get_row_counts_hook_for_test)(void); void session_delete_set_hook_for_test(void (*hook)(const char *session_id)) { g_session_delete_hook_for_test = hook; } +void memory_get_row_counts_set_hook_for_test(void (*hook)(void)) +{ + g_memory_get_row_counts_hook_for_test = hook; +} + static const char *SCHEMA_MEMORIES = "CREATE TABLE IF NOT EXISTS memories (" " rowid INTEGER PRIMARY KEY," @@ -467,6 +473,8 @@ static int count_table(const char *sql, int *out_count) int memory_get_row_counts(int *sessions_out, int *memories_out, int *cron_jobs_out) { + if (g_memory_get_row_counts_hook_for_test) + g_memory_get_row_counts_hook_for_test(); if (!g_db) return -1; if (sessions_out) { if (count_table("SELECT COUNT(*) FROM sessions", sessions_out) != 0) return -1; diff --git a/src/core/memory.h b/src/core/memory.h index 62f16eb..147e5a9 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -76,6 +76,13 @@ int session_delete(const char *session_id); */ void session_delete_set_hook_for_test(void (*hook)(const char *session_id)); +/** + * Test-only: invoke @p hook from memory_get_row_counts before the COUNT queries. + * Pass NULL to clear. Used by ASAP server tests to assert state.query holds + * the agent mutex (see #60). + */ +void memory_get_row_counts_set_hook_for_test(void (*hook)(void)); + /** * List session IDs from the database. * diff --git a/tests/test_asap_server.c b/tests/test_asap_server.c index 0fe40d4..8ce0c80 100644 --- a/tests/test_asap_server.c +++ b/tests/test_asap_server.c @@ -47,6 +47,8 @@ static int test_hook_state_fail(const asap_server_ctx_t *ctx, cJSON **payload_ou } static int g_agent_mutex_held_during_mcp_tool; +static int g_agent_mutex_held_during_mcp_hook; +static int g_agent_mutex_held_during_state_query; static int echo_tool_execute(const char *args_json, char *result_buf, size_t max_len) { @@ -83,6 +85,37 @@ static int hook_tool_dispatcher(const asap_server_ctx_t *ctx, const char *tool_n return -1; } +static int mutex_probe_tool_hook(const asap_server_ctx_t *ctx, const char *tool_name, + const char *args_json, char *result_buf, size_t result_cap) +{ + (void)ctx; + (void)tool_name; + (void)args_json; + g_agent_mutex_held_during_mcp_hook = agent_mutex_is_locked_for_test(); + snprintf(result_buf, result_cap, "hook-probe-ok"); + return 0; +} + +static void mutex_probe_row_counts(void) +{ + g_agent_mutex_held_during_state_query = agent_mutex_is_locked_for_test(); +} + +static int mutex_probe_state_query_hook(const asap_server_ctx_t *ctx, cJSON **payload_out) +{ + cJSON *o; + (void)ctx; + g_agent_mutex_held_during_state_query = agent_mutex_is_locked_for_test(); + o = cJSON_CreateObject(); + if (!o) return -1; + if (!cJSON_AddNumberToObject(o, "probed", 1.0)) { + cJSON_Delete(o); + return -1; + } + *payload_out = o; + return 0; +} + static agent_tool_t s_echo_tool = { .name = "echo", .description = "", @@ -765,6 +798,86 @@ static int test_mcp_tool_call_holds_agent_mutex(void) return 0; } +static int test_mcp_tool_call_hook_holds_agent_mutex(void) +{ + asap_envelope_t in; + asap_envelope_t out; + asap_server_ctx_t ctx; + char err[128]; + cJSON *pl; + int rc; + pl = cJSON_CreateObject(); + ASSERT(pl != NULL); + ASSERT(cJSON_AddStringToObject(pl, "name", "echo") != NULL); + ASSERT(cJSON_AddObjectToObject(pl, "arguments") != NULL); + ASSERT(wrap_build(&in, "mcp.tool_call", pl) == 0); + memset(&ctx, 0, sizeof ctx); + ctx.tool_call_hook = mutex_probe_tool_hook; + g_agent_mutex_held_during_mcp_hook = 0; + rc = asap_server_handle(&in, &out, &ctx, err, sizeof err); + ASSERT(rc == 0); + ASSERT(g_agent_mutex_held_during_mcp_hook == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); + teardown_env(&in); + teardown_env(&out); + return 0; +} + +static int test_state_query_memory_holds_agent_mutex(void) +{ + char tmpl[] = "/tmp/sc_asap_srv_lock_XXXXXX"; + int fd; + asap_envelope_t in; + asap_envelope_t out; + asap_server_ctx_t ctx; + char err[128]; + cJSON *pl; + int rc; + fd = mkstemp(tmpl); + ASSERT(fd >= 0); + close(fd); + ASSERT(memory_init(tmpl) == 0); + pl = cJSON_CreateObject(); + ASSERT(pl != NULL); + ASSERT(wrap_build(&in, "state.query", pl) == 0); + memset(&ctx, 0, sizeof ctx); + g_agent_mutex_held_during_state_query = 0; + memory_get_row_counts_set_hook_for_test(mutex_probe_row_counts); + rc = asap_server_handle(&in, &out, &ctx, err, sizeof err); + memory_get_row_counts_set_hook_for_test(NULL); + ASSERT(rc == 0); + ASSERT(g_agent_mutex_held_during_state_query == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); + memory_cleanup(); + unlink(tmpl); + teardown_env(&in); + teardown_env(&out); + return 0; +} + +static int test_state_query_hook_holds_agent_mutex(void) +{ + asap_envelope_t in; + asap_envelope_t out; + asap_server_ctx_t ctx; + char err[128]; + cJSON *pl; + int rc; + pl = cJSON_CreateObject(); + ASSERT(pl != NULL); + ASSERT(wrap_build(&in, "state.query", pl) == 0); + memset(&ctx, 0, sizeof ctx); + ctx.state_query_hook = mutex_probe_state_query_hook; + g_agent_mutex_held_during_state_query = 0; + rc = asap_server_handle(&in, &out, &ctx, err, sizeof err); + ASSERT(rc == 0); + ASSERT(g_agent_mutex_held_during_state_query == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); + teardown_env(&in); + teardown_env(&out); + return 0; +} + int main(void) { int r = 0; @@ -793,5 +906,8 @@ int main(void) r |= test_tool_execute_nonzero_reports_error(); r |= test_trust_sender_rejects_blank_sender_when_list_nonempty(); r |= test_mcp_tool_call_holds_agent_mutex(); + r |= test_mcp_tool_call_hook_holds_agent_mutex(); + r |= test_state_query_memory_holds_agent_mutex(); + r |= test_state_query_hook_holds_agent_mutex(); return r; } From 3f706d6bcf0d96ce4fddcfae98f1f3d1ff365329 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 13:03:38 -0300 Subject: [PATCH 07/92] docs: document agent_lock around mcp.tool_call and state.query Thread-safety notes still described the mutex as agent_run-only. Align README, ARCHITECTURE, and CONTRIBUTING with the inbound ASAP callers. Refs: #60 --- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 971ddfa..31eec4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ ShellClaw is C99/C11. Follow the project rule files (summarized here — full de - New tools → `src/tools/.c` + `tests/test_.c` + Makefile target. - New hardware backend → `src/hardware/` with board-specific code under `src/hardware/boards/`. - Config and secrets via TOML + environment variables only — see [`.env.example`](.env.example). -- Thread safety: inbound HTTP/WebSocket paths must use `agent_lock()` / `agent_unlock()` around `agent_run()` (see README § Thread Safety). +- Thread safety: inbound HTTP/WebSocket paths must use `agent_lock()` / `agent_unlock()` around `agent_run()`, inbound ASAP `mcp.tool_call` execute, and `state.query` memory-store reads (see README § Thread Safety). ## Testing diff --git a/README.md b/README.md index 8624c5f..1afa7a3 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for PR workflow, coding standards, and th The **main agent loop** is single-threaded: memory, providers, channels and tools keep much of their state in process-wide data initialized at startup. **Inbound HTTP/WebSocket paths** (for example ASAP `POST /asap` and the WebSocket chat dispatcher) may run on **libwebsockets worker threads**. -Those code paths must call `agent_lock()` before `agent_run()` and `agent_unlock()` afterward so only one `agent_run` uses shared session/memory state at a time. Do not call `agent_run`, provider `chat`, or memory functions from arbitrary new threads without the same discipline. +Those code paths must call `agent_lock()` / `agent_unlock()` around `agent_run()`, inbound ASAP `mcp.tool_call` execute, and `state.query` memory-store reads so only one thread uses shared session/memory state at a time. Do not call `agent_run`, provider `chat`, or memory functions from arbitrary new threads without the same discipline. ## Architecture diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 119c3e5..a7b24d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -78,7 +78,7 @@ Convention: one primary `.c` + `.h` per module; new tools go in `src/tools/ Date: Sat, 12 Sep 2026 13:31:15 -0300 Subject: [PATCH 08/92] fix(asap): isolate inbound task.request sessions by sender URN Inbound task.request used the hardcoded session "asap:inbound", leaking conversation history across unrelated clients. Derive per-sender session ids from the envelope sender URN. Refs: #64 --- src/asap/server.c | 16 +++++- src/asap/server.h | 11 ++++ tests/test_asap_server.c | 119 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/asap/server.c b/src/asap/server.c index d9e6a47..b6e0f56 100644 --- a/src/asap/server.c +++ b/src/asap/server.c @@ -112,11 +112,24 @@ static int fill_response_envelope(asap_envelope_t *out, const asap_envelope_t *i return 0; } +const char *asap_resolve_task_session_id(const char *ctx_session_id, const char *sender, + char *buf, size_t buf_size) +{ + if (ctx_session_id && ctx_session_id[0] != '\0') + return ctx_session_id; + if (sender && sender[0] != '\0' && buf != NULL && buf_size > 0) { + snprintf(buf, buf_size, "asap:%s", sender); + return buf; + } + return "asap:inbound"; +} + static int handle_task_request(const asap_envelope_t *in, asap_envelope_t *out, asap_server_ctx_t *ctx, char *err_message, size_t err_message_size) { char *prompt; char *resp_buf; + char sid_buf[256]; const char *sid; int ar; cJSON *pl; @@ -134,7 +147,8 @@ static int handle_task_request(const asap_envelope_t *in, asap_envelope_t *out, return -32603; } resp_buf[0] = '\0'; - sid = ctx->session_id ? ctx->session_id : "asap:inbound"; + /* Isolate SQLite history by sender URN; "asap:inbound" mixed clients (#64). */ + sid = asap_resolve_task_session_id(ctx->session_id, in->sender, sid_buf, sizeof sid_buf); if (ctx->task_request_hook) ar = ctx->task_request_hook(ctx, in, resp_buf, (size_t)ASAP_SERVER_AGENT_RESPONSE_CAP); else { diff --git a/src/asap/server.h b/src/asap/server.h index 3762d94..65d48bf 100644 --- a/src/asap/server.h +++ b/src/asap/server.h @@ -56,6 +56,17 @@ typedef struct asap_server_ctx { int asap_server_handle(const asap_envelope_t *in, asap_envelope_t *out, asap_server_ctx_t *ctx, char *err_message, size_t err_message_size); +/** + * Resolve the agent session id for inbound task.request. + * Explicit @p ctx_session_id wins; otherwise derives `asap:` from the + * envelope sender URN so unrelated clients do not share SQLite history (#64). + * + * Example: asap_resolve_task_session_id(NULL, "urn:alice", buf, sizeof buf) + * returns "asap:urn:alice". + */ +const char *asap_resolve_task_session_id(const char *ctx_session_id, const char *sender, + char *buf, size_t buf_size); + #ifdef __cplusplus } #endif diff --git a/tests/test_asap_server.c b/tests/test_asap_server.c index 8ce0c80..d2bd05f 100644 --- a/tests/test_asap_server.c +++ b/tests/test_asap_server.c @@ -8,6 +8,7 @@ #include "asap/envelope.h" #include "core/config.h" #include "core/memory.h" +#include "providers/provider.h" #include "cJSON.h" #include #include @@ -25,6 +26,37 @@ static int test_hook_task(const asap_server_ctx_t *ctx, const asap_envelope_t *i return 0; } +static int isolate_provider_init(const config_t *cfg) +{ + (void)cfg; + return 0; +} + +static int isolate_provider_chat(const provider_message_t *messages, size_t message_count, + const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) +{ + (void)messages; + (void)message_count; + (void)tools; + (void)tool_count; + response->error = 0; + response->content = strdup("isolate-ok"); + response->tool_calls = NULL; + response->tool_calls_count = 0; + return 0; +} + +static void isolate_provider_cleanup(void) +{ +} + +static const provider_t s_isolate_provider = { + .name = "isolate", + .init = isolate_provider_init, + .chat = isolate_provider_chat, + .cleanup = isolate_provider_cleanup, +}; + static int test_hook_state(const asap_server_ctx_t *ctx, cJSON **payload_out) { cJSON *o; @@ -177,6 +209,13 @@ static int wrap_build(asap_envelope_t *e, const char *ptype, cJSON *payload) return rc; } +static int wrap_build_from(asap_envelope_t *e, const char *ptype, const char *sender, cJSON *payload) +{ + int rc = build_in_custom(e, ptype, sender, "urn:to", payload); + cJSON_Delete(payload); + return rc; +} + static void teardown_env(asap_envelope_t *e) { asap_envelope_clear(e); @@ -736,6 +775,84 @@ static int test_tool_execute_nonzero_reports_error(void) return 0; } +static int submit_task_request(asap_server_ctx_t *ctx, const char *sender, const char *input) +{ + asap_envelope_t in; + asap_envelope_t out; + char err[192]; + cJSON *pl; + int rc; + asap_envelope_init(&in); + asap_envelope_init(&out); + pl = cJSON_CreateObject(); + if (!pl || !cJSON_AddStringToObject(pl, "input", input)) { + if (pl) cJSON_Delete(pl); + return -1; + } + if (wrap_build_from(&in, "task.request", sender, pl) != 0) + return -1; + rc = asap_server_handle(&in, &out, ctx, err, sizeof err); + teardown_env(&in); + teardown_env(&out); + return rc; +} + +static int test_task_request_isolates_sessions_by_sender(void) +{ + char tmpl[] = "/tmp/sc_asap_sid_XXXXXX"; + int fd; + const char *cfg_path = "/tmp/shellclaw_test_asap_sid.toml"; + FILE *f; + config_t *cfg = NULL; + asap_server_ctx_t ctx; + char loaded[4096]; + int rc = 1; + fd = mkstemp(tmpl); + ASSERT(fd >= 0); + close(fd); + f = fopen(cfg_path, "w"); + ASSERT(f != NULL); + fprintf(f, "[agent]\nmodel = \"test\"\n[memory]\npath = \"%s\"\n", tmpl); + fclose(f); + ASSERT(memory_init(tmpl) == 0); + ASSERT(config_load(cfg_path, &cfg, NULL, 0) == 0); + memset(&ctx, 0, sizeof ctx); + ctx.cfg = cfg; + ctx.provider = &s_isolate_provider; + ASSERT(submit_task_request(&ctx, "urn:alice", "alice-secret") == 0); + ASSERT(session_load("asap:urn:alice", loaded, sizeof loaded) == 0); + ASSERT(strstr(loaded, "alice-secret") != NULL); + ASSERT(submit_task_request(&ctx, "urn:bob", "bob-hello") == 0); + ASSERT(session_load("asap:urn:bob", loaded, sizeof loaded) == 0); + ASSERT(strstr(loaded, "bob-hello") != NULL); + ASSERT(strstr(loaded, "alice-secret") == NULL); + rc = 0; + config_free(cfg); + memory_cleanup(); + unlink(tmpl); + remove(cfg_path); + return rc; +} + +static int test_resolve_task_session_id(void) +{ + char buf[128]; + const char *sid; + sid = asap_resolve_task_session_id("cli:override", "urn:alice", buf, sizeof buf); + ASSERT(sid != NULL && strcmp(sid, "cli:override") == 0); + sid = asap_resolve_task_session_id(NULL, "urn:alice", buf, sizeof buf); + ASSERT(sid == buf); + ASSERT(strcmp(sid, "asap:urn:alice") == 0); + sid = asap_resolve_task_session_id("", "urn:bob", buf, sizeof buf); + ASSERT(sid == buf); + ASSERT(strcmp(sid, "asap:urn:bob") == 0); + sid = asap_resolve_task_session_id(NULL, NULL, buf, sizeof buf); + ASSERT(sid != NULL && strcmp(sid, "asap:inbound") == 0); + sid = asap_resolve_task_session_id(NULL, "", buf, sizeof buf); + ASSERT(sid != NULL && strcmp(sid, "asap:inbound") == 0); + return 0; +} + static int test_trust_sender_rejects_blank_sender_when_list_nonempty(void) { const char *path = "/tmp/shellclaw_test_asap_trust_blank.toml"; @@ -909,5 +1026,7 @@ int main(void) r |= test_mcp_tool_call_hook_holds_agent_mutex(); r |= test_state_query_memory_holds_agent_mutex(); r |= test_state_query_hook_holds_agent_mutex(); + r |= test_resolve_task_session_id(); + r |= test_task_request_isolates_sessions_by_sender(); return r; } From 72e9c6298120a45564080b8b78fde24370b00439 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 13:44:12 -0300 Subject: [PATCH 09/92] fix(asap): fail closed on oversized task.request sender URNs snprintf into the session-id buffer would clip long senders and reintroduce history collisions. Match discord session helpers: reject instead of truncating. Always derive the id from the envelope sender so a ctx override cannot restore a shared bucket. Refs: #64 --- src/asap/server.c | 43 +++++++++++++++++++++++++---------- src/asap/server.h | 11 +++++---- tests/test_asap_server.c | 49 ++++++++++++++++++++++++++++++++++------ 3 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/asap/server.c b/src/asap/server.c index b6e0f56..b62cf0d 100644 --- a/src/asap/server.c +++ b/src/asap/server.c @@ -17,6 +17,7 @@ #include enum { ASAP_SERVER_AGENT_RESPONSE_CAP = 256 * 1024 }; +enum { ASAP_TASK_SESSION_ID_CAP = 512 }; static void set_err(char *buf, size_t sz, const char *msg) { @@ -112,16 +113,21 @@ static int fill_response_envelope(asap_envelope_t *out, const asap_envelope_t *i return 0; } -const char *asap_resolve_task_session_id(const char *ctx_session_id, const char *sender, - char *buf, size_t buf_size) +const char *asap_resolve_task_session_id(const char *sender, char *buf, size_t buf_size) { - if (ctx_session_id && ctx_session_id[0] != '\0') - return ctx_session_id; - if (sender && sender[0] != '\0' && buf != NULL && buf_size > 0) { - snprintf(buf, buf_size, "asap:%s", sender); - return buf; - } - return "asap:inbound"; + size_t need; + int n; + if (!sender || sender[0] == '\0') + return "asap:inbound"; + if (!buf || buf_size == 0) + return NULL; + need = strlen("asap:") + strlen(sender) + 1; + if (need > buf_size) + return NULL; + n = snprintf(buf, buf_size, "asap:%s", sender); + if (n < 0 || (size_t)n >= buf_size) + return NULL; + return buf; } static int handle_task_request(const asap_envelope_t *in, asap_envelope_t *out, @@ -129,7 +135,7 @@ static int handle_task_request(const asap_envelope_t *in, asap_envelope_t *out, { char *prompt; char *resp_buf; - char sid_buf[256]; + char sid_buf[ASAP_TASK_SESSION_ID_CAP]; const char *sid; int ar; cJSON *pl; @@ -147,8 +153,21 @@ static int handle_task_request(const asap_envelope_t *in, asap_envelope_t *out, return -32603; } resp_buf[0] = '\0'; - /* Isolate SQLite history by sender URN; "asap:inbound" mixed clients (#64). */ - sid = asap_resolve_task_session_id(ctx->session_id, in->sender, sid_buf, sizeof sid_buf); + /* Isolate SQLite history by sender URN; "asap:inbound" mixed clients (#64). + * Ignore ctx->session_id so a later constant (including asap:inbound) + * cannot restore a shared bucket. Authenticity is sender_is_trusted(): + * POST /asap is protocol-public; empty trusted_senders allows every URN. */ + sid = asap_resolve_task_session_id(in->sender, sid_buf, sizeof sid_buf); + if (!sid) { + char too_long[128]; + free(resp_buf); + free(prompt); + snprintf(too_long, sizeof too_long, + "task.request: sender URN length %zu exceeds session id cap %d", + in->sender ? strlen(in->sender) : 0, ASAP_TASK_SESSION_ID_CAP); + set_err(err_message, err_message_size, too_long); + return -32602; + } if (ctx->task_request_hook) ar = ctx->task_request_hook(ctx, in, resp_buf, (size_t)ASAP_SERVER_AGENT_RESPONSE_CAP); else { diff --git a/src/asap/server.h b/src/asap/server.h index 65d48bf..f6ca15a 100644 --- a/src/asap/server.h +++ b/src/asap/server.h @@ -58,14 +58,15 @@ int asap_server_handle(const asap_envelope_t *in, asap_envelope_t *out, /** * Resolve the agent session id for inbound task.request. - * Explicit @p ctx_session_id wins; otherwise derives `asap:` from the - * envelope sender URN so unrelated clients do not share SQLite history (#64). + * Derives `asap:` from the envelope sender URN so unrelated clients + * do not share SQLite history (#64). Missing sender falls back to + * `asap:inbound`. Returns NULL when @p buf cannot hold the derived id + * (fail closed; do not truncate). * - * Example: asap_resolve_task_session_id(NULL, "urn:alice", buf, sizeof buf) + * Example: asap_resolve_task_session_id("urn:alice", buf, sizeof buf) * returns "asap:urn:alice". */ -const char *asap_resolve_task_session_id(const char *ctx_session_id, const char *sender, - char *buf, size_t buf_size); +const char *asap_resolve_task_session_id(const char *sender, char *buf, size_t buf_size); #ifdef __cplusplus } diff --git a/tests/test_asap_server.c b/tests/test_asap_server.c index d2bd05f..2408c58 100644 --- a/tests/test_asap_server.c +++ b/tests/test_asap_server.c @@ -812,7 +812,7 @@ static int test_task_request_isolates_sessions_by_sender(void) close(fd); f = fopen(cfg_path, "w"); ASSERT(f != NULL); - fprintf(f, "[agent]\nmodel = \"test\"\n[memory]\npath = \"%s\"\n", tmpl); + fprintf(f, "[agent]\nmodel = \"test\"\n[memory]\ndb_path = \"%s\"\n", tmpl); fclose(f); ASSERT(memory_init(tmpl) == 0); ASSERT(config_load(cfg_path, &cfg, NULL, 0) == 0); @@ -826,6 +826,10 @@ static int test_task_request_isolates_sessions_by_sender(void) ASSERT(session_load("asap:urn:bob", loaded, sizeof loaded) == 0); ASSERT(strstr(loaded, "bob-hello") != NULL); ASSERT(strstr(loaded, "alice-secret") == NULL); + ASSERT(session_load("asap:urn:alice", loaded, sizeof loaded) == 0); + ASSERT(strstr(loaded, "alice-secret") != NULL); + ASSERT(strstr(loaded, "bob-hello") == NULL); + ASSERT(session_load("asap:inbound", loaded, sizeof loaded) != 0); rc = 0; config_free(cfg); memory_cleanup(); @@ -837,19 +841,49 @@ static int test_task_request_isolates_sessions_by_sender(void) static int test_resolve_task_session_id(void) { char buf[128]; + char tiny[8]; const char *sid; - sid = asap_resolve_task_session_id("cli:override", "urn:alice", buf, sizeof buf); - ASSERT(sid != NULL && strcmp(sid, "cli:override") == 0); - sid = asap_resolve_task_session_id(NULL, "urn:alice", buf, sizeof buf); + sid = asap_resolve_task_session_id("urn:alice", buf, sizeof buf); ASSERT(sid == buf); ASSERT(strcmp(sid, "asap:urn:alice") == 0); - sid = asap_resolve_task_session_id("", "urn:bob", buf, sizeof buf); + sid = asap_resolve_task_session_id("urn:bob", buf, sizeof buf); ASSERT(sid == buf); ASSERT(strcmp(sid, "asap:urn:bob") == 0); - sid = asap_resolve_task_session_id(NULL, NULL, buf, sizeof buf); + sid = asap_resolve_task_session_id(NULL, buf, sizeof buf); ASSERT(sid != NULL && strcmp(sid, "asap:inbound") == 0); - sid = asap_resolve_task_session_id(NULL, "", buf, sizeof buf); + sid = asap_resolve_task_session_id("", buf, sizeof buf); ASSERT(sid != NULL && strcmp(sid, "asap:inbound") == 0); + sid = asap_resolve_task_session_id("urn:alice", tiny, sizeof tiny); + ASSERT(sid == NULL); + sid = asap_resolve_task_session_id("urn:alice", buf, 0); + ASSERT(sid == NULL); + return 0; +} + +static int test_task_request_rejects_oversized_sender(void) +{ + asap_envelope_t in; + asap_envelope_t out; + asap_server_ctx_t ctx; + char err[192]; + char sender[600]; + cJSON *pl; + int rc; + asap_envelope_init(&in); + asap_envelope_init(&out); + memset(sender, 'x', sizeof sender - 1); + sender[sizeof sender - 1] = '\0'; + pl = cJSON_CreateObject(); + ASSERT(pl != NULL); + ASSERT(cJSON_AddStringToObject(pl, "input", "hi") != NULL); + ASSERT(wrap_build_from(&in, "task.request", sender, pl) == 0); + memset(&ctx, 0, sizeof ctx); + ctx.task_request_hook = test_hook_task; + rc = asap_server_handle(&in, &out, &ctx, err, sizeof err); + ASSERT(rc == -32602); + ASSERT(strstr(err, "too long") != NULL || strstr(err, "exceeds") != NULL); + teardown_env(&in); + teardown_env(&out); return 0; } @@ -1027,6 +1061,7 @@ int main(void) r |= test_state_query_memory_holds_agent_mutex(); r |= test_state_query_hook_holds_agent_mutex(); r |= test_resolve_task_session_id(); + r |= test_task_request_rejects_oversized_sender(); r |= test_task_request_isolates_sessions_by_sender(); return r; } From 5bc9c57087d996c662079dc1cda3bc41dbaf201a Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 13:44:12 -0300 Subject: [PATCH 10/92] docs(asap): document trusted_senders for inbound session isolation POST /asap is protocol-public; empty trusted_senders allows every claimed URN. Production must set the allowlist so asap: sessions cannot be spoofed. Refs: #64 --- config.example.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config.example.toml b/config.example.toml index b666cfc..31d0411 100644 --- a/config.example.toml +++ b/config.example.toml @@ -74,6 +74,10 @@ description = "C-native edge-AI ASAP agent on NVIDIA Jetson Orin Nano Super (CUD # Production: set to your HTTPS origin before marketplace registration (example.com is dev-only). public_base_url = "https://shellclaw.example.com" registry_url = "https://raw.githubusercontent.com/asap-protocol/asap-protocol/main/registry.json" +# POST /asap is protocol-public (rate-limited, not Bearer). Empty trusted_senders +# allows every sender URN. Set this in production so task.request sessions +# keyed as asap: cannot be spoofed by claiming another client's URN. +# trusted_senders = ["urn:asap:agent:peer"] [asap.skill_descriptions] # assistant = "Override text for manifest capabilities.skills[].description" From 9aaf8a69500ce15c690742b4e4bc9ec5fcc92d7d Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 14:25:53 -0300 Subject: [PATCH 11/92] fix(asap): wire provider and tools into POST /asap handle_asap zeroed asap_server_ctx_t and only set cfg, so inbound task.request always failed with "server missing cfg or provider" and mcp.tool_call saw an empty tool table. Bind the bootstrap provider and flattened tools the same way handle_message does. Refs: #53 --- src/gateway/routes.c | 45 +++++++++++++++++++++++++++---- tests/test_gateway_http.c | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 24cb6ef..b355bed 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -14,6 +14,8 @@ #include "asap/envelope.h" #include "asap/server.h" #include "asap/log.h" +#include "core/agent.h" +#include "core/bootstrap.h" #include "core/config.h" #include "core/memory.h" #include "core/skill.h" @@ -555,6 +557,36 @@ static void handle_asap_log_get(char *buf, size_t size, int *status) free(s); } +/** + * Bind the running process into an inbound ASAP ctx. handle_asap used to + * set only cfg, so task.request always failed with "server missing cfg or + * provider" and mcp.tool_call saw an empty tool table (#53). + */ +static void asap_ctx_bind_bootstrap(asap_server_ctx_t *asap_ctx, const config_t *http_cfg, + agent_tool_t *flat_tools, size_t tools_cap) +{ + size_t tool_count = bootstrap_tool_count(); + size_t i; + if (tool_count > tools_cap) + tool_count = tools_cap; + for (i = 0; i < tool_count; i++) { + const tool_t *t = bootstrap_tool_at(i); + if (!t) { + tool_count = i; + break; + } + flat_tools[i].name = t->name; + flat_tools[i].description = t->description; + flat_tools[i].parameters_json = t->parameters_json; + flat_tools[i].execute = t->execute; + } + memset(asap_ctx, 0, sizeof *asap_ctx); + asap_ctx->cfg = http_cfg ? http_cfg : bootstrap_get_cfg(); + asap_ctx->provider = bootstrap_get_provider(); + asap_ctx->tools = flat_tools; + asap_ctx->tool_count = tool_count; +} + static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, const char *body, size_t body_len, char *buf, size_t size, int *status) { @@ -584,11 +616,14 @@ static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, snippet = in.payload ? cJSON_PrintUnformatted(in.payload) : NULL; asap_log_append_in(in.payload_type, in.id, snippet); free(snippet); - memset(&asap_ctx, 0, sizeof asap_ctx); - asap_ctx.cfg = ctx ? ctx->cfg : NULL; - err_msg[0] = '\0'; - asap_envelope_init(&out); - rc = asap_server_handle(&in, &out, &asap_ctx, err_msg, sizeof err_msg); + { + agent_tool_t flat_tools[SHELLCLAW_MAX_TOOLS]; + asap_ctx_bind_bootstrap(&asap_ctx, ctx ? ctx->cfg : NULL, + flat_tools, SHELLCLAW_MAX_TOOLS); + err_msg[0] = '\0'; + asap_envelope_init(&out); + rc = asap_server_handle(&in, &out, &asap_ctx, err_msg, sizeof err_msg); + } asap_envelope_clear(&in); if (rc != 0) { asap_envelope_clear(&out); diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 13b3abf..4a60cca 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -420,6 +420,60 @@ static int test_asap_missing_fields(void) return 0; } +static int test_asap_task_request(void) +{ + long code; + char *body = NULL; + static const char *req = + "{\"jsonrpc\":\"2.0\",\"method\":\"asap.send\"," + "\"params\":{" + "\"id\":\"01HZABC123\"," + "\"asap_version\":\"2.1\"," + "\"sender\":\"urn:asap:agent:a\"," + "\"recipient\":\"urn:asap:agent:b\"," + "\"payload_type\":\"task.request\"," + "\"payload\":{\"input\":\"hello\"}," + "\"correlation_id\":\"c1\"," + "\"trace_id\":\"t1\"," + "\"timestamp\":\"2026-01-01T00:00:00Z\"" + "},\"id\":42}"; + int r = http_post(gw_url("/asap"), req, &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "task.response") != NULL); + ASSERT(strstr(body, "server missing cfg or provider") == NULL); + free(body); + return 0; +} + +static int test_asap_mcp_tool_call(void) +{ + long code; + char *body = NULL; + static const char *req = + "{\"jsonrpc\":\"2.0\",\"method\":\"asap.send\"," + "\"params\":{" + "\"id\":\"01HZABC124\"," + "\"asap_version\":\"2.1\"," + "\"sender\":\"urn:asap:agent:a\"," + "\"recipient\":\"urn:asap:agent:b\"," + "\"payload_type\":\"mcp.tool_call\"," + "\"payload\":{\"name\":\"cron\",\"arguments\":{\"operation\":\"list\"}}," + "\"correlation_id\":\"c2\"," + "\"trace_id\":\"t2\"," + "\"timestamp\":\"2026-01-01T00:00:00Z\"" + "},\"id\":43}"; + int r = http_post(gw_url("/asap"), req, &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "mcp.tool_result") != NULL); + ASSERT(strstr(body, "unknown tool") == NULL); + free(body); + return 0; +} + static int test_manifest(void) { long code; @@ -1056,6 +1110,8 @@ int main(int argc, char **argv) } if (test_asap_invalid_body() != 0) { fprintf(stderr, "test_asap_invalid_body failed\n"); failed++; } if (test_asap_missing_fields() != 0) { fprintf(stderr, "test_asap_missing_fields failed\n"); failed++; } + if (test_asap_task_request() != 0) { fprintf(stderr, "test_asap_task_request failed\n"); failed++; } + if (test_asap_mcp_tool_call() != 0) { fprintf(stderr, "test_asap_mcp_tool_call failed\n"); failed++; } if (test_api_asap_log_401() != 0) { fprintf(stderr, "test_api_asap_log_401 failed\n"); failed++; } if (test_api_hardware_board_401() != 0) { fprintf(stderr, "test_api_hardware_board_401 failed\n"); From 6a876872ea3ef1f89d919e03a3f8fef2ddc7e199 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 14:39:31 -0300 Subject: [PATCH 12/92] refactor(core): share bootstrap_fill_agent_tools with POST /asap handle_message and handle_asap both flattened the tool table by hand. One helper keeps the filled count honest on a NULL slot. Refs: #53 --- Makefile | 6 +++--- src/core/bootstrap.c | 22 ++++++++++++++++++++++ src/core/bootstrap.h | 10 ++++++++++ src/core/dispatch.c | 13 +------------ src/gateway/routes.c | 18 +----------------- tests/stubs/bootstrap_dispatch_stub.c | 23 +++++++++++++++++++++++ 6 files changed, 60 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index c075461..ea36634 100644 --- a/Makefile +++ b/Makefile @@ -197,10 +197,10 @@ $(DAEMON_O): src/core/daemon.c src/core/daemon.h src/core/config.h $(RELOAD_O): src/core/reload.c src/core/reload.h src/core/bootstrap.h src/core/config.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/reload.c -$(BOOTSTRAP_O): src/core/bootstrap.c src/core/bootstrap.h src/asap/manifest.h src/core/config.h src/core/memory.h src/core/skill.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h src/tools/cron.h +$(BOOTSTRAP_O): src/core/bootstrap.c src/core/bootstrap.h src/asap/manifest.h src/core/agent.h src/core/config.h src/core/memory.h src/core/skill.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h src/tools/cron.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/bootstrap.c -$(BOOTSTRAP_DISPATCH_STUB_O): tests/stubs/bootstrap_dispatch_stub.c src/core/bootstrap.h src/core/config.h src/providers/provider.h src/tools/tool.h +$(BOOTSTRAP_DISPATCH_STUB_O): tests/stubs/bootstrap_dispatch_stub.c src/core/bootstrap.h src/core/agent.h src/core/config.h src/providers/provider.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) -c -o $@ tests/stubs/bootstrap_dispatch_stub.c $(TOOL_RELOAD_STUB_O): tests/stubs/tool_reload_stub.c src/tools/tool.h @@ -364,7 +364,7 @@ $(HTTP_O): src/gateway/http.c src/gateway/http.h src/gateway/http_lws.h src/gate $(HTTP_LWS_O): src/gateway/http_lws.c src/gateway/http_lws.h src/gateway/asap_http_body.h src/gateway/routes.h src/gateway/auth.h src/gateway/static.h src/gateway/ws.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/http_lws.c -$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/config.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h +$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/bootstrap.h src/core/agent.h src/core/config.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/routes.c $(ROUTES_HARDWARE_O): src/gateway/routes_hardware.c src/gateway/routes_hardware.h src/gateway/routes.h src/gateway/http_lws.h src/gateway/uri_match.h src/hardware/hardware.h src/hardware/hardware_gpio_snapshot.h src/hardware/hardware_tegrastats.h src/hardware/board_detect.h src/core/config.h diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index f097192..e407b63 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -95,6 +95,28 @@ const tool_t *bootstrap_tool_at(size_t index) return g_tools[index]; } +size_t bootstrap_fill_agent_tools(agent_tool_t *out, size_t cap) +{ + size_t tool_count = g_tool_count; + size_t i; + if (!out || cap == 0) + return 0; + if (tool_count > cap) + tool_count = cap; + for (i = 0; i < tool_count; i++) { + const tool_t *t = g_tools[i]; + if (!t) { + tool_count = i; + break; + } + out[i].name = t->name; + out[i].description = t->description; + out[i].parameters_json = t->parameters_json; + out[i].execute = t->execute; + } + return tool_count; +} + static int memory_init_from_config(const config_t *cfg) { const char *path = config_memory_db_path(cfg); diff --git a/src/core/bootstrap.h b/src/core/bootstrap.h index e529492..e7873b8 100644 --- a/src/core/bootstrap.h +++ b/src/core/bootstrap.h @@ -7,6 +7,7 @@ #define SHELLCLAW_BOOTSTRAP_H #include "channels/channel.h" +#include "core/agent.h" #include "core/config.h" #include "providers/provider.h" #include "tools/tool.h" @@ -40,6 +41,15 @@ const channel_t *bootstrap_channel_at(int index); size_t bootstrap_tool_count(void); const tool_t *bootstrap_tool_at(size_t index); +/** + * Copy registered tools into an agent_tool_t table for agent_run / ASAP. + * Caps at @p cap and stops on a NULL slot so the returned count never + * overruns the filled prefix. + * + * Example: n = bootstrap_fill_agent_tools(flat, SHELLCLAW_MAX_TOOLS); + */ +size_t bootstrap_fill_agent_tools(agent_tool_t *out, size_t cap); + #ifdef __cplusplus } #endif diff --git a/src/core/dispatch.c b/src/core/dispatch.c index 0639132..a9a4803 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -33,19 +33,8 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) return ch->send(msg->session_id, buf, NULL, 0); } char resp_buf[RESPONSE_BUF_SIZE]; - size_t tool_count = bootstrap_tool_count(); agent_tool_t flat_tools[SHELLCLAW_MAX_TOOLS]; - if (tool_count > SHELLCLAW_MAX_TOOLS) - tool_count = SHELLCLAW_MAX_TOOLS; - for (size_t i = 0; i < tool_count; i++) { - const tool_t *t = bootstrap_tool_at(i); - if (!t) - break; - flat_tools[i].name = t->name; - flat_tools[i].description = t->description; - flat_tools[i].parameters_json = t->parameters_json; - flat_tools[i].execute = t->execute; - } + size_t tool_count = bootstrap_fill_agent_tools(flat_tools, SHELLCLAW_MAX_TOOLS); agent_lock(); int err = agent_run(bootstrap_get_cfg(), msg->session_id, text, bootstrap_get_provider(), flat_tools, tool_count, diff --git a/src/gateway/routes.c b/src/gateway/routes.c index b355bed..bf183a9 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -14,7 +14,6 @@ #include "asap/envelope.h" #include "asap/server.h" #include "asap/log.h" -#include "core/agent.h" #include "core/bootstrap.h" #include "core/config.h" #include "core/memory.h" @@ -565,26 +564,11 @@ static void handle_asap_log_get(char *buf, size_t size, int *status) static void asap_ctx_bind_bootstrap(asap_server_ctx_t *asap_ctx, const config_t *http_cfg, agent_tool_t *flat_tools, size_t tools_cap) { - size_t tool_count = bootstrap_tool_count(); - size_t i; - if (tool_count > tools_cap) - tool_count = tools_cap; - for (i = 0; i < tool_count; i++) { - const tool_t *t = bootstrap_tool_at(i); - if (!t) { - tool_count = i; - break; - } - flat_tools[i].name = t->name; - flat_tools[i].description = t->description; - flat_tools[i].parameters_json = t->parameters_json; - flat_tools[i].execute = t->execute; - } memset(asap_ctx, 0, sizeof *asap_ctx); asap_ctx->cfg = http_cfg ? http_cfg : bootstrap_get_cfg(); asap_ctx->provider = bootstrap_get_provider(); + asap_ctx->tool_count = bootstrap_fill_agent_tools(flat_tools, tools_cap); asap_ctx->tools = flat_tools; - asap_ctx->tool_count = tool_count; } static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, diff --git a/tests/stubs/bootstrap_dispatch_stub.c b/tests/stubs/bootstrap_dispatch_stub.c index 4691397..9b9fccf 100644 --- a/tests/stubs/bootstrap_dispatch_stub.c +++ b/tests/stubs/bootstrap_dispatch_stub.c @@ -4,6 +4,7 @@ */ #define _POSIX_C_SOURCE 200809L +#include "core/agent.h" #include "core/bootstrap.h" #include "providers/provider.h" #include "tools/tool.h" @@ -61,6 +62,28 @@ const tool_t *bootstrap_tool_at(size_t index) return g_tools[index]; } +size_t bootstrap_fill_agent_tools(agent_tool_t *out, size_t cap) +{ + size_t tool_count = g_tool_count; + size_t i; + if (!out || cap == 0) + return 0; + if (tool_count > cap) + tool_count = cap; + for (i = 0; i < tool_count; i++) { + const tool_t *t = g_tools[i]; + if (!t) { + tool_count = i; + break; + } + out[i].name = t->name; + out[i].description = t->description; + out[i].parameters_json = t->parameters_json; + out[i].execute = t->execute; + } + return tool_count; +} + void bootstrap_reset_tools_for_test(void) { g_tool_count = 0; From 3d81b230427ecbc6d2f40d2e61f7ca99d9341aa9 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 14:39:31 -0300 Subject: [PATCH 13/92] test(gateway): assert ASAP JSON payload_type and unknown-tool 400 Parse the JSON-RPC result instead of strstr, print the body on HTTP mismatch, and cover mcp.tool_call of an unknown name (-32001). Refs: #53 --- tests/test_gateway_http.c | 141 ++++++++++++++++++++++++++++++-------- 1 file changed, 112 insertions(+), 29 deletions(-) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 4a60cca..69c4809 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -420,29 +420,104 @@ static int test_asap_missing_fields(void) return 0; } -static int test_asap_task_request(void) +static int post_asap(const char *payload_type, const char *payload_json, + const char *env_id, long *code, char **body) { - long code; - char *body = NULL; - static const char *req = + char req[1536]; + int n; + n = snprintf(req, sizeof req, "{\"jsonrpc\":\"2.0\",\"method\":\"asap.send\"," "\"params\":{" - "\"id\":\"01HZABC123\"," + "\"id\":\"%s\"," "\"asap_version\":\"2.1\"," "\"sender\":\"urn:asap:agent:a\"," "\"recipient\":\"urn:asap:agent:b\"," - "\"payload_type\":\"task.request\"," - "\"payload\":{\"input\":\"hello\"}," + "\"payload_type\":\"%s\"," + "\"payload\":%s," "\"correlation_id\":\"c1\"," "\"trace_id\":\"t1\"," "\"timestamp\":\"2026-01-01T00:00:00Z\"" - "},\"id\":42}"; - int r = http_post(gw_url("/asap"), req, &code, &body); + "},\"id\":42}", + env_id, payload_type, payload_json); + if (n < 0 || (size_t)n >= sizeof req) + return -1; + return http_post(gw_url("/asap"), req, code, body); +} + +static int asap_http_is(long code, long want, const char *body) +{ + if (code == want) + return 1; + fprintf(stderr, "FAIL: HTTP %ld want %ld body=%s\n", + code, want, body ? body : "(null)"); + return 0; +} + +static int asap_result_payload_type_is(const char *body, const char *want) +{ + cJSON *root = cJSON_Parse(body); + cJSON *result; + cJSON *ptype; + int ok = 0; + if (!root) + return 0; + result = cJSON_GetObjectItemCaseSensitive(root, "result"); + ptype = result ? cJSON_GetObjectItemCaseSensitive(result, "payload_type") : NULL; + if (ptype && cJSON_IsString(ptype) && ptype->valuestring && + strcmp(ptype->valuestring, want) == 0) + ok = 1; + cJSON_Delete(root); + return ok; +} + +static int asap_error_code_is(const char *body, int want) +{ + cJSON *root = cJSON_Parse(body); + cJSON *err; + cJSON *code; + int ok = 0; + if (!root) + return 0; + err = cJSON_GetObjectItemCaseSensitive(root, "error"); + code = err ? cJSON_GetObjectItemCaseSensitive(err, "code") : NULL; + if (code && cJSON_IsNumber(code) && (int)code->valuedouble == want) + ok = 1; + cJSON_Delete(root); + return ok; +} + +static int asap_mcp_result_is_json_array(const char *body) +{ + cJSON *root = cJSON_Parse(body); + cJSON *result; + cJSON *payload; + cJSON *result_str; + cJSON *inner = NULL; + int ok = 0; + if (!root) + return 0; + result = cJSON_GetObjectItemCaseSensitive(root, "result"); + payload = result ? cJSON_GetObjectItemCaseSensitive(result, "payload") : NULL; + result_str = payload ? cJSON_GetObjectItemCaseSensitive(payload, "result") : NULL; + if (result_str && cJSON_IsString(result_str) && result_str->valuestring) { + inner = cJSON_Parse(result_str->valuestring); + ok = (inner && cJSON_IsArray(inner)) ? 1 : 0; + cJSON_Delete(inner); + } + cJSON_Delete(root); + return ok; +} + +static int test_asap_task_request(void) +{ + long code; + char *body = NULL; + int r = post_asap("task.request", "{\"input\":\"hello\"}", "01HZABC123", + &code, &body); ASSERT(r == 0); - ASSERT(code == 200); + ASSERT(asap_http_is(code, 200, body)); ASSERT(body != NULL); - ASSERT(strstr(body, "task.response") != NULL); - ASSERT(strstr(body, "server missing cfg or provider") == NULL); + ASSERT(asap_result_payload_type_is(body, "task.response")); free(body); return 0; } @@ -451,25 +526,32 @@ static int test_asap_mcp_tool_call(void) { long code; char *body = NULL; - static const char *req = - "{\"jsonrpc\":\"2.0\",\"method\":\"asap.send\"," - "\"params\":{" - "\"id\":\"01HZABC124\"," - "\"asap_version\":\"2.1\"," - "\"sender\":\"urn:asap:agent:a\"," - "\"recipient\":\"urn:asap:agent:b\"," - "\"payload_type\":\"mcp.tool_call\"," - "\"payload\":{\"name\":\"cron\",\"arguments\":{\"operation\":\"list\"}}," - "\"correlation_id\":\"c2\"," - "\"trace_id\":\"t2\"," - "\"timestamp\":\"2026-01-01T00:00:00Z\"" - "},\"id\":43}"; - int r = http_post(gw_url("/asap"), req, &code, &body); + int r = post_asap("mcp.tool_call", + "{\"name\":\"cron\",\"arguments\":{\"operation\":\"list\"}}", + "01HZABC124", &code, &body); ASSERT(r == 0); - ASSERT(code == 200); + ASSERT(asap_http_is(code, 200, body)); + ASSERT(body != NULL); + ASSERT(asap_result_payload_type_is(body, "mcp.tool_result")); + ASSERT(asap_mcp_result_is_json_array(body)); + free(body); + return 0; +} + +static int test_asap_mcp_unknown_tool(void) +{ + long code; + char *body = NULL; + int r = post_asap("mcp.tool_call", + "{\"name\":\"no-such-tool\",\"arguments\":{}}", + "01HZABC125", &code, &body); + ASSERT(r == 0); + if (code != 400) + fprintf(stderr, "FAIL: HTTP %ld want 400 body=%s\n", + code, body ? body : "(null)"); + ASSERT(code == 400); ASSERT(body != NULL); - ASSERT(strstr(body, "mcp.tool_result") != NULL); - ASSERT(strstr(body, "unknown tool") == NULL); + ASSERT(asap_error_code_is(body, -32001)); free(body); return 0; } @@ -1112,6 +1194,7 @@ int main(int argc, char **argv) if (test_asap_missing_fields() != 0) { fprintf(stderr, "test_asap_missing_fields failed\n"); failed++; } if (test_asap_task_request() != 0) { fprintf(stderr, "test_asap_task_request failed\n"); failed++; } if (test_asap_mcp_tool_call() != 0) { fprintf(stderr, "test_asap_mcp_tool_call failed\n"); failed++; } + if (test_asap_mcp_unknown_tool() != 0) { fprintf(stderr, "test_asap_mcp_unknown_tool failed\n"); failed++; } if (test_api_asap_log_401() != 0) { fprintf(stderr, "test_api_asap_log_401 failed\n"); failed++; } if (test_api_hardware_board_401() != 0) { fprintf(stderr, "test_api_hardware_board_401 failed\n"); From 208f7a44b11f2464aee9e0504fa5f1e90c3b4056 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 14:39:31 -0300 Subject: [PATCH 14/92] docs(asap): document protocol-public tool dispatch on POST /asap POST /asap is rate-limited, not Bearer. Empty trusted_senders allows any URN to reach agent_run and direct mcp.tool_call. Production must set the allowlist before exposing the gateway. Refs: #53 --- CHANGELOG.md | 2 ++ config.example.toml | 7 +++++-- docs/ASAP.md | 2 +- docs/SECURITY.md | 15 +++++++++++++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0640027..722954b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - 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`. ### Added - Phase 5 documentation suite (`docs/SECURITY.md`, `docs/ASAP.md`, and related guides). @@ -19,6 +20,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Security - Camera auto-output keeps the exclusive `mkstemp` inode (no unlink + `${tmpl}.jpg` sibling). - Reject I2C `bus` outside 0–255 at the tool JSON boundary. +- Document that protocol-public `POST /asap` can invoke local tools; production must set `[asap].trusted_senders` before exposing the gateway. --- diff --git a/config.example.toml b/config.example.toml index 31d0411..cbf1998 100644 --- a/config.example.toml +++ b/config.example.toml @@ -75,8 +75,11 @@ description = "C-native edge-AI ASAP agent on NVIDIA Jetson Orin Nano Super (CUD public_base_url = "https://shellclaw.example.com" registry_url = "https://raw.githubusercontent.com/asap-protocol/asap-protocol/main/registry.json" # POST /asap is protocol-public (rate-limited, not Bearer). Empty trusted_senders -# allows every sender URN. Set this in production so task.request sessions -# keyed as asap: cannot be spoofed by claiming another client's URN. +# allows every sender URN. After inbound dispatch is wired, a trusted (or, with +# an empty list, any) sender can run task.request (agent_run + tool table) and +# mcp.tool_call (direct tool execute, no LLM). Default gateway bind is 127.0.0.1. +# Set this in production before exposing the gateway so sessions keyed as +# asap: cannot be spoofed and local tools are not open to any URN. # trusted_senders = ["urn:asap:agent:peer"] [asap.skill_descriptions] diff --git a/docs/ASAP.md b/docs/ASAP.md index c75aa53..505bf3d 100644 --- a/docs/ASAP.md +++ b/docs/ASAP.md @@ -16,7 +16,7 @@ When the gateway is enabled and signing keys load successfully, ShellClaw serves |-------|------|----------| | `GET /.well-known/asap/manifest.json` | Public | **SignedManifest** JSON (inner manifest + Ed25519 signature + public_key) | | `GET /.well-known/asap/health` | Public | Minimal health stub (`{"status":"ok"}` in v1.0) | -| `POST /asap` | Rate-limited | JSON-RPC ASAP task ingress (partial v1.0) | +| `POST /asap` | Rate-limited | JSON-RPC ASAP ingress: `task.request` / `mcp.tool_call` dispatch with the process provider and tool table. Compliance harness shape is still partial (see Known gaps). | | `GET /api/asap/log` | Bearer | Inbound ASAP message log | Implementation: `src/asap/manifest.c`, `src/gateway/routes.c`. If keys cannot load, manifest route returns **500** and agent startup fails fast (`init_subsystems()`). diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c9594a4..f2578fc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -12,8 +12,8 @@ ShellClaw runs as a user-level agent on edge boards (Jetson Orin Nano Super, Ras - **Shell tool** — subprocess with optional Linux namespaces + allowlist. - **File tool** — workspace-scoped paths when configured. -- **Hardware tools** — GPIO, I2C, camera (gateway-authenticated HTTP in v1.0). -- **ASAP / gateway** — Bearer-authenticated HTTP and WebSocket. +- **Hardware tools** — GPIO, I2C, camera (gateway `/api/hardware/*` is Bearer-authenticated in v1.0). +- **ASAP / gateway** — Bearer-authenticated `/api/*` HTTP and WebSocket. `POST /asap` is protocol-public (rate-limited, not Bearer); authenticity is `[asap].trusted_senders`. The primary goals are: prevent sandboxed shell commands from escaping to host destruction, block direct GPU/camera daemon access from the shell sandbox, and keep signing keys and cloud credentials off disk with unsafe permissions. @@ -172,6 +172,17 @@ Handlers: [`src/gateway/routes_hardware.c`](../src/gateway/routes_hardware.c). N Slice 02 introduced these routes; this audit confirms Bearer gating remains centralized in `http_lws.c`. Rate limiting for camera POST is deferred with the capture implementation. +## Inbound ASAP `POST /asap` + +`POST /asap` is excluded from Bearer pairing so ASAP peers can call without a ShellClaw token. Authenticity is `[asap].trusted_senders`. An empty list (local/dev default) allows every claimed URN. + +After provider/tool wiring in `handle_asap` (#53), a sender that passes that check can: + +- `task.request` — `agent_run()` with the same tool table as chat (`shell`, `file`, hardware, `asap_invoke`, …) +- `mcp.tool_call` — `execute()` with attacker-chosen name and arguments (no LLM) + +Default gateway bind is `127.0.0.1`, which contains this for stock installs. Production MUST set `trusted_senders` before exposing the gateway (`allow_bind_all`, tunnel, marketplace URL). Restricting the inbound MCP tool table (or failing closed when the allowlist is empty and the host is not loopback) is a follow-up. + --- ## Ed25519 signing keys (task 7.5) From e7bb60e9495e36fc106318c30cc059797522de71 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 18:19:21 -0300 Subject: [PATCH 15/92] fix(gateway): reject oversized ASAP responses instead of truncating JSON Inbound POST /asap copied serialized JSON-RPC into the 64 KiB gateway buffer with silent truncation, which produced invalid JSON. Reject with JSON-RPC -32603 when the payload cannot fit. Refs: #61 --- src/gateway/routes.c | 32 +++++++++++++++----- tests/test_gateway_http.c | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/gateway/routes.c b/src/gateway/routes.c index bf183a9..da07aad 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -571,6 +571,29 @@ static void asap_ctx_bind_bootstrap(asap_server_ctx_t *asap_ctx, const config_t asap_ctx->tools = flat_tools; } +/** + * Copy serialized JSON-RPC into the gateway HTTP buffer. Truncation + * produced invalid JSON for callers (#61); reject instead. + */ +static void write_asap_jsonrpc(char *buf, size_t size, int *status, char *resp_json) +{ + size_t rlen; + rlen = strlen(resp_json); + if (rlen >= size) { + char too_big[96]; + snprintf(too_big, sizeof too_big, + "ASAP response length %zu exceeds gateway buffer %zu", + rlen, size); + free(resp_json); + jsonrpc_error(buf, size, status, 500, -32603, too_big); + return; + } + *status = 200; + memcpy(buf, resp_json, rlen); + buf[rlen] = '\0'; + free(resp_json); +} + static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, const char *body, size_t body_len, char *buf, size_t size, int *status) { @@ -623,14 +646,7 @@ static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, jsonrpc_error(buf, size, status, 500, -32603, "failed to serialize response"); return; } - *status = 200; - { - size_t rlen = strlen(resp_json); - if (rlen >= size) rlen = size - 1; - memcpy(buf, resp_json, rlen); - buf[rlen] = '\0'; - } - free(resp_json); + write_asap_jsonrpc(buf, size, status, resp_json); } static void handle_well_known(http_server_ctx_t *ctx, const char *uri, int uri_len, diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 69c4809..77f58aa 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -556,6 +556,64 @@ static int test_asap_mcp_unknown_tool(void) return 0; } +/* Gateway HTTP buffer is RESP_BUF_SIZE (65536). A (64 KiB - 1) file + * read wraps into JSON-RPC larger than that; truncation was #61. */ +enum { ASAP_OVERSIZE_FILE_BYTES = 65535 }; + +static int write_asap_oversize_file(char *path, size_t path_sz) +{ + FILE *f; + char *block; + size_t nw; + snprintf(path, path_sz, "%s/.shellclaw/asap_oversize.txt", g_test_home); + block = malloc(ASAP_OVERSIZE_FILE_BYTES); + if (!block) + return -1; + memset(block, 'A', ASAP_OVERSIZE_FILE_BYTES); + f = fopen(path, "wb"); + if (!f) { + free(block); + return -1; + } + nw = fwrite(block, 1, ASAP_OVERSIZE_FILE_BYTES, f); + fclose(f); + free(block); + return nw == ASAP_OVERSIZE_FILE_BYTES ? 0 : -1; +} + +static int test_asap_rejects_oversized_response(void) +{ + char path[256]; + char payload[640]; + long code; + char *body = NULL; + int n; + int r; + cJSON *parsed; + ASSERT(write_asap_oversize_file(path, sizeof path) == 0); + n = snprintf(payload, sizeof payload, + "{\"name\":\"file\",\"arguments\":{\"operation\":\"read_file\",\"path\":\"%s\"}}", + path); + ASSERT(n > 0 && (size_t)n < sizeof payload); + r = post_asap("mcp.tool_call", payload, "01HZABC126", &code, &body); + ASSERT(r == 0); + if (code != 500) + fprintf(stderr, "FAIL: HTTP %ld want 500 body_len=%zu\n", + code, body ? strlen(body) : 0); + ASSERT(code == 500); + ASSERT(body != NULL); + parsed = cJSON_Parse(body); + if (!parsed) + fprintf(stderr, "FAIL: oversized ASAP body is not JSON: %.200s\n", + body); + ASSERT(parsed != NULL); + cJSON_Delete(parsed); + ASSERT(asap_error_code_is(body, -32603)); + ASSERT(strstr(body, "exceeds gateway buffer") != NULL); + free(body); + return 0; +} + static int test_manifest(void) { long code; @@ -1195,6 +1253,10 @@ int main(int argc, char **argv) if (test_asap_task_request() != 0) { fprintf(stderr, "test_asap_task_request failed\n"); failed++; } if (test_asap_mcp_tool_call() != 0) { fprintf(stderr, "test_asap_mcp_tool_call failed\n"); failed++; } if (test_asap_mcp_unknown_tool() != 0) { fprintf(stderr, "test_asap_mcp_unknown_tool failed\n"); failed++; } + if (test_asap_rejects_oversized_response() != 0) { + fprintf(stderr, "test_asap_rejects_oversized_response failed\n"); + failed++; + } if (test_api_asap_log_401() != 0) { fprintf(stderr, "test_api_asap_log_401 failed\n"); failed++; } if (test_api_hardware_board_401() != 0) { fprintf(stderr, "test_api_hardware_board_401 failed\n"); From 4534b94765f1f4d25d55145683413a19b35baf66 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 18:45:12 -0300 Subject: [PATCH 16/92] fix(gateway): log ASAP outbound only after the HTTP copy succeeds asap_log_append_out ran before write_asap_jsonrpc, so /api/asap/log showed mcp.tool_result that the caller never received. Refs: #61 --- src/gateway/routes.c | 21 ++++++++++------ tests/test_gateway_http.c | 51 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/gateway/routes.c b/src/gateway/routes.c index da07aad..db64a95 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -574,8 +574,10 @@ static void asap_ctx_bind_bootstrap(asap_server_ctx_t *asap_ctx, const config_t /** * Copy serialized JSON-RPC into the gateway HTTP buffer. Truncation * produced invalid JSON for callers (#61); reject instead. + * Takes ownership of @p resp_json. + * @return 0 if copied, -1 if rejected as too large. */ -static void write_asap_jsonrpc(char *buf, size_t size, int *status, char *resp_json) +static int write_asap_jsonrpc(char *buf, size_t size, int *status, char *resp_json) { size_t rlen; rlen = strlen(resp_json); @@ -586,12 +588,13 @@ static void write_asap_jsonrpc(char *buf, size_t size, int *status, char *resp_j rlen, size); free(resp_json); jsonrpc_error(buf, size, status, 500, -32603, too_big); - return; + return -1; } *status = 200; memcpy(buf, resp_json, rlen); buf[rlen] = '\0'; free(resp_json); + return 0; } static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, @@ -638,15 +641,19 @@ static void handle_asap(http_server_ctx_t *ctx, const char *client_ip, return; } resp_json = asap_envelope_to_jsonrpc_string(&out, NULL); - snippet = out.payload ? cJSON_PrintUnformatted(out.payload) : NULL; - asap_log_append_out(out.payload_type, out.id, snippet); - free(snippet); - asap_envelope_clear(&out); if (!resp_json) { + asap_envelope_clear(&out); jsonrpc_error(buf, size, status, 500, -32603, "failed to serialize response"); return; } - write_asap_jsonrpc(buf, size, status, resp_json); + if (write_asap_jsonrpc(buf, size, status, resp_json) != 0) { + asap_envelope_clear(&out); + return; + } + snippet = out.payload ? cJSON_PrintUnformatted(out.payload) : NULL; + asap_log_append_out(out.payload_type, out.id, snippet); + free(snippet); + asap_envelope_clear(&out); } static void handle_well_known(http_server_ctx_t *ctx, const char *uri, int uri_len, diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 77f58aa..61053f9 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -581,7 +581,46 @@ static int write_asap_oversize_file(char *path, size_t path_sz) return nw == ASAP_OVERSIZE_FILE_BYTES ? 0 : -1; } -static int test_asap_rejects_oversized_response(void) +static int asap_log_outbound_count(const char *token, int *count_out) +{ + long code; + char *body = NULL; + cJSON *root; + cJSON *ent; + int i; + int n; + int count = 0; + if (!token || !token[0] || !count_out) + return -1; + if (http_get_auth(gw_url("/api/asap/log"), token, &code, &body) != 0) + return -1; + if (code != 200 || !body) { + free(body); + return -1; + } + root = cJSON_Parse(body); + free(body); + if (!root) + return -1; + ent = cJSON_GetObjectItemCaseSensitive(root, "entries"); + if (!ent || !cJSON_IsArray(ent)) { + cJSON_Delete(root); + return -1; + } + n = cJSON_GetArraySize(ent); + for (i = 0; i < n; i++) { + cJSON *e = cJSON_GetArrayItem(ent, i); + cJSON *dir = e ? cJSON_GetObjectItemCaseSensitive(e, "direction") : NULL; + if (dir && cJSON_IsString(dir) && dir->valuestring && + strcmp(dir->valuestring, "out") == 0) + count++; + } + cJSON_Delete(root); + *count_out = count; + return 0; +} + +static int test_asap_rejects_oversized_response(const char *token) { char path[256]; char payload[640]; @@ -589,12 +628,16 @@ static int test_asap_rejects_oversized_response(void) char *body = NULL; int n; int r; + int out_before = 0; + int out_after = 0; cJSON *parsed; ASSERT(write_asap_oversize_file(path, sizeof path) == 0); n = snprintf(payload, sizeof payload, "{\"name\":\"file\",\"arguments\":{\"operation\":\"read_file\",\"path\":\"%s\"}}", path); ASSERT(n > 0 && (size_t)n < sizeof payload); + if (token && token[0]) + ASSERT(asap_log_outbound_count(token, &out_before) == 0); r = post_asap("mcp.tool_call", payload, "01HZABC126", &code, &body); ASSERT(r == 0); if (code != 500) @@ -611,6 +654,10 @@ static int test_asap_rejects_oversized_response(void) ASSERT(asap_error_code_is(body, -32603)); ASSERT(strstr(body, "exceeds gateway buffer") != NULL); free(body); + if (token && token[0]) { + ASSERT(asap_log_outbound_count(token, &out_after) == 0); + ASSERT(out_after == out_before); + } return 0; } @@ -1253,7 +1300,7 @@ int main(int argc, char **argv) if (test_asap_task_request() != 0) { fprintf(stderr, "test_asap_task_request failed\n"); failed++; } if (test_asap_mcp_tool_call() != 0) { fprintf(stderr, "test_asap_mcp_tool_call failed\n"); failed++; } if (test_asap_mcp_unknown_tool() != 0) { fprintf(stderr, "test_asap_mcp_unknown_tool failed\n"); failed++; } - if (test_asap_rejects_oversized_response() != 0) { + if (test_asap_rejects_oversized_response(token) != 0) { fprintf(stderr, "test_asap_rejects_oversized_response failed\n"); failed++; } From e6e6037f4436e74c9d6987487ff8d7feacb39859 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 12 Sep 2026 18:45:12 -0300 Subject: [PATCH 17/92] docs(asap): document 64 KiB POST /asap response buffer cap Refs: #61 --- CHANGELOG.md | 1 + docs/ASAP.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722954b..2f485b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - 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`. +- `POST /asap` rejects serialized JSON-RPC larger than the 64 KiB gateway HTTP buffer (HTTP 500 / JSON-RPC `-32603`) instead of truncating the body. ### Added - Phase 5 documentation suite (`docs/SECURITY.md`, `docs/ASAP.md`, and related guides). diff --git a/docs/ASAP.md b/docs/ASAP.md index 505bf3d..6a1d344 100644 --- a/docs/ASAP.md +++ b/docs/ASAP.md @@ -16,7 +16,7 @@ When the gateway is enabled and signing keys load successfully, ShellClaw serves |-------|------|----------| | `GET /.well-known/asap/manifest.json` | Public | **SignedManifest** JSON (inner manifest + Ed25519 signature + public_key) | | `GET /.well-known/asap/health` | Public | Minimal health stub (`{"status":"ok"}` in v1.0) | -| `POST /asap` | Rate-limited | JSON-RPC ASAP ingress: `task.request` / `mcp.tool_call` dispatch with the process provider and tool table. Compliance harness shape is still partial (see Known gaps). | +| `POST /asap` | Rate-limited | JSON-RPC ASAP ingress: `task.request` / `mcp.tool_call` dispatch with the process provider and tool table. Serialized responses larger than the 64 KiB gateway HTTP buffer (`RESP_BUF_SIZE`) are rejected with HTTP 500 / JSON-RPC `-32603` rather than truncated. Compliance harness shape is still partial (see Known gaps). | | `GET /api/asap/log` | Bearer | Inbound ASAP message log | Implementation: `src/asap/manifest.c`, `src/gateway/routes.c`. If keys cannot load, manifest route returns **500** and agent startup fails fast (`init_subsystems()`). From 3b61f1400edc977703dd65db37771c4f95ba4efd Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 02:30:13 -0300 Subject: [PATCH 18/92] fix(asap): avoid double-free on malformed JSON-RPC results Refs: #84 --- src/asap/envelope.c | 2 +- src/asap/envelope.h | 4 +++- tests/test_asap_client.c | 46 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/asap/envelope.c b/src/asap/envelope.c index f222f8d..9bc57e3 100644 --- a/src/asap/envelope.c +++ b/src/asap/envelope.c @@ -328,7 +328,7 @@ int asap_envelope_parse_jsonrpc_response(const char *json, asap_envelope_t *out, if (errmsg && errlen && cJSON_IsString(m) && m->valuestring) (void)snprintf(errmsg, errlen, "%s", m->valuestring); cJSON_Delete(tmp_err); } else if (errmsg && errlen) (void)snprintf(errmsg, errlen, "Invalid result envelope"); - cJSON_Delete(rpc_id); + /* parse_fail already freed rpc_id; inbound parse does not delete again (Refs: #84). */ cJSON_Delete(root); return -1; } diff --git a/src/asap/envelope.h b/src/asap/envelope.h index d873eb8..bc0017e 100644 --- a/src/asap/envelope.h +++ b/src/asap/envelope.h @@ -72,7 +72,9 @@ int asap_envelope_parse(const char *json, asap_envelope_t *out, cJSON **err_out) * jsonrpc_request_id. * * @param obj Object with ASAP envelope members (not NULL) - * @param rpc_id Request id for error echo (not consumed) + * @param rpc_id Request id for error echo. On validation failure this + * pointer is freed. On success it is not consumed; the + * caller may assign it to out->jsonrpc_request_id. * @param out Envelope; must be zeroed (#asap_envelope_init) or cleared first * because the implementation clears the struct on entry. * @param err_out Optional error root diff --git a/tests/test_asap_client.c b/tests/test_asap_client.c index ffc85ea..a4f80ab 100644 --- a/tests/test_asap_client.c +++ b/tests/test_asap_client.c @@ -246,6 +246,29 @@ static int test_parse_response_jsonrpc_error(void) return 0; } +/* HTTP 200 result missing required envelope fields used to double-free rpc_id (Refs: #84). */ +static const char jsonrpc_result_missing_payload[] = + "{\"jsonrpc\":\"2.0\"," + "\"id\":\"1\"," + "\"result\":{" + "\"id\":\"t1\"," + "\"asap_version\":\"2.1\"," + "\"sender\":\"a\",\"recipient\":\"b\"," + "\"payload_type\":\"task.response\"" + "}}"; + +static int test_parse_response_missing_payload(void) +{ + char err[128]; + asap_envelope_t out; + asap_envelope_init(&out); + err[0] = 0; + ASSERT(asap_envelope_parse_jsonrpc_response(jsonrpc_result_missing_payload, &out, err, sizeof err) == -1); + ASSERT(strstr(err, "payload") != NULL); + asap_envelope_clear(&out); + return 0; +} + static int test_config_defaults(void) { asap_client_config_t c; @@ -386,6 +409,27 @@ static int test_live_http_non_two_hundred(void) return 0; } +static int test_live_http_malformed_result_envelope(void) +{ + asap_envelope_t env; + asap_envelope_t resp; + struct tiny_http_srv srv; + char err[256]; + char url[96]; + ASSERT(fill_min_task_request(&env) == 0); + ASSERT(tiny_http_start(&srv, 200L, jsonrpc_result_missing_payload, strlen(jsonrpc_result_missing_payload)) == 0); + asap_envelope_init(&resp); + err[0] = '\0'; + ASSERT(snprintf(url, sizeof url, "http://127.0.0.1:%hu/", srv.bind_port) < (int)sizeof url); + ASSERT(asap_client_send_task(url, NULL, ASAP_DEFAULT_JSONRPC_METHOD, &env, NULL, NULL, &resp, + err, sizeof err) == -1); + ASSERT(strstr(err, "payload") != NULL); + asap_envelope_clear(&env); + asap_envelope_clear(&resp); + tiny_http_join(&srv); + return 0; +} + static int test_empty_jsonrpc_method_uses_default(void) { asap_envelope_t env; @@ -442,11 +486,13 @@ int main(int argc, char **argv) if (test_request_roundtrip_string() != 0) { fprintf(stderr, "test_request_roundtrip_string failed\n"); failed++; } if (test_parse_response_ok() != 0) { fprintf(stderr, "test_parse_response_ok failed\n"); failed++; } if (test_parse_response_jsonrpc_error() != 0) { fprintf(stderr, "test_parse_response_jsonrpc_error failed\n"); failed++; } + if (test_parse_response_missing_payload() != 0) { fprintf(stderr, "test_parse_response_missing_payload failed\n"); failed++; } if (test_config_defaults() != 0) { fprintf(stderr, "test_config_defaults failed\n"); failed++; } if (test_config_from_config_timeout() != 0) { fprintf(stderr, "test_config_from_config_timeout failed\n"); failed++; } if (test_send_invalid_arguments() != 0) { fprintf(stderr, "test_send_invalid_arguments failed\n"); failed++; } if (test_live_http_roundtrip_success() != 0) { fprintf(stderr, "test_live_http_roundtrip_success failed\n"); failed++; } if (test_live_http_non_two_hundred() != 0) { fprintf(stderr, "test_live_http_non_two_hundred failed\n"); failed++; } + if (test_live_http_malformed_result_envelope() != 0) { fprintf(stderr, "test_live_http_malformed_result_envelope failed\n"); failed++; } if (test_empty_jsonrpc_method_uses_default() != 0) { fprintf(stderr, "test_empty_jsonrpc_method_uses_default failed\n"); failed++; } if (test_send_with_explicit_jsonrpc_request_id() != 0) { fprintf(stderr, "test_send_with_explicit_jsonrpc_request_id failed\n"); failed++; } if (test_send_fails_no_server() != 0) { fprintf(stderr, "test_send_fails_no_server failed\n"); failed++; } From f7028ed238b3599e33c040f7ec70521c1f91da9a Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 02:50:19 -0300 Subject: [PATCH 19/92] docs(asap): qualify from_object rpc_id ownership on early return Refs: #84 --- src/asap/envelope.c | 2 +- src/asap/envelope.h | 8 +++++--- tests/test_asap_envelope.c | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/asap/envelope.c b/src/asap/envelope.c index 9bc57e3..665bae7 100644 --- a/src/asap/envelope.c +++ b/src/asap/envelope.c @@ -148,7 +148,7 @@ int asap_envelope_from_object(const cJSON *obj, cJSON *rpc_id, asap_envelope_t * cJSON *rid_temp = NULL; if (err_out) *err_out = NULL; - if (!obj || !cJSON_IsObject(r) || !out) return -1; + if (!obj || !cJSON_IsObject(r) || !out) return -1; /* caller still owns rpc_id */ if (!rpc_id) { rid_temp = cJSON_CreateNull(); if (!rid_temp) return -1; diff --git a/src/asap/envelope.h b/src/asap/envelope.h index bc0017e..fda2f90 100644 --- a/src/asap/envelope.h +++ b/src/asap/envelope.h @@ -72,9 +72,11 @@ int asap_envelope_parse(const char *json, asap_envelope_t *out, cJSON **err_out) * jsonrpc_request_id. * * @param obj Object with ASAP envelope members (not NULL) - * @param rpc_id Request id for error echo. On validation failure this - * pointer is freed. On success it is not consumed; the - * caller may assign it to out->jsonrpc_request_id. + * @param rpc_id Request id for error echo. Envelope-field validation + * failures free this pointer. Early argument checks + * (!obj, a non-object @p obj, or !out) return -1 + * without consuming it. On success it is not consumed; + * the caller may assign it to out->jsonrpc_request_id. * @param out Envelope; must be zeroed (#asap_envelope_init) or cleared first * because the implementation clears the struct on entry. * @param err_out Optional error root diff --git a/tests/test_asap_envelope.c b/tests/test_asap_envelope.c index 1e24491..5999f5f 100644 --- a/tests/test_asap_envelope.c +++ b/tests/test_asap_envelope.c @@ -313,6 +313,25 @@ static int test_to_jsonrpc_string_alloc(void) return 0; } +static int test_from_object_early_reject_keeps_rpc_id(void) +{ + cJSON *rpc_id; + cJSON *not_obj; + asap_envelope_t out; + rpc_id = cJSON_CreateString("keep-me"); + not_obj = cJSON_CreateString("not-an-object"); + ASSERT(rpc_id != NULL); + ASSERT(not_obj != NULL); + asap_envelope_init(&out); + ASSERT(asap_envelope_from_object(not_obj, rpc_id, &out, NULL) == -1); + ASSERT(cJSON_IsString(rpc_id) && rpc_id->valuestring && + strcmp(rpc_id->valuestring, "keep-me") == 0); + cJSON_Delete(rpc_id); + cJSON_Delete(not_obj); + asap_envelope_clear(&out); + return 0; +} + int main(int argc, char **argv) { (void)argc; @@ -335,6 +354,7 @@ int main(int argc, char **argv) if (test_to_jsonrpc_id_override() != 0) { fprintf(stderr, "test_to_jsonrpc_id_override failed\n"); failed++; } if (test_to_jsonrpc_invalid_envelope() != 0) { fprintf(stderr, "test_to_jsonrpc_invalid_envelope failed\n"); failed++; } if (test_to_jsonrpc_string_alloc() != 0) { fprintf(stderr, "test_to_jsonrpc_string_alloc failed\n"); failed++; } + if (test_from_object_early_reject_keeps_rpc_id() != 0) { fprintf(stderr, "test_from_object_early_reject_keeps_rpc_id failed\n"); failed++; } if (failed == 0) printf("test_asap_envelope: all tests passed\n"); return failed; From 5c653d648889ba40b97818d0b880934b2802ca8b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 02:58:50 -0300 Subject: [PATCH 20/92] docs(asap): changelog the JSON-RPC result double-free fix Refs: #84 --- CHANGELOG.md | 1 + src/asap/envelope.c | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f485b4..1fc6c1c 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 +- 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`. - `POST /asap` rejects serialized JSON-RPC larger than the 64 KiB gateway HTTP buffer (HTTP 500 / JSON-RPC `-32603`) instead of truncating the body. diff --git a/src/asap/envelope.c b/src/asap/envelope.c index 665bae7..11d4857 100644 --- a/src/asap/envelope.c +++ b/src/asap/envelope.c @@ -328,7 +328,7 @@ int asap_envelope_parse_jsonrpc_response(const char *json, asap_envelope_t *out, if (errmsg && errlen && cJSON_IsString(m) && m->valuestring) (void)snprintf(errmsg, errlen, "%s", m->valuestring); cJSON_Delete(tmp_err); } else if (errmsg && errlen) (void)snprintf(errmsg, errlen, "Invalid result envelope"); - /* parse_fail already freed rpc_id; inbound parse does not delete again (Refs: #84). */ + /* parse_fail already owns rpc_id, same as asap_envelope_parse (Refs: #84). */ cJSON_Delete(root); return -1; } From 744cde35aa112079ba2f321035c6412b134252f0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 03:26:52 -0300 Subject: [PATCH 21/92] 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 22/92] 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`. From 9cf62da97b327e69130cc9491cc970ad9692d0bd Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 04:10:46 -0300 Subject: [PATCH 23/92] fix(memory): preserve existing DB when sqlite3_open fails Skip remove() when sqlite3_open fails on a path that already exists. Deleting it caused silent data loss on permissions or transient I/O. Refs: #63 --- src/core/memory.c | 5 +++++ tests/test_memory.c | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/core/memory.c b/src/core/memory.c index a949f93..2f48367 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -131,6 +131,11 @@ int memory_init(const char *path) int recreated = 0; if (sqlite3_open(path, &g_db) != SQLITE_OK) { if (g_db) { sqlite3_close(g_db); g_db = NULL; } + /* Never delete an existing DB on open failure (permissions, transient I/O). */ + if (file_existed) { + fprintf(stderr, "Error: cannot open existing memory DB at %s\n", path); + return -1; + } remove(path); if (sqlite3_open(path, &g_db) != SQLITE_OK) { if (g_db) sqlite3_close(g_db); diff --git a/tests/test_memory.c b/tests/test_memory.c index a72ae47..9347f0f 100644 --- a/tests/test_memory.c +++ b/tests/test_memory.c @@ -7,6 +7,7 @@ #include #include #include +#include #include "sqlite3.h" @@ -153,11 +154,31 @@ static int test_gateway_schema_migration_v01(void) return 0; } +static int test_existing_db_preserved_on_open_failure(void) +{ + const char *path = "/tmp/shellclaw_test_open_fail.db"; + remove(path); + ASSERT(memory_init(path) == 0); + ASSERT(memory_save("preserve", "important data", NULL) == 0); + memory_cleanup(); + ASSERT(chmod(path, 0000) == 0); + ASSERT(memory_init(path) == -1); + ASSERT(chmod(path, 0600) == 0); + ASSERT(memory_init(path) == 0); + char buf[256]; + ASSERT(memory_recall("important", buf, sizeof(buf), 5) == 0); + ASSERT(strstr(buf, "important data") != NULL); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_schema_and_fts5()); RUN(test_save_overwrite()); RUN(test_corrupted_db_recreated()); + RUN(test_existing_db_preserved_on_open_failure()); RUN(test_session_list()); RUN(test_session_crud()); RUN(test_gateway_schema_new_db()); From c6717f3fdce01a75bd7da3209fe1d0b0a0055e50 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 04:10:46 -0300 Subject: [PATCH 24/92] docs(memory): changelog preserve existing DB on open failure Refs: #63 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc52426..955f8d7 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 +- `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). - 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`. From 5f8d4204d66f2a79c3df4c2d46cb50fad1f0084b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 04:28:11 -0300 Subject: [PATCH 25/92] fix(memory): log sqlite errmsg on preserved-DB open failure Capture sqlite3_errmsg before close. Skip the chmod-000 case when sqlite3_open still succeeds (root/DAC), and restore mode 0600 if the init-fail assertion fires. Refs: #63 --- src/core/memory.c | 6 ++++-- tests/test_memory.c | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/core/memory.c b/src/core/memory.c index 2f48367..b6fc955 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -130,12 +130,14 @@ int memory_init(const char *path) int file_existed = path_exists(path); int recreated = 0; if (sqlite3_open(path, &g_db) != SQLITE_OK) { - if (g_db) { sqlite3_close(g_db); g_db = NULL; } /* Never delete an existing DB on open failure (permissions, transient I/O). */ if (file_existed) { - fprintf(stderr, "Error: cannot open existing memory DB at %s\n", path); + fprintf(stderr, "Error: cannot open existing memory DB at %s: %s\n", + path, g_db ? sqlite3_errmsg(g_db) : "unknown"); + if (g_db) { sqlite3_close(g_db); g_db = NULL; } return -1; } + if (g_db) { sqlite3_close(g_db); g_db = NULL; } remove(path); if (sqlite3_open(path, &g_db) != SQLITE_OK) { if (g_db) sqlite3_close(g_db); diff --git a/tests/test_memory.c b/tests/test_memory.c index 9347f0f..1a61b91 100644 --- a/tests/test_memory.c +++ b/tests/test_memory.c @@ -157,12 +157,29 @@ static int test_gateway_schema_migration_v01(void) static int test_existing_db_preserved_on_open_failure(void) { const char *path = "/tmp/shellclaw_test_open_fail.db"; + sqlite3 *probe = NULL; + int open_rc; remove(path); ASSERT(memory_init(path) == 0); ASSERT(memory_save("preserve", "important data", NULL) == 0); memory_cleanup(); ASSERT(chmod(path, 0000) == 0); - ASSERT(memory_init(path) == -1); + open_rc = sqlite3_open(path, &probe); + if (probe) + sqlite3_close(probe); + if (open_rc == SQLITE_OK) { + /* Root / DAC override: mode 000 still opens. Do not delete. */ + (void)chmod(path, 0600); + remove(path); + return 0; + } + if (memory_init(path) != -1) { + fprintf(stderr, "FAIL: %s:%d memory_init(path) == -1\n", __FILE__, __LINE__); + (void)chmod(path, 0600); + memory_cleanup(); + remove(path); + return 1; + } ASSERT(chmod(path, 0600) == 0); ASSERT(memory_init(path) == 0); char buf[256]; From 6e837b21b98e8fbc5077d0f96a117cddc3466c7e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 12:40:03 -0300 Subject: [PATCH 26/92] fix(agent): preserve multi-round ReAct tool results in message history Tool result messages pointed into a reusable scratch buffer that was overwritten on each iteration, corrupting prior rounds before the next provider chat. Own each result with strdup and free ReAct-owned slots on cleanup. Cast through uintptr_t so const tool_calls free cleanly under -Werror=discarded-qualifiers. Refs: #59 --- src/core/agent.c | 75 ++++++++++++++++++++------------ tests/test_agent.c | 104 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 27 deletions(-) diff --git a/src/core/agent.c b/src/core/agent.c index f536687..2e7f45f 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -11,6 +11,7 @@ #include "providers/provider.h" #include "cJSON.h" #include +#include #include #include #include @@ -258,15 +259,23 @@ static const agent_tool_t *find_tool(const agent_tool_t *tools, size_t tool_coun return NULL; } -static void free_tool_calls_copy(provider_tool_call_t *copy, size_t n) +static void agent_free_owned_ptr(const void *p) { + free((void *)(uintptr_t)p); +} + +static void free_tool_calls_copy(const provider_tool_call_t *copy, size_t n) +{ + provider_tool_call_t *owned; + size_t i; if (!copy) return; - for (size_t i = 0; i < n; i++) { - free(copy[i].id); - free(copy[i].name); - free(copy[i].arguments); + owned = (provider_tool_call_t *)(uintptr_t)copy; + for (i = 0; i < n; i++) { + free(owned[i].id); + free(owned[i].name); + free(owned[i].arguments); } - free(copy); + free(owned); } /** Append user+assistant exchange to session JSON. Invalid or empty existing becomes []. */ @@ -343,6 +352,7 @@ typedef struct agent_run_ctx { char *tool_result_bufs; provider_message_t *messages; size_t total_msgs; + size_t base_msg_count; int history_count; int max_iter; int max_ctx; @@ -357,6 +367,20 @@ static void agent_oom_msg(agent_run_ctx_t *ctx) } } +/** Free ReAct-owned slots (assistant text, tool results, tool_calls copies). */ +static void agent_free_heap_messages(agent_run_ctx_t *ctx, size_t from_idx) +{ + size_t i; + if (!ctx->messages) return; + for (i = from_idx; i < ctx->total_msgs; i++) { + agent_free_owned_ptr(ctx->messages[i].content); + ctx->messages[i].content = NULL; + free_tool_calls_copy(ctx->messages[i].tool_calls, ctx->messages[i].tool_calls_count); + ctx->messages[i].tool_calls = NULL; + ctx->messages[i].tool_calls_count = 0; + } +} + /** Load skills, system prompt, memories, session history; compact if over limit. */ static int agent_prepare_context(agent_run_ctx_t *ctx) { @@ -429,6 +453,7 @@ static int agent_build_messages(agent_run_ctx_t *ctx) } ctx->messages[1 + ctx->history_count].role = "user"; ctx->messages[1 + ctx->history_count].content = ctx->user_message; + ctx->base_msg_count = ctx->total_msgs; return 0; } @@ -448,32 +473,20 @@ static int agent_react_loop(agent_run_ctx_t *ctx) { int iteration = 0; provider_response_t response = {0}; - char *prev_assistant = NULL; - provider_tool_call_t *prev_calls = NULL; - size_t prev_n = 0; for (;;) { int err = ctx->provider->chat(ctx->messages, ctx->total_msgs, ctx->tool_defs, ctx->tool_count, &response); if (err != 0) { copy_response_to_buf(response.content, ctx->response_buf, ctx->response_size); provider_response_clear(&response); - free(prev_assistant); - free_tool_calls_copy(prev_calls, prev_n); return -1; } if (response.tool_calls_count == 0 || iteration >= ctx->max_iter) { copy_response_to_buf(response.content, ctx->response_buf, ctx->response_size); provider_response_clear(&response); agent_persist_session(ctx, ctx->response_buf); - free(prev_assistant); - free_tool_calls_copy(prev_calls, prev_n); return 0; } - free(prev_assistant); - free_tool_calls_copy(prev_calls, prev_n); - prev_assistant = NULL; - prev_calls = NULL; - prev_n = 0; { size_t nc = response.tool_calls_count; char *assistant_content; @@ -482,14 +495,12 @@ static int agent_react_loop(agent_run_ctx_t *ctx) size_t new_count; if (nc > MAX_TOOL_CALLS) nc = MAX_TOOL_CALLS; - assistant_content = response.content ? strdup(response.content) : NULL; - if (!assistant_content && response.content && response.content[0] != '\0') { + assistant_content = response.content ? strdup(response.content) : strdup(""); + if (!assistant_content) { provider_response_clear(&response); agent_oom_msg(ctx); return -1; } - if (!assistant_content) - assistant_content = strdup(""); our_calls = copy_tool_calls(response.tool_calls, nc); provider_response_clear(&response); if (!our_calls) { @@ -523,24 +534,34 @@ static int agent_react_loop(agent_run_ctx_t *ctx) new_messages[ctx->total_msgs].tool_calls = our_calls; new_messages[ctx->total_msgs].tool_calls_count = nc; for (size_t k = 0; k < nc; k++) { + char *one_buf = ctx->tool_result_bufs + k * TOOL_RESULT_SIZE; + /* Scratch is reused each round; history must own a copy (Refs: #59). */ + char *tool_content = strdup(one_buf); + if (!tool_content) { + for (size_t j = 0; j < k; j++) + agent_free_owned_ptr(new_messages[ctx->total_msgs + 1 + j].content); + free(new_messages); + free_tool_calls_copy(our_calls, nc); + free(assistant_content); + agent_oom_msg(ctx); + return -1; + } new_messages[ctx->total_msgs + 1 + k].role = "user"; - new_messages[ctx->total_msgs + 1 + k].content = - ctx->tool_result_bufs + k * TOOL_RESULT_SIZE; + new_messages[ctx->total_msgs + 1 + k].content = tool_content; new_messages[ctx->total_msgs + 1 + k].tool_use_id = our_calls[k].id; } free(ctx->messages); ctx->messages = new_messages; ctx->total_msgs = new_count; iteration++; - prev_assistant = assistant_content; - prev_calls = our_calls; - prev_n = nc; } } } static void agent_run_cleanup(agent_run_ctx_t *ctx) { + if (ctx->messages && ctx->base_msg_count < ctx->total_msgs) + agent_free_heap_messages(ctx, ctx->base_msg_count); free(ctx->system_buf); free(ctx->skills_buf); free(ctx->session_buf); diff --git a/tests/test_agent.c b/tests/test_agent.c index 1ee8603..4ed22a9 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -567,6 +567,109 @@ static int test_agent_unknown_tool_continues(void) return 0; } +static int seq_tool_exec_count; +static int seq_tool_execute(const char *args_json, char *result_buf, size_t max_len) +{ + (void)args_json; + seq_tool_exec_count++; + if (max_len > 0) { + snprintf(result_buf, max_len, "tool_output_%d", seq_tool_exec_count); + result_buf[max_len - 1] = '\0'; + } + return 0; +} +static const agent_tool_t seq_echo_tool = { + .name = "echo", + .description = "Echo test with sequence counter", + .parameters_json = "{}", + .execute = seq_tool_execute, +}; + +static int multi_tool_round_call_count; +static int multi_tool_round_init(const config_t *cfg) +{ + (void)cfg; + multi_tool_round_call_count = 0; + seq_tool_exec_count = 0; + return 0; +} +static int multi_tool_round_chat(const provider_message_t *messages, size_t message_count, + const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) +{ + (void)tools; + (void)tool_count; + size_t i; + int saw_first_tool_output = 0; + response->error = 0; + response->tool_calls = NULL; + response->tool_calls_count = 0; + response->content = NULL; + multi_tool_round_call_count++; + if (multi_tool_round_call_count >= 2) { + for (i = 0; i < message_count; i++) { + if (messages[i].content && strstr(messages[i].content, "tool_output_1") != NULL) { + saw_first_tool_output = 1; + break; + } + } + if (!saw_first_tool_output) { + response->content = strdup("CORRUPTED_TOOL_HISTORY"); + return 0; + } + } + if (multi_tool_round_call_count <= 2) { + response->tool_calls = malloc(sizeof(provider_tool_call_t)); + if (!response->tool_calls) { + response->error = 1; + return -1; + } + response->tool_calls[0].id = strdup("mt1"); + response->tool_calls[0].name = strdup("echo"); + response->tool_calls[0].arguments = strdup("{}"); + response->tool_calls_count = 1; + response->content = strdup(""); + return 0; + } + response->content = strdup("multi tool done"); + return 0; +} +static void multi_tool_round_cleanup(void) {} +static const provider_t multi_tool_round_provider = { + .name = "multi_tool_round", + .init = multi_tool_round_init, + .chat = multi_tool_round_chat, + .cleanup = multi_tool_round_cleanup, +}; + +static int test_react_loop_preserves_prior_tool_results(void) +{ + int failed = 1; + const char *path = "build/test_agent_multi_tool.toml"; + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"test\"\nmax_tool_iterations = 5\n"); + fclose(f); + config_t *cfg = NULL; + char errbuf[256]; + char response_buf[4096]; + int ret; + if (config_load(path, &cfg, errbuf, sizeof(errbuf)) != 0) goto cleanup; + if (cfg == NULL) goto cleanup; + response_buf[0] = '\0'; + ret = agent_run(cfg, "cli:multitool", "hi", &multi_tool_round_provider, &seq_echo_tool, 1, + response_buf, sizeof(response_buf)); + if (ret != 0) goto cleanup; + if (strstr(response_buf, "multi tool done") == NULL) goto cleanup; + if (strstr(response_buf, "CORRUPTED_TOOL_HISTORY") != NULL) goto cleanup; + if (multi_tool_round_call_count != 3) goto cleanup; + if (seq_tool_exec_count != 2) goto cleanup; + failed = 0; +cleanup: + config_free(cfg); + remove(path); + return failed; +} + static int test_local_offline_note_skipped_for_non_local(void) { const char *path = "build/test_agent_nonlocal_note.toml"; @@ -597,6 +700,7 @@ int main(void) RUN(test_context_assembly_system_prompt_history_memories()); RUN(test_react_loop_tool_then_text()); RUN(test_react_loop_max_iterations()); + RUN(test_react_loop_preserves_prior_tool_results()); RUN(test_session_persisted_after_exchange()); RUN(test_context_compaction_when_history_exceeds_max()); RUN(test_local_offline_note_when_active_is_local()); From ccff48c9319e9d0a085b23163a607744b950cd1f Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 12:40:27 -0300 Subject: [PATCH 27/92] docs(agent): changelog preserve multi-round ReAct tool results Refs: #59 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 955f8d7..bb7458f 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 +- Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. - `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). - 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. From c7a0b13d69de164e1e1e87a577962a2b5b5ec5b7 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 12:52:24 -0300 Subject: [PATCH 28/92] test(agent): assert ReAct tool_calls stay live across rounds The content-only check would still pass if prev_calls were freed after chat() returned. On the third chat(), require a readable tool_calls id and a matching tool_use_id. Null tool_use_id before freeing copies. Refs: #59 --- src/core/agent.c | 3 +++ tests/test_agent.c | 45 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/core/agent.c b/src/core/agent.c index 2e7f45f..16e55d5 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -372,6 +372,9 @@ static void agent_free_heap_messages(agent_run_ctx_t *ctx, size_t from_idx) { size_t i; if (!ctx->messages) return; + /* tool_use_id aliases our_calls[k].id; drop it before freeing tool_calls. */ + for (i = from_idx; i < ctx->total_msgs; i++) + ctx->messages[i].tool_use_id = NULL; for (i = from_idx; i < ctx->total_msgs; i++) { agent_free_owned_ptr(ctx->messages[i].content); ctx->messages[i].content = NULL; diff --git a/tests/test_agent.c b/tests/test_agent.c index 4ed22a9..7e0a46f 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -586,36 +586,57 @@ static const agent_tool_t seq_echo_tool = { }; static int multi_tool_round_call_count; +static int multi_tool_round_saw_live_tool_calls; static int multi_tool_round_init(const config_t *cfg) { (void)cfg; multi_tool_round_call_count = 0; seq_tool_exec_count = 0; + multi_tool_round_saw_live_tool_calls = 0; return 0; } +static int multi_tool_history_is_intact(const provider_message_t *messages, size_t message_count) +{ + size_t i; + int saw_first_tool_output = 0; + const char *first_call_id = NULL; + int saw_matching_use_id = 0; + for (i = 0; i < message_count; i++) { + if (messages[i].content && strstr(messages[i].content, "tool_output_1") != NULL) + saw_first_tool_output = 1; + if (!first_call_id && messages[i].tool_calls && messages[i].tool_calls_count == 1 && + messages[i].tool_calls[0].id) { + first_call_id = messages[i].tool_calls[0].id; + if (first_call_id[0] == '\0') + return 0; + } + } + if (!saw_first_tool_output || !first_call_id) + return 0; + for (i = 0; i < message_count; i++) { + if (messages[i].tool_use_id && strcmp(messages[i].tool_use_id, first_call_id) == 0) { + saw_matching_use_id = 1; + break; + } + } + return saw_matching_use_id; +} static int multi_tool_round_chat(const provider_message_t *messages, size_t message_count, const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) { (void)tools; (void)tool_count; - size_t i; - int saw_first_tool_output = 0; response->error = 0; response->tool_calls = NULL; response->tool_calls_count = 0; response->content = NULL; multi_tool_round_call_count++; - if (multi_tool_round_call_count >= 2) { - for (i = 0; i < message_count; i++) { - if (messages[i].content && strstr(messages[i].content, "tool_output_1") != NULL) { - saw_first_tool_output = 1; - break; - } - } - if (!saw_first_tool_output) { + if (multi_tool_round_call_count >= 3) { + if (!multi_tool_history_is_intact(messages, message_count)) { response->content = strdup("CORRUPTED_TOOL_HISTORY"); return 0; } + multi_tool_round_saw_live_tool_calls = 1; } if (multi_tool_round_call_count <= 2) { response->tool_calls = malloc(sizeof(provider_tool_call_t)); @@ -655,6 +676,9 @@ static int test_react_loop_preserves_prior_tool_results(void) int ret; if (config_load(path, &cfg, errbuf, sizeof(errbuf)) != 0) goto cleanup; if (cfg == NULL) goto cleanup; + multi_tool_round_call_count = 0; + seq_tool_exec_count = 0; + multi_tool_round_saw_live_tool_calls = 0; response_buf[0] = '\0'; ret = agent_run(cfg, "cli:multitool", "hi", &multi_tool_round_provider, &seq_echo_tool, 1, response_buf, sizeof(response_buf)); @@ -663,6 +687,7 @@ static int test_react_loop_preserves_prior_tool_results(void) if (strstr(response_buf, "CORRUPTED_TOOL_HISTORY") != NULL) goto cleanup; if (multi_tool_round_call_count != 3) goto cleanup; if (seq_tool_exec_count != 2) goto cleanup; + if (!multi_tool_round_saw_live_tool_calls) goto cleanup; failed = 0; cleanup: config_free(cfg); From 0197b9b1706c680a449e1db62f0590e0d5bd8f08 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 15:15:49 -0300 Subject: [PATCH 29/92] fix(agent,memory): refuse session JSON truncation that wipes history When serialized session JSON exceeded SESSION_JSON_MAX, append and load silently clipped mid-JSON. The next agent_run parse then treated history as empty. Refuse oversize copies instead of slicing. Refs: #70 --- src/core/agent.c | 20 ++++-- src/core/memory.c | 10 ++- tests/test_agent.c | 170 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_memory.c | 23 ++++++ 4 files changed, 214 insertions(+), 9 deletions(-) diff --git a/src/core/agent.c b/src/core/agent.c index 16e55d5..a019640 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -130,9 +130,13 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si cJSON_Delete(new_arr); if (!printed) return -1; size_t plen = strlen(printed); - if (plen >= session_buf_size) plen = session_buf_size - 1; - memcpy(session_buf, printed, plen); - session_buf[plen] = '\0'; + /* Refuse mid-JSON truncation: a clipped payload corrupts the session and + * the next agent_run parse treats history as empty (permanent wipe). */ + if (plen >= session_buf_size) { + cJSON_free(printed); + return -1; + } + memcpy(session_buf, printed, plen + 1); cJSON_free(printed); session_save(session_id, session_buf); return 0; @@ -308,9 +312,13 @@ static int append_exchange_to_session_json(const char *existing_json, const char cJSON_Delete(arr); if (!printed) return -1; size_t len = strlen(printed); - if (len >= out_size) len = out_size - 1; - memcpy(out_buf, printed, len); - out_buf[len] = '\0'; + /* Do not truncate: partial JSON saved via session_save is unparseable and + * wipes conversation history on the next agent_run (see agent_prepare_context). */ + if (len >= out_size) { + cJSON_free(printed); + return -1; + } + memcpy(out_buf, printed, len + 1); cJSON_free(printed); return 0; } diff --git a/src/core/memory.c b/src/core/memory.c index b6fc955..1213010 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -245,9 +245,13 @@ int session_load(const char *session_id, char *messages_out, size_t max_len) const char *msg = (const char *)sqlite3_column_text(stmt, 0); if (msg) { size_t n = strlen(msg); - if (n >= max_len) n = max_len - 1; - memcpy(messages_out, msg, n); - messages_out[n] = '\0'; + /* Refuse silent truncation: a clipped messages blob is invalid JSON + * and agent_run would treat the session as empty history. */ + if (n >= max_len) { + sqlite3_finalize(stmt); + return -1; + } + memcpy(messages_out, msg, n + 1); ret = 0; } } diff --git a/tests/test_agent.c b/tests/test_agent.c index 7e0a46f..f8c312d 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -8,6 +8,7 @@ #include "core/config.h" #include "core/memory.h" #include "providers/provider.h" +#include "cJSON.h" #include #include #include @@ -718,6 +719,174 @@ static int test_local_offline_note_skipped_for_non_local(void) return 0; } +/* SESSION_JSON_MAX in agent.c is 128 KiB. A reply this large plus a near-full + * prior session forces append_exchange_to_session_json over the cap. */ +#define OVERFLOW_REPLY_BYTES (16 * 1024) + +static int overflow_reply_init(const config_t *cfg) { (void)cfg; return 0; } +static int overflow_reply_chat(const provider_message_t *messages, size_t message_count, + const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) +{ + char *big; + (void)messages; + (void)message_count; + (void)tools; + (void)tool_count; + response->error = 0; + response->tool_calls = NULL; + response->tool_calls_count = 0; + big = malloc(OVERFLOW_REPLY_BYTES); + if (!big) return -1; + memset(big, 'R', OVERFLOW_REPLY_BYTES - 1); + big[OVERFLOW_REPLY_BYTES - 1] = '\0'; + response->content = big; + return 0; +} +static void overflow_reply_cleanup(void) {} +static const provider_t overflow_reply_provider = { + .name = "overflow_reply", + .init = overflow_reply_init, + .chat = overflow_reply_chat, + .cleanup = overflow_reply_cleanup, +}; + +static int keep_history_init(const config_t *cfg) { (void)cfg; return 0; } +static int keep_history_chat(const provider_message_t *messages, size_t message_count, + const provider_tool_def_t *tools, size_t tool_count, provider_response_t *response) +{ + (void)tools; + (void)tool_count; + response->error = 0; + response->tool_calls = NULL; + response->tool_calls_count = 0; + response->content = strdup("ok"); + spy_roles_clear(); + spy_message_count = message_count; + for (size_t i = 0; i < message_count && i < SPY_SLOTS; i++) { + spy_roles[i] = messages[i].role ? strdup(messages[i].role) : NULL; + if (messages[i].content) { + size_t n = strlen(messages[i].content); + if (n >= SPY_CONTENT_SIZE) n = SPY_CONTENT_SIZE - 1; + memcpy(spy_content[i], messages[i].content, n); + spy_content[i][n] = '\0'; + } else + spy_content[i][0] = '\0'; + } + return 0; +} +static void keep_history_cleanup(void) {} +static const provider_t keep_history_provider = { + .name = "keep_history", + .init = keep_history_init, + .chat = keep_history_chat, + .cleanup = keep_history_cleanup, +}; + +/** + * Concrete trigger: near-cap session JSON + fat assistant reply. + * Before the fix, append truncated mid-JSON, session_save persisted corrupt + * payload, and the next agent_run parse wiped history. After the fix, overflow + * refuses to save and prior history remains parseable. + */ +static int test_session_overflow_does_not_corrupt_history(void) +{ + int failed = 1; + config_t *cfg = NULL; + const char *db_path = "build/test_agent_overflow.db"; + const char *config_path = "build/test_agent_overflow.toml"; + const char *session_id = "cli:overflow"; + const char *marker = "UNIQUE_HISTORY_MARKER_xyz"; + /* Two large messages keep msg_count under max_context so compaction does not shrink first. */ + enum { PAD_A = 62 * 1024, PAD_B = 62 * 1024, LOAD_CAP = 130 * 1024 }; + char *session_json = NULL; + char *pad_a = NULL; + char *pad_b = NULL; + char *loaded = NULL; + size_t need; + size_t off = 0; + char response_buf[OVERFLOW_REPLY_BYTES + 64]; + cJSON *parsed; + + memory_cleanup(); + if (memory_init(db_path) != 0) goto cleanup; + pad_a = malloc(PAD_A + 1); + pad_b = malloc(PAD_B + 1); + if (!pad_a || !pad_b) goto cleanup; + memset(pad_a, 'A', PAD_A); + pad_a[PAD_A] = '\0'; + memset(pad_b, 'B', PAD_B); + pad_b[PAD_B] = '\0'; + need = strlen(marker) + PAD_A + PAD_B + 128; + session_json = malloc(need); + if (!session_json) goto cleanup; + off = (size_t)snprintf(session_json, need, + "[{\"role\":\"user\",\"content\":\"%s%s\"},{\"role\":\"assistant\",\"content\":\"%s\"}]", + marker, pad_a, pad_b); + if (off == 0 || off >= need) goto cleanup; + if (session_save(session_id, session_json) != 0) goto cleanup; + + { + FILE *cf = fopen(config_path, "w"); + if (!cf) goto cleanup; + fprintf(cf, "[agent]\nmodel = \"test\"\nmax_context_messages = 40\n[memory]\npath = \"%s\"\n", + db_path); + fclose(cf); + } + { + char errbuf[256]; + if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) goto cleanup; + } + if (!cfg) goto cleanup; + + response_buf[0] = '\0'; + if (agent_run(cfg, session_id, "push over the limit", &overflow_reply_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) { + fprintf(stderr, "FAIL: tests/test_agent.c: overflow agent_run failed\n"); + goto cleanup; + } + + loaded = malloc(LOAD_CAP); + if (!loaded) goto cleanup; + loaded[0] = '\0'; + if (session_load(session_id, loaded, LOAD_CAP) != 0) goto cleanup; + parsed = cJSON_Parse(loaded); + if (!parsed || !cJSON_IsArray(parsed)) { + if (parsed) cJSON_Delete(parsed); + fprintf(stderr, "FAIL: tests/test_agent.c: overflow session JSON unparseable\n"); + goto cleanup; + } + cJSON_Delete(parsed); + if (strstr(loaded, marker) == NULL) goto cleanup; + + /* Next turn must still see prior history (not wiped to empty []). */ + spy_roles_clear(); + response_buf[0] = '\0'; + if (agent_run(cfg, session_id, "still there?", &keep_history_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) + goto cleanup; + { + int found = 0; + for (size_t j = 0; j < spy_message_count && j < SPY_SLOTS; j++) { + if (strstr(spy_content[j], marker) != NULL) { + found = 1; + break; + } + } + if (!found) goto cleanup; + } + failed = 0; +cleanup: + config_free(cfg); + free(session_json); + free(pad_a); + free(pad_b); + free(loaded); + remove(config_path); + remove(db_path); + memory_cleanup(); + return failed; +} + int main(void) { RUN(test_agent_run_with_stub_and_no_tools()); @@ -730,6 +899,7 @@ int main(void) RUN(test_context_compaction_when_history_exceeds_max()); RUN(test_local_offline_note_when_active_is_local()); RUN(test_local_offline_note_skipped_for_non_local()); + RUN(test_session_overflow_does_not_corrupt_history()); RUN(test_agent_provider_error_response()); RUN(test_agent_unknown_tool_continues()); printf("test_agent: all tests passed\n"); diff --git a/tests/test_memory.c b/tests/test_memory.c index 1a61b91..c653068 100644 --- a/tests/test_memory.c +++ b/tests/test_memory.c @@ -109,6 +109,28 @@ static int test_session_crud(void) return 0; } +/** Oversized session blobs must not be silently truncated into invalid JSON. */ +static int test_session_load_rejects_oversized(void) +{ + const char *path = "/tmp/shellclaw_test_memory_oversized.db"; + char big[2048]; + char small[64]; + size_t i; + remove(path); + ASSERT(memory_init(path) == 0); + big[0] = '['; + for (i = 1; i < sizeof(big) - 2; i++) + big[i] = 'x'; + big[sizeof(big) - 2] = ']'; + big[sizeof(big) - 1] = '\0'; + ASSERT(session_save("cli:big", big) == 0); + ASSERT(session_load("cli:big", small, sizeof(small)) == -1); + ASSERT(small[0] == '\0'); + memory_cleanup(); + remove(path); + return 0; +} + static int test_gateway_schema_new_db(void) { const char *path = "/tmp/shellclaw_test_gateway_schema.db"; @@ -198,6 +220,7 @@ int main(void) RUN(test_existing_db_preserved_on_open_failure()); RUN(test_session_list()); RUN(test_session_crud()); + RUN(test_session_load_rejects_oversized()); RUN(test_gateway_schema_new_db()); RUN(test_gateway_schema_migration_v01()); printf("test_memory: all tests passed\n"); From 8d8748cff4c73f25f33c5a04214c24d1f68fbffe Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 15:15:49 -0300 Subject: [PATCH 30/92] docs(agent,memory): changelog refuse session JSON truncation Refs: #70 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7458f..32a4017 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 +- Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. - Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. - `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). - Anthropic `content` parse fails closed when growing the text buffer or `tool_use` array cannot `realloc`, instead of copying against an inflated cap. From 1b54efdbb3dcaf7009331410941c979cfb752652 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 15:29:10 -0300 Subject: [PATCH 31/92] fix(agent,memory): log refused session JSON and skip persist on oversized load Refuse copies now log session_id, len, and cap. session_load returns SESSION_LOAD_TOO_LARGE so a later small turn cannot overwrite the stored blob. Refs: #70 --- CHANGELOG.md | 2 +- src/core/agent.c | 61 ++++++++++++++++++++-------------- src/core/memory.c | 2 +- src/core/memory.h | 11 ++++++- tests/test_agent.c | 80 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_memory.c | 2 +- 6 files changed, 130 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a4017..2cdacfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed -- Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. +- Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. - Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. - `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). - Anthropic `content` parse fails closed when growing the text buffer or `tool_use` array cannot `realloc`, instead of copying against an inflated cap. diff --git a/src/core/agent.c b/src/core/agent.c index a019640..34ef677 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -60,6 +60,26 @@ int agent_mutex_is_locked_for_test(void) #define SUMMARY_SOURCE_MAX (64 * 1024) #define SUMMARY_RESULT_MAX 4096 +/** Copy cJSON_PrintUnformatted output or refuse mid-JSON truncation (Refs: #70). */ +static int copy_printed_session_json(char *dst, size_t dst_size, char *printed, + const char *session_id) +{ + size_t len; + if (!printed) + return -1; + len = strlen(printed); + if (len >= dst_size) { + fprintf(stderr, + "agent: refuse session JSON truncation session_id=%s len=%zu cap=%zu\n", + session_id ? session_id : "", len, dst_size); + cJSON_free(printed); + return -1; + } + memcpy(dst, printed, len + 1); + cJSON_free(printed); + return 0; +} + static const char SUMMARIZE_SYSTEM[] = "Summarize the following conversation in one short paragraph. Output only the summary, no preamble."; /** Summarize oldest messages when over max_ctx; replace with one summary + trailing. */ @@ -128,16 +148,8 @@ static int compact_session_via_llm(const char *session_id, char *session_buf, si cJSON_Delete(root); char *printed = cJSON_PrintUnformatted(new_arr); cJSON_Delete(new_arr); - if (!printed) return -1; - size_t plen = strlen(printed); - /* Refuse mid-JSON truncation: a clipped payload corrupts the session and - * the next agent_run parse treats history as empty (permanent wipe). */ - if (plen >= session_buf_size) { - cJSON_free(printed); + if (copy_printed_session_json(session_buf, session_buf_size, printed, session_id) != 0) return -1; - } - memcpy(session_buf, printed, plen + 1); - cJSON_free(printed); session_save(session_id, session_buf); return 0; } @@ -284,7 +296,7 @@ static void free_tool_calls_copy(const provider_tool_call_t *copy, size_t n) /** Append user+assistant exchange to session JSON. Invalid or empty existing becomes []. */ static int append_exchange_to_session_json(const char *existing_json, const char *user_message, - const char *assistant_content, char *out_buf, size_t out_size) + const char *assistant_content, char *out_buf, size_t out_size, const char *session_id) { cJSON *arr = NULL; if (existing_json && existing_json[0] == '[') { @@ -310,17 +322,7 @@ static int append_exchange_to_session_json(const char *existing_json, const char } char *printed = cJSON_PrintUnformatted(arr); cJSON_Delete(arr); - if (!printed) return -1; - size_t len = strlen(printed); - /* Do not truncate: partial JSON saved via session_save is unparseable and - * wipes conversation history on the next agent_run (see agent_prepare_context). */ - if (len >= out_size) { - cJSON_free(printed); - return -1; - } - memcpy(out_buf, printed, len + 1); - cJSON_free(printed); - return 0; + return copy_printed_session_json(out_buf, out_size, printed, session_id); } static provider_tool_call_t *copy_tool_calls(const provider_tool_call_t *src, size_t n) @@ -365,6 +367,7 @@ typedef struct agent_run_ctx { int max_iter; int max_ctx; int ret; + int skip_session_persist; } agent_run_ctx_t; static void agent_oom_msg(agent_run_ctx_t *ctx) @@ -428,7 +431,10 @@ static int agent_prepare_context(agent_run_ctx_t *ctx) if (ctx->max_ctx <= 0 || ctx->max_ctx > MAX_HISTORY_MESSAGES) ctx->max_ctx = MAX_HISTORY_MESSAGES; ctx->session_buf[0] = '\0'; - session_load(ctx->session_id, ctx->session_buf, SESSION_JSON_MAX); + { + int load_rc = session_load(ctx->session_id, ctx->session_buf, SESSION_JSON_MAX); + ctx->skip_session_persist = (load_rc == SESSION_LOAD_TOO_LARGE); + } parsed = cJSON_Parse(ctx->session_buf); msg_count = (parsed && cJSON_IsArray(parsed)) ? cJSON_GetArraySize(parsed) : 0; if (parsed) @@ -470,11 +476,18 @@ static int agent_build_messages(agent_run_ctx_t *ctx) static void agent_persist_session(agent_run_ctx_t *ctx, const char *assistant_content) { - char *updated = malloc(SESSION_JSON_MAX); + char *updated; + if (ctx->skip_session_persist) { + fprintf(stderr, + "agent: skip session persist session_id=%s (stored blob exceeds cap)\n", + ctx->session_id ? ctx->session_id : ""); + return; + } + updated = malloc(SESSION_JSON_MAX); if (!updated) return; if (append_exchange_to_session_json(ctx->session_buf, ctx->user_message, assistant_content, - updated, SESSION_JSON_MAX) == 0) + updated, SESSION_JSON_MAX, ctx->session_id) == 0) session_save(ctx->session_id, updated); free(updated); } diff --git a/src/core/memory.c b/src/core/memory.c index 1213010..067b52c 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -249,7 +249,7 @@ int session_load(const char *session_id, char *messages_out, size_t max_len) * and agent_run would treat the session as empty history. */ if (n >= max_len) { sqlite3_finalize(stmt); - return -1; + return SESSION_LOAD_TOO_LARGE; } memcpy(messages_out, msg, n + 1); ret = 0; diff --git a/src/core/memory.h b/src/core/memory.h index 147e5a9..f8149ea 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -42,13 +42,22 @@ int memory_save(const char *key, const char *content, const char *metadata); */ int memory_recall(const char *query, char *results, size_t max_len, int limit); +/** + * Stored session JSON does not fit the caller buffer. messages_out is left empty. + * Distinct from "not found" so callers can skip persist instead of overwriting. + */ +#define SESSION_LOAD_TOO_LARGE (-2) + /** * Load session messages by session ID (e.g. "cli:default" or "telegram:123456789"). * * @param session_id Session identifier. * @param messages_out Output buffer for JSON array of messages; caller must free if allocated. * @param max_len Size of messages_out buffer (or 0 if messages_out is to be allocated by implementation). - * @return 0 on success, non-zero if not found or error. + * @return 0 on success, SESSION_LOAD_TOO_LARGE if the blob does not fit max_len, + * -1 if not found or error. + * + * Example: `if (session_load(id, buf, sizeof(buf)) == SESSION_LOAD_TOO_LARGE) skip_save;` */ int session_load(const char *session_id, char *messages_out, size_t max_len); diff --git a/tests/test_agent.c b/tests/test_agent.c index f8c312d..78cb14c 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -887,6 +887,85 @@ static int test_session_overflow_does_not_corrupt_history(void) return failed; } +/** + * A stored blob larger than SESSION_JSON_MAX must not be replaced by a later + * small turn: session_load refuse leaves an empty buffer, and persist must not + * treat that as a new empty session. + */ +static int test_oversized_stored_session_not_wiped_by_small_turn(void) +{ + int failed = 1; + config_t *cfg = NULL; + const char *db_path = "build/test_agent_oversize_load.db"; + const char *config_path = "build/test_agent_oversize_load.toml"; + const char *session_id = "cli:oversize-load"; + const char *marker = "OVERSIZE_LOAD_MARKER_xyz"; + enum { PAD = 128 * 1024, LOAD_CAP = 160 * 1024 }; + char *pad = NULL; + char *session_json = NULL; + char *loaded = NULL; + size_t need; + size_t off = 0; + char response_buf[256]; + cJSON *parsed; + + memory_cleanup(); + if (memory_init(db_path) != 0) goto cleanup; + pad = malloc(PAD + 1); + if (!pad) goto cleanup; + memset(pad, 'Z', PAD); + pad[PAD] = '\0'; + need = strlen(marker) + PAD + 128; + session_json = malloc(need); + if (!session_json) goto cleanup; + off = (size_t)snprintf(session_json, need, + "[{\"role\":\"user\",\"content\":\"%s%s\"}]", marker, pad); + if (off == 0 || off >= need) goto cleanup; + if (session_save(session_id, session_json) != 0) goto cleanup; + { + FILE *cf = fopen(config_path, "w"); + if (!cf) goto cleanup; + fprintf(cf, "[agent]\nmodel = \"test\"\n[memory]\npath = \"%s\"\n", db_path); + fclose(cf); + } + { + char errbuf[256]; + if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) goto cleanup; + } + if (!cfg) goto cleanup; + response_buf[0] = '\0'; + if (agent_run(cfg, session_id, "tiny", &persist_reply_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) { + fprintf(stderr, "FAIL: tests/test_agent.c: oversize-load agent_run failed\n"); + goto cleanup; + } + loaded = malloc(LOAD_CAP); + if (!loaded) goto cleanup; + loaded[0] = '\0'; + if (session_load(session_id, loaded, LOAD_CAP) != 0) goto cleanup; + parsed = cJSON_Parse(loaded); + if (!parsed || !cJSON_IsArray(parsed)) { + if (parsed) cJSON_Delete(parsed); + fprintf(stderr, "FAIL: tests/test_agent.c: oversize stored session wiped or corrupt\n"); + goto cleanup; + } + cJSON_Delete(parsed); + if (strstr(loaded, marker) == NULL) { + fprintf(stderr, "FAIL: tests/test_agent.c: oversize stored session missing marker\n"); + goto cleanup; + } + failed = 0; +cleanup: + config_free(cfg); + free(pad); + free(session_json); + free(loaded); + remove(config_path); + remove(db_path); + memory_cleanup(); + return failed; +} + int main(void) { RUN(test_agent_run_with_stub_and_no_tools()); @@ -900,6 +979,7 @@ int main(void) RUN(test_local_offline_note_when_active_is_local()); RUN(test_local_offline_note_skipped_for_non_local()); RUN(test_session_overflow_does_not_corrupt_history()); + RUN(test_oversized_stored_session_not_wiped_by_small_turn()); RUN(test_agent_provider_error_response()); RUN(test_agent_unknown_tool_continues()); printf("test_agent: all tests passed\n"); diff --git a/tests/test_memory.c b/tests/test_memory.c index c653068..8b0c7b5 100644 --- a/tests/test_memory.c +++ b/tests/test_memory.c @@ -124,7 +124,7 @@ static int test_session_load_rejects_oversized(void) big[sizeof(big) - 2] = ']'; big[sizeof(big) - 1] = '\0'; ASSERT(session_save("cli:big", big) == 0); - ASSERT(session_load("cli:big", small, sizeof(small)) == -1); + ASSERT(session_load("cli:big", small, sizeof(small)) == SESSION_LOAD_TOO_LARGE); ASSERT(small[0] == '\0'); memory_cleanup(); remove(path); From 93cd374e166abc7a5cf290ffa1a403a9255ee313 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 16:50:45 -0300 Subject: [PATCH 32/92] fix(agent): skip memory injection when system prompt buffer is full Refs: #75 --- src/core/agent.c | 26 ++++++++++--- tests/test_agent.c | 95 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/core/agent.c b/src/core/agent.c index 34ef677..90cbed2 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -212,13 +212,27 @@ static void copy_response_to_buf(const char *content, char *response_buf, size_t static size_t append_memories_to_system(char *system_buf, size_t buf_size, const char *recall_buf) { size_t len = strlen(system_buf); - if (len == 0 || !recall_buf || recall_buf[0] == '\0') return len; + size_t prefix_len; + size_t recall_len; + size_t remain; const char *prefix = "\n\nRelevant memories:\n\n"; - size_t prefix_len = strlen(prefix); - size_t recall_len = strlen(recall_buf); - if (len + prefix_len + recall_len + 1 > buf_size) - recall_len = buf_size > len + prefix_len ? (buf_size - len - prefix_len - 1) : 0; - if (prefix_len + recall_len == 0) return len; + if (len == 0 || !recall_buf || recall_buf[0] == '\0') + return len; + /* prefix_len is never 0, so the old `prefix_len + recall_len == 0` guard + * never fired. When the prompt filled SYSTEM_PROMPT_MAX, recall_len was + * clamped to 0 and memcpy still wrote the prefix (and a NUL) past the heap + * buffer (Refs: #75). Skip unless prefix + at least one recall byte + NUL fit. */ + if (len >= buf_size) + return len; + prefix_len = strlen(prefix); + if (len + prefix_len + 2U > buf_size) + return len; + recall_len = strlen(recall_buf); + remain = buf_size - len - prefix_len - 1U; + if (recall_len > remain) + recall_len = remain; + if (recall_len == 0) + return len; memcpy(system_buf + len, prefix, prefix_len); len += prefix_len; memcpy(system_buf + len, recall_buf, recall_len); diff --git a/tests/test_agent.c b/tests/test_agent.c index 78cb14c..7f683ce 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -21,6 +21,7 @@ #define SPY_CONTENT_SIZE 4096 #define SPY_SLOTS 8 static size_t spy_message_count; +static size_t spy_first_content_len; static char spy_content[SPY_SLOTS][SPY_CONTENT_SIZE]; static char *spy_roles[SPY_SLOTS]; @@ -43,12 +44,15 @@ static int spy_chat(const provider_message_t *messages, size_t message_count, response->tool_calls_count = 0; spy_roles_clear(); spy_message_count = message_count; + spy_first_content_len = (message_count > 0 && messages[0].content) + ? strlen(messages[0].content) : 0; for (size_t i = 0; i < message_count && i < SPY_SLOTS; i++) { spy_roles[i] = messages[i].role ? strdup(messages[i].role) : NULL; if (messages[i].content) { size_t n = strlen(messages[i].content); if (n >= SPY_CONTENT_SIZE) n = SPY_CONTENT_SIZE - 1; - memcpy(spy_content[i], messages[i].content, n + 1); + memcpy(spy_content[i], messages[i].content, n); + spy_content[i][n] = '\0'; } else spy_content[i][0] = '\0'; } @@ -966,6 +970,94 @@ static int test_oversized_stored_session_not_wiped_by_small_turn(void) return failed; } +static int write_filled_soul_file(const char *path, size_t nbytes) +{ + char chunk[4096]; + size_t remaining = nbytes; + FILE *sf = fopen(path, "w"); + if (!sf) + return -1; + memset(chunk, 'A', sizeof(chunk)); + while (remaining > 0) { + size_t n = remaining < sizeof(chunk) ? remaining : sizeof(chunk); + if (fwrite(chunk, 1, n, sf) != n) { + fclose(sf); + return -1; + } + remaining -= n; + } + fclose(sf); + return 0; +} + +static int test_full_system_prompt_skips_memory_append_without_overflow(void) +{ + int failed = 1; + const char *db_path = "build/test_agent_mem_overflow.db"; + const char *soul_path = "build/test_agent_mem_overflow_soul.md"; + const char *config_path = "build/test_agent_mem_overflow.toml"; + config_t *cfg = NULL; + char response_buf[4096]; + char errbuf[256] = {0}; + FILE *cf; + + /* SYSTEM_PROMPT_MAX is 65536; a 65535-byte SOUL fills it so the 22-byte + * "Relevant memories" prefix cannot fit. The old clamp still memcpy'd + * the prefix past the heap allocation (Refs: #75). */ + memory_cleanup(); + if (memory_init(db_path) != 0) + goto cleanup; + if (memory_save("pref", "User likes coffee. New message context.", NULL) != 0) + goto cleanup; + { + char recall_check[512]; + if (memory_recall("coffee", recall_check, sizeof(recall_check), 5) != 0) + goto cleanup; + if (recall_check[0] == '\0') + goto cleanup; + } + if (write_filled_soul_file(soul_path, 65535U) != 0) + goto cleanup; + cf = fopen(config_path, "w"); + if (!cf) + goto cleanup; + fprintf(cf, + "[agent]\nmodel = \"test\"\n[agent.identity]\nsoul = \"%s\"\n[memory]\ndb_path = \"%s\"\n", + soul_path, db_path); + fclose(cf); + if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) + goto cleanup; + if (!cfg) + goto cleanup; + spy_roles_clear(); + spy_first_content_len = 0; + if (agent_run(cfg, "cli:memoverflow", "coffee", &spy_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) { + fprintf(stderr, "FAIL: tests/test_agent.c: full-prompt memory skip agent_run failed\n"); + goto cleanup; + } + if (spy_message_count < 1) + goto cleanup; + if (!spy_roles[0] || strcmp(spy_roles[0], "system") != 0) + goto cleanup; + if (spy_first_content_len != 65535U) { + fprintf(stderr, + "FAIL: tests/test_agent.c: system prompt len %zu (expected 65535, memories not skipped)\n", + spy_first_content_len); + goto cleanup; + } + if (spy_content[0][0] != 'A') + goto cleanup; + failed = 0; +cleanup: + config_free(cfg); + remove(config_path); + remove(soul_path); + remove(db_path); + memory_cleanup(); + return failed; +} + int main(void) { RUN(test_agent_run_with_stub_and_no_tools()); @@ -980,6 +1072,7 @@ int main(void) RUN(test_local_offline_note_skipped_for_non_local()); RUN(test_session_overflow_does_not_corrupt_history()); RUN(test_oversized_stored_session_not_wiped_by_small_turn()); + RUN(test_full_system_prompt_skips_memory_append_without_overflow()); RUN(test_agent_provider_error_response()); RUN(test_agent_unknown_tool_continues()); printf("test_agent: all tests passed\n"); From cd236d9e516fc9d29b97ca1cddfe7e33df2e04aa Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 16:50:45 -0300 Subject: [PATCH 33/92] docs(agent): changelog skip memory injection when prompt is full Refs: #75 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cdacfc..3d466db 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 +- Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of clamping a truncated `Relevant memories` prefix past the allocation. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. - Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. - `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). From 7deaad7997a61829c431970305c175acc5ea90a0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 17:05:09 -0300 Subject: [PATCH 34/92] fix(agent): log skipped memory injection on a full system prompt Cover the one-byte recall clip path. Refs: #75 --- CHANGELOG.md | 2 +- src/core/agent.c | 6 +- tests/test_agent.c | 155 +++++++++++++++++++++++++++++++++++++++------ 3 files changed, 141 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d466db..4e42e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed -- Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of clamping a truncated `Relevant memories` prefix past the allocation. +- Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. - Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. - `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). diff --git a/src/core/agent.c b/src/core/agent.c index 90cbed2..92bc6f0 100644 --- a/src/core/agent.c +++ b/src/core/agent.c @@ -222,11 +222,11 @@ static size_t append_memories_to_system(char *system_buf, size_t buf_size, const * never fired. When the prompt filled SYSTEM_PROMPT_MAX, recall_len was * clamped to 0 and memcpy still wrote the prefix (and a NUL) past the heap * buffer (Refs: #75). Skip unless prefix + at least one recall byte + NUL fit. */ - if (len >= buf_size) - return len; prefix_len = strlen(prefix); - if (len + prefix_len + 2U > buf_size) + if (len >= buf_size || buf_size - len < prefix_len + 2U) { + fprintf(stderr, "agent: skip memory injection len=%zu cap=%zu\n", len, buf_size); return len; + } recall_len = strlen(recall_buf); remain = buf_size - len - prefix_len - 1U; if (recall_len > remain) diff --git a/tests/test_agent.c b/tests/test_agent.c index 7f683ce..4b3b7e6 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -9,6 +9,7 @@ #include "core/memory.h" #include "providers/provider.h" #include "cJSON.h" +#include #include #include #include @@ -22,6 +23,7 @@ #define SPY_SLOTS 8 static size_t spy_message_count; static size_t spy_first_content_len; +static char spy_first_last_char; static char spy_content[SPY_SLOTS][SPY_CONTENT_SIZE]; static char *spy_roles[SPY_SLOTS]; @@ -46,6 +48,8 @@ static int spy_chat(const provider_message_t *messages, size_t message_count, spy_message_count = message_count; spy_first_content_len = (message_count > 0 && messages[0].content) ? strlen(messages[0].content) : 0; + spy_first_last_char = (spy_first_content_len > 0 && messages[0].content) + ? messages[0].content[spy_first_content_len - 1] : '\0'; for (size_t i = 0; i < message_count && i < SPY_SLOTS; i++) { spy_roles[i] = messages[i].role ? strdup(messages[i].role) : NULL; if (messages[i].content) { @@ -990,52 +994,90 @@ static int write_filled_soul_file(const char *path, size_t nbytes) return 0; } +static int write_mem_overflow_config(const char *config_path, const char *soul_path, + const char *db_path) +{ + FILE *cf = fopen(config_path, "w"); + if (!cf) + return -1; + fprintf(cf, + "[agent]\nmodel = \"test\"\n[agent.identity]\nsoul = \"%s\"\n" + "[memory]\ndb_path = \"%s\"\n[skills]\ndir = \"build/test_agent_mem_noskills\"\n", + soul_path, db_path); + fclose(cf); + return 0; +} + +static int prepare_coffee_memory_store(const char *db_path) +{ + char recall_check[512]; + memory_cleanup(); + if (memory_init(db_path) != 0) + return -1; + if (memory_save("pref", "User likes coffee. New message context.", NULL) != 0) + return -1; + if (memory_recall("coffee", recall_check, sizeof(recall_check), 5) != 0) + return -1; + if (recall_check[0] == '\0') + return -1; + return 0; +} + static int test_full_system_prompt_skips_memory_append_without_overflow(void) { int failed = 1; const char *db_path = "build/test_agent_mem_overflow.db"; const char *soul_path = "build/test_agent_mem_overflow_soul.md"; const char *config_path = "build/test_agent_mem_overflow.toml"; + const char *err_path = "build/test_agent_mem_overflow.err"; config_t *cfg = NULL; char response_buf[4096]; char errbuf[256] = {0}; - FILE *cf; + char captured[2048]; + int saved_stderr = -1; + int errfd = -1; + FILE *ef; /* SYSTEM_PROMPT_MAX is 65536; a 65535-byte SOUL fills it so the 22-byte * "Relevant memories" prefix cannot fit. The old clamp still memcpy'd * the prefix past the heap allocation (Refs: #75). */ - memory_cleanup(); - if (memory_init(db_path) != 0) + if (prepare_coffee_memory_store(db_path) != 0) goto cleanup; - if (memory_save("pref", "User likes coffee. New message context.", NULL) != 0) - goto cleanup; - { - char recall_check[512]; - if (memory_recall("coffee", recall_check, sizeof(recall_check), 5) != 0) - goto cleanup; - if (recall_check[0] == '\0') - goto cleanup; - } if (write_filled_soul_file(soul_path, 65535U) != 0) goto cleanup; - cf = fopen(config_path, "w"); - if (!cf) + if (write_mem_overflow_config(config_path, soul_path, db_path) != 0) goto cleanup; - fprintf(cf, - "[agent]\nmodel = \"test\"\n[agent.identity]\nsoul = \"%s\"\n[memory]\ndb_path = \"%s\"\n", - soul_path, db_path); - fclose(cf); if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) goto cleanup; if (!cfg) goto cleanup; spy_roles_clear(); spy_first_content_len = 0; + errfd = open(err_path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (errfd < 0) + goto cleanup; + saved_stderr = dup(STDERR_FILENO); + if (saved_stderr < 0) + goto cleanup; + if (dup2(errfd, STDERR_FILENO) < 0) + goto cleanup; + close(errfd); + errfd = -1; if (agent_run(cfg, "cli:memoverflow", "coffee", &spy_provider, NULL, 0, response_buf, sizeof(response_buf)) != 0) { + if (saved_stderr >= 0) { + fflush(stderr); + dup2(saved_stderr, STDERR_FILENO); + close(saved_stderr); + saved_stderr = -1; + } fprintf(stderr, "FAIL: tests/test_agent.c: full-prompt memory skip agent_run failed\n"); goto cleanup; } + fflush(stderr); + dup2(saved_stderr, STDERR_FILENO); + close(saved_stderr); + saved_stderr = -1; if (spy_message_count < 1) goto cleanup; if (!spy_roles[0] || strcmp(spy_roles[0], "system") != 0) @@ -1048,6 +1090,82 @@ static int test_full_system_prompt_skips_memory_append_without_overflow(void) } if (spy_content[0][0] != 'A') goto cleanup; + ef = fopen(err_path, "r"); + if (!ef) + goto cleanup; + { + size_t n = fread(captured, 1, sizeof(captured) - 1, ef); + captured[n] = '\0'; + fclose(ef); + } + if (strstr(captured, "agent: skip memory injection") == NULL) { + fprintf(stderr, "FAIL: tests/test_agent.c: missing skip memory injection log\n"); + goto cleanup; + } + failed = 0; +cleanup: + if (errfd >= 0) + close(errfd); + if (saved_stderr >= 0) { + fflush(stderr); + dup2(saved_stderr, STDERR_FILENO); + close(saved_stderr); + } + config_free(cfg); + remove(config_path); + remove(soul_path); + remove(db_path); + remove(err_path); + memory_cleanup(); + return failed; +} + +static int test_near_full_system_prompt_keeps_one_recall_byte(void) +{ + int failed = 1; + const char *db_path = "build/test_agent_mem_trunc.db"; + const char *soul_path = "build/test_agent_mem_trunc_soul.md"; + const char *config_path = "build/test_agent_mem_trunc.toml"; + config_t *cfg = NULL; + char response_buf[4096]; + char errbuf[256] = {0}; + + /* Prefix is 22 bytes. Soul 65510 plus PROMPT_SEP "\\n\\n" yields len 65512 + * so len + prefix + 1 recall byte + NUL == SYSTEM_PROMPT_MAX. One FTS byte + * ('U' from "User likes coffee") must be kept (Refs: #75). */ + if (prepare_coffee_memory_store(db_path) != 0) + goto cleanup; + if (write_filled_soul_file(soul_path, 65510U) != 0) + goto cleanup; + if (write_mem_overflow_config(config_path, soul_path, db_path) != 0) + goto cleanup; + if (config_load(config_path, &cfg, errbuf, sizeof(errbuf)) != 0) + goto cleanup; + if (!cfg) + goto cleanup; + spy_roles_clear(); + spy_first_content_len = 0; + if (agent_run(cfg, "cli:memtrunc", "coffee", &spy_provider, NULL, 0, + response_buf, sizeof(response_buf)) != 0) { + fprintf(stderr, "FAIL: tests/test_agent.c: near-full memory clip agent_run failed\n"); + goto cleanup; + } + if (spy_message_count < 1) + goto cleanup; + if (!spy_roles[0] || strcmp(spy_roles[0], "system") != 0) + goto cleanup; + if (spy_first_content_len != 65535U) { + fprintf(stderr, + "FAIL: tests/test_agent.c: clipped prompt len %zu (expected 65535)\n", + spy_first_content_len); + goto cleanup; + } + if (spy_first_last_char != 'U') { + fprintf(stderr, + "FAIL: tests/test_agent.c: last byte 0x%02x (expected clipped recall 'U')\n", + (unsigned char)spy_first_last_char); + goto cleanup; + } failed = 0; cleanup: config_free(cfg); @@ -1073,6 +1191,7 @@ int main(void) RUN(test_session_overflow_does_not_corrupt_history()); RUN(test_oversized_stored_session_not_wiped_by_small_turn()); RUN(test_full_system_prompt_skips_memory_append_without_overflow()); + RUN(test_near_full_system_prompt_keeps_one_recall_byte()); RUN(test_agent_provider_error_response()); RUN(test_agent_unknown_tool_continues()); printf("test_agent: all tests passed\n"); From bf7a797fc0acb04b473f4cc7da526d74572cb2d2 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 18:53:56 -0300 Subject: [PATCH 35/92] test(gateway): overlap Bearer GETs with SIGTERM teardown Sequential /api/status calls finished before the signal, so the helper could pass on the old auth_cleanup-then-http_stop order. Background loaders keep auth_validate_token in flight across kill. SIGKILL only if waitpid never reaped the child. Refs: #72 --- Makefile | 2 +- tests/test_gateway_http.c | 118 +++++++++++++++++++++++++++++++------- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index ea36634..922d76f 100644 --- a/Makefile +++ b/Makefile @@ -713,7 +713,7 @@ test_gateway_http: shellclaw tests/test_gateway_http.c $(AUTH_O) $(CONFIG_O) $(T echo "test_gateway_http: skipped (GATEWAY=0)"; exit 0; \ fi @mkdir -p $(BINDIR) - $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -DSHELLCLAW_GATEWAY -o $(BINDIR)/$@ tests/test_gateway_http.c $(AUTH_O) $(CRYPTO_LINK) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) + $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -DSHELLCLAW_GATEWAY -pthread -o $(BINDIR)/$@ tests/test_gateway_http.c $(AUTH_O) $(CRYPTO_LINK) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) -pthread $(DSYM_SCRIPT) test_routes_hardware: tests/test_routes_hardware.c tests/test_routes_json_stub.c $(ROUTES_HARDWARE_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_INIT_O) $(HARDWARE_STUB_O) $(BOARD_DETECT_O) $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(HARDWARE_LIBGPIOD_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 9c4ec74..c489386 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1049,31 +1050,105 @@ static int test_api_asap_log_401(void) return 0; } -static int test_shutdown_does_not_crash(pid_t pid, const char *token) +#define SHUTDOWN_LOAD_THREADS 4 +#define SHUTDOWN_LOAD_START_SPINS 50 + +/* Bearer GETs that stay in-flight across SIGTERM so auth_validate_token + * still runs while cleanup_subsystems joins the lws thread. */ +struct shutdown_load { + char url[256]; + const char *token; + volatile sig_atomic_t stop; + volatile sig_atomic_t started; +}; + +static void *shutdown_load_thread(void *arg) +{ + struct shutdown_load *load = (struct shutdown_load *)arg; + while (!load->stop) { + long code = 0; + char *body = NULL; + load->started = 1; + (void)http_get_auth(load->url, load->token, &code, &body); + free(body); + } + return NULL; +} + +static int shutdown_load_spawn(struct shutdown_load *load, pthread_t *thds, int n) { int i; - int status = 0; + int ncreated = 0; + for (i = 0; i < n; i++) { + if (pthread_create(&thds[ncreated], NULL, shutdown_load_thread, load) != 0) + continue; + ncreated++; + } + return ncreated; +} - if (token && token[0]) { - for (i = 0; i < 16; i++) { - long code = 0; - char *body = NULL; - (void)http_get_auth(gw_url("/api/status"), token, &code, &body); - free(body); - } +static void shutdown_load_wait_started(const struct shutdown_load *load) +{ + int i; + for (i = 0; i < SHUTDOWN_LOAD_START_SPINS; i++) { + struct timespec delay = { 0, 10000000L }; + if (load->started) + return; + (void)nanosleep(&delay, NULL); } - ASSERT(kill(pid, SIGTERM) == 0); - ASSERT(waitpid(pid, &status, 0) == pid); - if (WIFSIGNALED(status)) { - int sig = WTERMSIG(status); - if (sig == SIGSEGV || sig == SIGABRT || sig == SIGBUS || sig == SIGILL) { - fprintf(stderr, "FAIL: gateway crashed on shutdown with signal %d\n", sig); - return 1; - } +} + +static void shutdown_load_join(struct shutdown_load *load, pthread_t *thds, int ncreated) +{ + int i; + load->stop = 1; + for (i = 0; i < ncreated; i++) + (void)pthread_join(thds[i], NULL); +} + +static int shutdown_crash_status(int status) +{ + int sig; + if (!WIFSIGNALED(status)) + return 0; + sig = WTERMSIG(status); + if (sig == SIGSEGV || sig == SIGABRT || sig == SIGBUS || sig == SIGILL) { + fprintf(stderr, "FAIL: gateway crashed on shutdown with signal %d\n", sig); + return 1; } return 0; } +static int test_shutdown_does_not_crash(pid_t pid, const char *token, int *reaped) +{ + struct shutdown_load load; + pthread_t thds[SHUTDOWN_LOAD_THREADS]; + int ncreated = 0; + int status = 0; + if (reaped) + *reaped = 0; + memset(&load, 0, sizeof(load)); + if (token && token[0]) { + (void)curl_global_init(CURL_GLOBAL_DEFAULT); + load.token = token; + snprintf(load.url, sizeof(load.url), "%s/api/status", g_base_url); + ncreated = shutdown_load_spawn(&load, thds, SHUTDOWN_LOAD_THREADS); + shutdown_load_wait_started(&load); + } + if (kill(pid, SIGTERM) != 0) { + shutdown_load_join(&load, thds, ncreated); + return 1; + } + if (waitpid(pid, &status, 0) != pid) { + shutdown_load_join(&load, thds, ncreated); + return 1; + } + if (reaped) + *reaped = 1; + shutdown_load_join(&load, thds, ncreated); + return shutdown_crash_status(status); +} + static int test_api_asap_log(const char *token) { long code; @@ -1297,6 +1372,7 @@ int main(int argc, char **argv) } char token[128] = {0}; int failed = 0; + int shutdown_reaped = 0; if (test_health() != 0) { fprintf(stderr, "test_health failed\n"); failed++; } if (test_pair(pairing_code, token, sizeof(token)) != 0) { fprintf(stderr, "test_pair failed\n"); @@ -1383,11 +1459,13 @@ int main(int argc, char **argv) failed++; } } - if (test_shutdown_does_not_crash(pid, token) != 0) { + if (test_shutdown_does_not_crash(pid, token, &shutdown_reaped) != 0) { fprintf(stderr, "test_shutdown_does_not_crash failed\n"); failed++; - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); + if (!shutdown_reaped) { + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } } unlink(config_path); unlink(tokens_path); From 564766facc1e4626e1639842dcbeef5708ef63b6 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 18:53:56 -0300 Subject: [PATCH 36/92] docs(gateway): changelog auth_ctx UAF on HTTP teardown Refs: #72 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e42e7b..bd618e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Gateway `/health` `version` matches `SHELLCLAW_RELEASE_VERSION`. ### Security +- Gateway shutdown joins the HTTP thread before `auth_cleanup`, so in-flight `/api/*`, `/pair`, and WebSocket upgrades cannot call `auth_validate_token` / `auth_pair` on a freed `auth_ctx`. - Camera auto-output keeps the exclusive `mkstemp` inode (no unlink + `${tmpl}.jpg` sibling). - Reject I2C `bus` outside 0–255 at the tool JSON boundary. - Document that protocol-public `POST /asap` can invoke local tools; production must set `[asap].trusted_senders` before exposing the gateway. From ceacd7a2611e59c9a7c46864b988b99cf43ac682 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:23:08 +0000 Subject: [PATCH 37/92] test(gateway): assert listen bind honors gateway.host /health can succeed on 127.0.0.1 even when libwebsockets bound INADDR_ANY. Parse /proc/net/tcp LISTEN rows for the configured port. Co-authored-by: Adrianno E. S. --- tests/test_gateway_http.c | 125 +++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index c489386..66542fa 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1,6 +1,6 @@ /** * @file test_gateway_http.c - * @brief Integration tests for gateway HTTP: health, pair, auth, manifest, config, skills, memory, cron. + * @brief Integration tests for gateway HTTP: health, pair, auth, listen bind, manifest, config, skills, memory, cron. * Requires libwebsockets and SHELLCLAW_GATEWAY. Starts server in subprocess. */ #define _POSIX_C_SOURCE 200809L @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +67,124 @@ static int pick_ephemeral_port(void) return (int)ntohs(addr.sin_port); } +enum listen_bind_kind { + LISTEN_BIND_NONE = 0, + LISTEN_BIND_ANY = 1, + LISTEN_BIND_LOOPBACK = 2, + LISTEN_BIND_OTHER = 3 +}; + +static int hex_port_matches(const char *port_hex, int port) +{ + unsigned long parsed; + char *end = NULL; + + if (!port_hex || !port_hex[0]) + return 0; + parsed = strtoul(port_hex, &end, 16); + if (!end || end == port_hex) + return 0; + return parsed == (unsigned long)port; +} + +static int ipv4_hex_kind(const char *hex8) +{ + if (!hex8 || strlen(hex8) < 8) + return LISTEN_BIND_OTHER; + if (strncasecmp(hex8, "00000000", 8) == 0) + return LISTEN_BIND_ANY; + if (strncasecmp(hex8, "0100007F", 8) == 0) + return LISTEN_BIND_LOOPBACK; + return LISTEN_BIND_OTHER; +} + +static int ipv6_hex_kind(const char *hex32) +{ + static const char z32[] = "00000000000000000000000000000000"; + static const char lo[] = "00000000000000000000000001000000"; + static const char v4map[] = "0000000000000000FFFF00000100007F"; + + if (!hex32 || strlen(hex32) < 32) + return LISTEN_BIND_OTHER; + if (strncasecmp(hex32, z32, 32) == 0) + return LISTEN_BIND_ANY; + if (strncasecmp(hex32, lo, 32) == 0) + return LISTEN_BIND_LOOPBACK; + if (strncasecmp(hex32, v4map, 32) == 0) + return LISTEN_BIND_LOOPBACK; + return LISTEN_BIND_OTHER; +} + +static int parse_proc_listen_kind(const char *line, int port, int ipv6) +{ + unsigned int sl; + unsigned int st; + char local[40]; + char rem[40]; + char *colon; + + if (sscanf(line, "%u: %39s %39s %X", &sl, local, rem, &st) != 4) + return LISTEN_BIND_NONE; + (void)sl; + (void)rem; + if (st != 0x0A) + return LISTEN_BIND_NONE; + colon = strrchr(local, ':'); + if (!colon) + return LISTEN_BIND_NONE; + *colon = '\0'; + if (!hex_port_matches(colon + 1, port)) + return LISTEN_BIND_NONE; + return ipv6 ? ipv6_hex_kind(local) : ipv4_hex_kind(local); +} + +static int scan_proc_tcp(const char *path, int port, int ipv6, int *saw_any, + int *saw_loop) +{ + FILE *fp; + char line[512]; + + if (!path || !saw_any || !saw_loop) + return -1; + fp = fopen(path, "r"); + if (!fp) + return 0; + if (!fgets(line, sizeof(line), fp)) { + fclose(fp); + return -1; + } + while (fgets(line, sizeof(line), fp)) { + int kind = parse_proc_listen_kind(line, port, ipv6); + + if (kind == LISTEN_BIND_ANY) + *saw_any = 1; + if (kind == LISTEN_BIND_LOOPBACK) + *saw_loop = 1; + } + fclose(fp); + return 0; +} + +static int test_listen_bound_to_loopback(int port) +{ + int saw_any = 0; + int saw_loop = 0; + + /* /health can succeed on 127.0.0.1 even when LWS bound INADDR_ANY. */ + + ASSERT(scan_proc_tcp("/proc/net/tcp", port, 0, &saw_any, &saw_loop) == 0); + (void)scan_proc_tcp("/proc/net/tcp6", port, 1, &saw_any, &saw_loop); + if (saw_any) { + fprintf(stderr, + "FAIL: gateway LISTEN on 0.0.0.0/:: port %d " + "(config host is 127.0.0.1)\n", + port); + return 1; + } + ASSERT(saw_loop); + return 0; +} + static int http_get(const char *url, long *code_out, char **body_out); static int http_post_raw(const char *url, const void *data, size_t data_len, const char *content_length, long *code_out, char **body_out); @@ -1373,6 +1492,10 @@ int main(int argc, char **argv) char token[128] = {0}; int failed = 0; int shutdown_reaped = 0; + if (test_listen_bound_to_loopback(port) != 0) { + fprintf(stderr, "test_listen_bound_to_loopback failed\n"); + failed++; + } if (test_health() != 0) { fprintf(stderr, "test_health failed\n"); failed++; } if (test_pair(pairing_code, token, sizeof(token)) != 0) { fprintf(stderr, "test_pair failed\n"); From 04638f165231d44276cf66c4153624b1a315a8bf Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:23:08 +0000 Subject: [PATCH 38/92] fix(gateway): bind listen socket to configured host http_start rejected 0.0.0.0 without allow_bind_all but never set lws info.iface, so host 127.0.0.1 still listened on all interfaces. Pass host through unless binding 0.0.0.0 or "*" with allow_bind_all. Co-authored-by: Adrianno E. S. --- src/gateway/http.c | 25 +++++++++++++++++++------ src/gateway/http.h | 3 ++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/gateway/http.c b/src/gateway/http.c index 7bcd4bf..2b3462f 100644 --- a/src/gateway/http.c +++ b/src/gateway/http.c @@ -104,23 +104,36 @@ static void *http_thread_fn(void *arg) return NULL; } +static int gateway_host_is_bind_all(const char *host) +{ + if (!host) + return 0; + return strcmp(host, "0.0.0.0") == 0 || strcmp(host, "*") == 0; +} + int http_start(const config_t *cfg, struct auth_ctx *auth_ctx, const char *config_path) { + const char *host; + int bind_all; + http_server_ctx_t *ctx; + struct lws_context_creation_info info; + if (!cfg || !auth_ctx || g_ctx) return -1; - const char *host = config_gateway_host(cfg); - int port = config_gateway_port(cfg); - if (strcmp(host, "0.0.0.0") == 0 && !config_gateway_allow_bind_all(cfg)) + host = config_gateway_host(cfg); + bind_all = gateway_host_is_bind_all(host); + if (bind_all && !config_gateway_allow_bind_all(cfg)) return -1; - http_server_ctx_t *ctx = calloc(1, sizeof(*ctx)); + ctx = calloc(1, sizeof(*ctx)); if (!ctx) return -1; ctx->cfg = cfg; ctx->auth = auth_ctx; ctx->config_path = config_path ? strdup(config_path) : NULL; ctx->start_time = time(NULL); ctx->running = 1; - struct lws_context_creation_info info; memset(&info, 0, sizeof(info)); - info.port = port; + info.port = config_gateway_port(cfg); + /* LWS iface NULL = INADDR_ANY. Pass host so 127.0.0.1 is not all-NICs. */ + info.iface = bind_all ? NULL : host; info.protocols = protocols; #if defined(LWS_SERVER_OPTION_HTTP_HEADERS_SECURITY_BEST_PRACTICES_ENFORCE) info.options = LWS_SERVER_OPTION_HTTP_HEADERS_SECURITY_BEST_PRACTICES_ENFORCE; diff --git a/src/gateway/http.h b/src/gateway/http.h index 24b043f..60da5af 100644 --- a/src/gateway/http.h +++ b/src/gateway/http.h @@ -16,7 +16,8 @@ struct auth_ctx; /** * Start HTTP+WebSocket server on config host:port. - * Rejects bind to 0.0.0.0 if allow_bind_all is false. + * Binds the listen socket to config_gateway_host (default 127.0.0.1 via + * info.iface). Rejects host 0.0.0.0/"*" unless allow_bind_all is true. * * @param cfg Configuration (host, port, allow_bind_all). * @param auth_ctx Auth context for token validation. From 0231c4089d7055ac7e84c228bcb192b83cf1e6e0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:23:08 +0000 Subject: [PATCH 39/92] docs(gateway): changelog loopback listen bind via gateway.host Refs: #68 Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + docs/SECURITY.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd618e4..09c2389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Security - Gateway shutdown joins the HTTP thread before `auth_cleanup`, so in-flight `/api/*`, `/pair`, and WebSocket upgrades cannot call `auth_validate_token` / `auth_pair` on a freed `auth_ctx`. +- Gateway listen bind now uses `gateway.host` (`lws` `info.iface`). `host = "127.0.0.1"` is loopback-only; `0.0.0.0`/`*` still require `allow_bind_all`. - Camera auto-output keeps the exclusive `mkstemp` inode (no unlink + `${tmpl}.jpg` sibling). - Reject I2C `bus` outside 0–255 at the tool JSON boundary. - Document that protocol-public `POST /asap` can invoke local tools; production must set `[asap].trusted_senders` before exposing the gateway. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f2578fc..c7e4ff5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -181,7 +181,7 @@ After provider/tool wiring in `handle_asap` (#53), a sender that passes that che - `task.request` — `agent_run()` with the same tool table as chat (`shell`, `file`, hardware, `asap_invoke`, …) - `mcp.tool_call` — `execute()` with attacker-chosen name and arguments (no LLM) -Default gateway bind is `127.0.0.1`, which contains this for stock installs. Production MUST set `trusted_senders` before exposing the gateway (`allow_bind_all`, tunnel, marketplace URL). Restricting the inbound MCP tool table (or failing closed when the allowlist is empty and the host is not loopback) is a follow-up. +Default gateway bind is `127.0.0.1` (`http_start` sets libwebsockets `info.iface` from `gateway.host`), which contains this for stock installs. Production MUST set `trusted_senders` before exposing the gateway (`allow_bind_all`, tunnel, marketplace URL). Restricting the inbound MCP tool table (or failing closed when the allowlist is empty and the host is not loopback) is a follow-up. --- From 8e7444cc3740fdb4fec705398564305d62760407 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:39:56 +0000 Subject: [PATCH 40/92] test(gateway): cover IPv6/empty bind-all and v4-mapped ANY Reject ::, [::], and empty host without allow_bind_all. allow_bind_all plus 0.0.0.0 must leave lws iface NULL. Classify ::ffff:0.0.0.0 as ANY. Co-authored-by: Adrianno E. S. --- tests/test_gateway_http.c | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 66542fa..2bb9456 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -6,6 +6,7 @@ #define _POSIX_C_SOURCE 200809L #include "gateway/auth.h" +#include "gateway/http.h" #include "gateway/rate_limit.h" #include "core/config.h" #include "core/version.h" @@ -103,11 +104,14 @@ static int ipv6_hex_kind(const char *hex32) static const char z32[] = "00000000000000000000000000000000"; static const char lo[] = "00000000000000000000000001000000"; static const char v4map[] = "0000000000000000FFFF00000100007F"; + static const char v4any[] = "0000000000000000FFFF000000000000"; if (!hex32 || strlen(hex32) < 32) return LISTEN_BIND_OTHER; if (strncasecmp(hex32, z32, 32) == 0) return LISTEN_BIND_ANY; + if (strncasecmp(hex32, v4any, 32) == 0) + return LISTEN_BIND_ANY; if (strncasecmp(hex32, lo, 32) == 0) return LISTEN_BIND_LOOPBACK; if (strncasecmp(hex32, v4map, 32) == 0) @@ -115,6 +119,41 @@ static int ipv6_hex_kind(const char *hex32) return LISTEN_BIND_OTHER; } +static int test_ipv6_hex_kind_v4mapped_any(void) +{ + /* ::ffff:0.0.0.0 is IPv4-mapped INADDR_ANY in /proc/net/tcp6. */ + ASSERT(ipv6_hex_kind("0000000000000000FFFF000000000000") == LISTEN_BIND_ANY); + ASSERT(ipv6_hex_kind("0000000000000000FFFF00000100007F") == LISTEN_BIND_LOOPBACK); + ASSERT(ipv6_hex_kind("00000000000000000000000000000000") == LISTEN_BIND_ANY); + return 0; +} + +static int test_http_listen_iface(void) +{ + const char *iface; + + ASSERT(http_listen_iface("0.0.0.0", 1, &iface) == 0); + ASSERT(iface == NULL); + ASSERT(http_listen_iface("*", 1, &iface) == 0); + ASSERT(iface == NULL); + ASSERT(http_listen_iface("::", 0, &iface) != 0); + ASSERT(http_listen_iface("[::]", 0, &iface) != 0); + ASSERT(http_listen_iface("", 0, &iface) != 0); + ASSERT(http_listen_iface("::", 1, &iface) == 0); + ASSERT(iface == NULL); + ASSERT(http_listen_iface("[::]", 1, &iface) == 0); + ASSERT(iface == NULL); + ASSERT(http_listen_iface("", 1, &iface) == 0); + ASSERT(iface == NULL); + ASSERT(http_listen_iface("127.0.0.1", 0, &iface) == 0); + ASSERT(iface != NULL && strcmp(iface, "127.0.0.1") == 0); + ASSERT(http_listen_iface("::1", 0, &iface) == 0); + ASSERT(iface != NULL && strcmp(iface, "::1") == 0); + ASSERT(http_listen_iface("0.0.0.0", 0, &iface) != 0); + ASSERT(http_listen_iface("127.0.0.1", 0, NULL) != 0); + return 0; +} + static int parse_proc_listen_kind(const char *line, int port, int ipv6) { unsigned int sl; @@ -171,6 +210,12 @@ static int test_listen_bound_to_loopback(int port) int saw_loop = 0; /* /health can succeed on 127.0.0.1 even when LWS bound INADDR_ANY. */ + if (access("/proc/net/tcp", R_OK) != 0 && + access("/proc/net/tcp6", R_OK) != 0) { + fprintf(stderr, + "test_listen_bound_to_loopback: skip (/proc/net/tcp{,6} missing)\n"); + return 0; + } ASSERT(scan_proc_tcp("/proc/net/tcp", port, 0, &saw_any, &saw_loop) == 0); (void)scan_proc_tcp("/proc/net/tcp6", port, 1, &saw_any, &saw_loop); @@ -1492,6 +1537,14 @@ int main(int argc, char **argv) char token[128] = {0}; int failed = 0; int shutdown_reaped = 0; + if (test_ipv6_hex_kind_v4mapped_any() != 0) { + fprintf(stderr, "test_ipv6_hex_kind_v4mapped_any failed\n"); + failed++; + } + if (test_http_listen_iface() != 0) { + fprintf(stderr, "test_http_listen_iface failed\n"); + failed++; + } if (test_listen_bound_to_loopback(port) != 0) { fprintf(stderr, "test_listen_bound_to_loopback failed\n"); failed++; From e2eeba9751d178d3ca288bd2314b693c84465164 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:39:56 +0000 Subject: [PATCH 41/92] fix(gateway): treat empty and IPv6 any as bind-all gateway_host_is_bind_all only matched 0.0.0.0 and *, so host :: was passed as info.iface without allow_bind_all. Treat "", ::, [::], and INADDR_ANY via inet_pton as bind-all (iface NULL when allowed). Co-authored-by: Adrianno E. S. --- src/gateway/http.c | 14 ++------- src/gateway/http.h | 75 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/gateway/http.c b/src/gateway/http.c index 2b3462f..313a600 100644 --- a/src/gateway/http.c +++ b/src/gateway/http.c @@ -104,24 +104,16 @@ static void *http_thread_fn(void *arg) return NULL; } -static int gateway_host_is_bind_all(const char *host) -{ - if (!host) - return 0; - return strcmp(host, "0.0.0.0") == 0 || strcmp(host, "*") == 0; -} - int http_start(const config_t *cfg, struct auth_ctx *auth_ctx, const char *config_path) { const char *host; - int bind_all; + const char *iface; http_server_ctx_t *ctx; struct lws_context_creation_info info; if (!cfg || !auth_ctx || g_ctx) return -1; host = config_gateway_host(cfg); - bind_all = gateway_host_is_bind_all(host); - if (bind_all && !config_gateway_allow_bind_all(cfg)) + if (http_listen_iface(host, config_gateway_allow_bind_all(cfg), &iface) != 0) return -1; ctx = calloc(1, sizeof(*ctx)); if (!ctx) return -1; @@ -133,7 +125,7 @@ int http_start(const config_t *cfg, struct auth_ctx *auth_ctx, const char *confi memset(&info, 0, sizeof(info)); info.port = config_gateway_port(cfg); /* LWS iface NULL = INADDR_ANY. Pass host so 127.0.0.1 is not all-NICs. */ - info.iface = bind_all ? NULL : host; + info.iface = iface; info.protocols = protocols; #if defined(LWS_SERVER_OPTION_HTTP_HEADERS_SECURITY_BEST_PRACTICES_ENFORCE) info.options = LWS_SERVER_OPTION_HTTP_HEADERS_SECURITY_BEST_PRACTICES_ENFORCE; diff --git a/src/gateway/http.h b/src/gateway/http.h index 60da5af..0753687 100644 --- a/src/gateway/http.h +++ b/src/gateway/http.h @@ -6,6 +6,11 @@ #ifndef SHELLCLAW_GATEWAY_HTTP_H #define SHELLCLAW_GATEWAY_HTTP_H +#include +#include +#include +#include + #ifdef __cplusplus extern "C" { #endif @@ -14,10 +19,78 @@ struct config; typedef struct config config_t; struct auth_ctx; +static inline const char *http_host_without_brackets(const char *host, char *buf, + size_t buf_sz) +{ + size_t n; + + if (!host || host[0] != '[') + return host; + n = strlen(host); + if (n < 2 || host[n - 1] != ']') + return host; + n -= 2; + if (n >= buf_sz) + return host; + memcpy(buf, host + 1, n); + buf[n] = '\0'; + return buf; +} + +/** + * Non-zero if @p host is all-interfaces: 0.0.0.0, *, IPv6 any, or empty. + * Those require allow_bind_all; otherwise http_start must fail closed. + * + * Example: http_host_is_bind_all("::") != 0; http_host_is_bind_all("::1") == 0. + */ +static inline int http_host_is_bind_all(const char *host) +{ + char unbrack[INET6_ADDRSTRLEN]; + const char *p; + struct in_addr a4; + struct in6_addr a6; + + if (!host || host[0] == '\0' || strcmp(host, "*") == 0) + return 1; + p = http_host_without_brackets(host, unbrack, sizeof(unbrack)); + if (p[0] == '\0' || strcmp(p, "*") == 0) + return 1; + if (inet_pton(AF_INET, p, &a4) == 1) + return a4.s_addr == htonl(INADDR_ANY); + if (inet_pton(AF_INET6, p, &a6) == 1) + return IN6_IS_ADDR_UNSPECIFIED(&a6); + return 0; +} + +/** + * LWS iface for @p host. NULL iface is INADDR_ANY (all NICs). + * + * @param host config_gateway_host value (may be empty). + * @param allow_bind_all config_gateway_allow_bind_all. + * @param iface_out Set to NULL (bind all) or @p host. Must be non-NULL. + * @return 0 on success, -1 if bind-all is requested without allow_bind_all. + * + * Example: http_listen_iface("0.0.0.0", 1, &iface) == 0 && iface == NULL. + */ +static inline int http_listen_iface(const char *host, int allow_bind_all, + const char **iface_out) +{ + if (!iface_out) + return -1; + if (http_host_is_bind_all(host)) { + if (!allow_bind_all) + return -1; + *iface_out = NULL; + return 0; + } + *iface_out = host; + return 0; +} + /** * Start HTTP+WebSocket server on config host:port. * Binds the listen socket to config_gateway_host (default 127.0.0.1 via - * info.iface). Rejects host 0.0.0.0/"*" unless allow_bind_all is true. + * info.iface). Rejects bind-all hosts unless allow_bind_all is true. * * @param cfg Configuration (host, port, allow_bind_all). * @param auth_ctx Auth context for token validation. From cbe241629029f04bb4cb0a7237847e3408309e5c Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:39:56 +0000 Subject: [PATCH 42/92] docs(gateway): changelog IPv6 and empty bind-all hosts Refs: #68 Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 2 +- docs/SECURITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09c2389..395d34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Security - Gateway shutdown joins the HTTP thread before `auth_cleanup`, so in-flight `/api/*`, `/pair`, and WebSocket upgrades cannot call `auth_validate_token` / `auth_pair` on a freed `auth_ctx`. -- Gateway listen bind now uses `gateway.host` (`lws` `info.iface`). `host = "127.0.0.1"` is loopback-only; `0.0.0.0`/`*` still require `allow_bind_all`. +- Gateway listen bind now uses `gateway.host` (`lws` `info.iface`). `host = "127.0.0.1"` is loopback-only. Bind-all forms (`0.0.0.0`, `*`, `::`, `[::]`, empty) require `allow_bind_all`. - Camera auto-output keeps the exclusive `mkstemp` inode (no unlink + `${tmpl}.jpg` sibling). - Reject I2C `bus` outside 0–255 at the tool JSON boundary. - Document that protocol-public `POST /asap` can invoke local tools; production must set `[asap].trusted_senders` before exposing the gateway. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c7e4ff5..b93aa9d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -181,7 +181,7 @@ After provider/tool wiring in `handle_asap` (#53), a sender that passes that che - `task.request` — `agent_run()` with the same tool table as chat (`shell`, `file`, hardware, `asap_invoke`, …) - `mcp.tool_call` — `execute()` with attacker-chosen name and arguments (no LLM) -Default gateway bind is `127.0.0.1` (`http_start` sets libwebsockets `info.iface` from `gateway.host`), which contains this for stock installs. Production MUST set `trusted_senders` before exposing the gateway (`allow_bind_all`, tunnel, marketplace URL). Restricting the inbound MCP tool table (or failing closed when the allowlist is empty and the host is not loopback) is a follow-up. +Default gateway bind is `127.0.0.1` (`http_start` sets libwebsockets `info.iface` from `gateway.host`), which contains this for stock installs. Bind-all hosts (`0.0.0.0`, `*`, `::`, `[::]`, empty) require `allow_bind_all`. Production MUST set `trusted_senders` before exposing the gateway (`allow_bind_all`, tunnel, marketplace URL). Restricting the inbound MCP tool table (or failing closed when the allowlist is empty and the host is not loopback) is a follow-up. --- From 35cbc55635a198b2e03ab230996ebae482537055 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:56:19 +0000 Subject: [PATCH 43/92] test(ws): assert send_to accepts dispatch-sized payload Agent replies can fill RESPONSE_BUF_SIZE (32 KiB). A ~10 KiB WebSocket send must enqueue and dequeue intact, not fail at 8 KiB. Co-authored-by: Adrianno E. S. --- tests/test_ws.c | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_ws.c b/tests/test_ws.c index f3f0507..bf28a55 100644 --- a/tests/test_ws.c +++ b/tests/test_ws.c @@ -27,6 +27,33 @@ /** Must match MSG_MAX in src/gateway/ws.c */ #define WS_MSG_MAX 8192 +/** + * Agent replies can be up to RESPONSE_BUF_SIZE (32 KiB) in dispatch.c. + * A ~10 KiB payload is above the historical 8 KiB WS cap and must still + * enqueue and dequeue intact. + */ +static int test_send_to_accepts_dispatch_sized_payload(void) +{ + char *payload; + char buf[10000]; + size_t len_out; + const size_t payload_len = 9999; + + ws_cleanup(); + ASSERT(ws_register_conn(8, (ws_conn_t)(intptr_t)8) == 0); + payload = malloc(payload_len + 1); + ASSERT(payload != NULL); + memset(payload, 'c', payload_len); + payload[payload_len] = '\0'; + ASSERT(ws_send_to("webchat:8", payload) == 0); + ASSERT(ws_dequeue_outgoing(8, buf, sizeof(buf), &len_out) == 1); + ASSERT(len_out == payload_len); + ASSERT(memcmp(buf, payload, payload_len) == 0); + free(payload); + ws_cleanup(); + return 0; +} + static int test_register_conn_full_table(void) { int i; @@ -186,6 +213,7 @@ int main(void) RUN(test_register_conn_full_table()); RUN(test_push_incoming_msg_max()); RUN(test_send_to_rejects_oversized()); + RUN(test_send_to_accepts_dispatch_sized_payload()); RUN(test_next_conn_id_and_unregister()); RUN(test_send_to_rejects_bad_session()); RUN(test_dequeue_outgoing_and_pending()); From 341f5ac8a52a9557d0541ece6dff71b8a96f147c Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:56:55 +0000 Subject: [PATCH 44/92] fix(webchat): align WebSocket payload limit with agent response buffer Raise WS_TEXT_MAX to 32 KiB so replies between 8 KiB and dispatch RESPONSE_BUF_SIZE are queued and written instead of silently dropped. Co-authored-by: Adrianno E. S. --- src/channels/webchat.c | 2 +- src/gateway/http_lws.c | 4 ++-- src/gateway/ws.c | 3 +-- src/gateway/ws.h | 3 +++ tests/test_ws.c | 19 ++++++++----------- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/channels/webchat.c b/src/channels/webchat.c index 830273e..0aa356a 100644 --- a/src/channels/webchat.c +++ b/src/channels/webchat.c @@ -25,7 +25,7 @@ static int webchat_poll(channel_incoming_msg_t *out, int timeout_ms) if (!out) return -1; memset(out, 0, sizeof(*out)); char session_id[128]; - char text[8192]; + char text[WS_TEXT_MAX]; int r = ws_pop_incoming(session_id, sizeof(session_id), text, sizeof(text), timeout_ms); if (r != 1) return r; out->session_id = strdup(session_id); diff --git a/src/gateway/http_lws.c b/src/gateway/http_lws.c index dee91d6..23ab070 100644 --- a/src/gateway/http_lws.c +++ b/src/gateway/http_lws.c @@ -437,10 +437,10 @@ int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, case LWS_CALLBACK_SERVER_WRITEABLE: { int conn_id = (int)(intptr_t)lws_wsi_user(wsi); if (conn_id <= 0) break; - char buf[8192]; + char buf[WS_TEXT_MAX]; size_t len_out = 0; if (ws_dequeue_outgoing(conn_id, buf, sizeof(buf), &len_out)) { - unsigned char frame[LWS_PRE + 8192]; + unsigned char frame[LWS_PRE + WS_TEXT_MAX]; if (len_out < sizeof(frame) - LWS_PRE) { memcpy(frame + LWS_PRE, buf, len_out); if (lws_write(wsi, frame + LWS_PRE, len_out, LWS_WRITE_TEXT) < 0) diff --git a/src/gateway/ws.c b/src/gateway/ws.c index 07b661b..c1848f5 100644 --- a/src/gateway/ws.c +++ b/src/gateway/ws.c @@ -24,14 +24,13 @@ static void lws_callback_on_writable(struct lws *wsi) { (void)wsi; } #define MAX_CONNECTIONS 16 #define MAX_QUEUE 64 -#define MSG_MAX 8192 static size_t ws_text_len_ok(const char *text) { size_t len; if (!text) return 0; len = strlen(text); - return len > 0 && len <= (size_t)MSG_MAX; + return len > 0 && len <= (size_t)WS_TEXT_MAX; } typedef struct ws_msg { diff --git a/src/gateway/ws.h b/src/gateway/ws.h index b8b19e1..d80274a 100644 --- a/src/gateway/ws.h +++ b/src/gateway/ws.h @@ -12,6 +12,9 @@ extern "C" { #endif +/** Max WebSocket text payload (matches RESPONSE_BUF_SIZE in dispatch.c). */ +#define WS_TEXT_MAX (32 * 1024) + /** Opaque WebSocket connection handle (lws wsi cast to void*). */ typedef void *ws_conn_t; diff --git a/tests/test_ws.c b/tests/test_ws.c index bf28a55..9aa28ee 100644 --- a/tests/test_ws.c +++ b/tests/test_ws.c @@ -1,6 +1,6 @@ /** * @file test_ws.c - * @brief WebSocket connection table and MSG_MAX enforcement (no libwebsockets). + * @brief WebSocket connection table and WS_TEXT_MAX enforcement (no libwebsockets). */ #define _POSIX_C_SOURCE 200809L @@ -24,9 +24,6 @@ return _r; \ } while (0) -/** Must match MSG_MAX in src/gateway/ws.c */ -#define WS_MSG_MAX 8192 - /** * Agent replies can be up to RESPONSE_BUF_SIZE (32 KiB) in dispatch.c. * A ~10 KiB payload is above the historical 8 KiB WS cap and must still @@ -35,7 +32,7 @@ static int test_send_to_accepts_dispatch_sized_payload(void) { char *payload; - char buf[10000]; + char buf[WS_TEXT_MAX]; size_t len_out; const size_t payload_len = 9999; @@ -73,10 +70,10 @@ static int test_push_incoming_msg_max(void) int got; ws_cleanup(); ASSERT(ws_register_conn(1, (ws_conn_t)(intptr_t)1) == 0); - big = malloc((size_t)WS_MSG_MAX + 2); + big = malloc((size_t)WS_TEXT_MAX + 2); ASSERT(big != NULL); - memset(big, 'a', (size_t)WS_MSG_MAX + 1); - big[WS_MSG_MAX + 1] = '\0'; + memset(big, 'a', (size_t)WS_TEXT_MAX + 1); + big[WS_TEXT_MAX + 1] = '\0'; ws_push_incoming(1, big); got = ws_pop_incoming(session, sizeof(session), text, sizeof(text), 50); ASSERT(got == 0); @@ -94,10 +91,10 @@ static int test_send_to_rejects_oversized(void) char *big; ws_cleanup(); ASSERT(ws_register_conn(2, (ws_conn_t)(intptr_t)2) == 0); - big = malloc((size_t)WS_MSG_MAX + 2); + big = malloc((size_t)WS_TEXT_MAX + 2); ASSERT(big != NULL); - memset(big, 'b', (size_t)WS_MSG_MAX + 1); - big[WS_MSG_MAX + 1] = '\0'; + memset(big, 'b', (size_t)WS_TEXT_MAX + 1); + big[WS_TEXT_MAX + 1] = '\0'; ASSERT(ws_send_to("webchat:2", big) != 0); ASSERT(ws_send_to("webchat:2", "hi") == 0); free(big); From 97ac5258fb891203f5c0d893bd124fdbcb13e7be Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 22:56:55 +0000 Subject: [PATCH 45/92] docs(webchat): changelog WS payload limit aligned with dispatch Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395d34f..ca7486f 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 +- WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. - Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. - Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. From 3ab8e5ae80518adfd0219c6859479b0aba5e7cfc Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:06:45 +0000 Subject: [PATCH 46/92] test(ws): lock WS_TEXT_MAX minus one and exact payload roundtrip A dest of WS_TEXT_MAX clamps strlen == cap, so an exact 32 KiB payload must stay intact including the trailing NUL. Co-authored-by: Adrianno E. S. --- tests/test_ws.c | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_ws.c b/tests/test_ws.c index 9aa28ee..4552674 100644 --- a/tests/test_ws.c +++ b/tests/test_ws.c @@ -51,6 +51,76 @@ static int test_send_to_accepts_dispatch_sized_payload(void) return 0; } +static int assert_outgoing_roundtrip(int conn_id, size_t payload_len, size_t dest_size) +{ + char *payload; + char *buf; + size_t len_out; + char session[32]; + int n; + + ws_cleanup(); + ASSERT(ws_register_conn(conn_id, (ws_conn_t)(intptr_t)conn_id) == 0); + payload = malloc(payload_len + 1); + buf = malloc(dest_size); + ASSERT(payload != NULL); + ASSERT(buf != NULL); + memset(payload, 'd', payload_len); + payload[payload_len] = '\0'; + n = snprintf(session, sizeof(session), "webchat:%d", conn_id); + ASSERT(n > 0 && (size_t)n < sizeof(session)); + ASSERT(ws_send_to(session, payload) == 0); + ASSERT(ws_dequeue_outgoing(conn_id, buf, dest_size, &len_out) == 1); + ASSERT(len_out == payload_len); + ASSERT(memcmp(buf, payload, payload_len) == 0); + if (dest_size > payload_len) + ASSERT(buf[payload_len] == '\0'); + free(payload); + free(buf); + ws_cleanup(); + return 0; +} + +/** Dispatch copy_response_to_buf leaves one byte for NUL (32767). */ +static int test_send_to_accepts_ws_text_max_minus_one(void) +{ + return assert_outgoing_roundtrip(9, (size_t)WS_TEXT_MAX - 1, (size_t)WS_TEXT_MAX); +} + +/** + * Production dest is currently WS_TEXT_MAX; dequeue then clamps + * strlen == dest_size and the memcpy of len+1 is not a NUL. + */ +static int test_send_to_preserves_exact_ws_text_max(void) +{ + return assert_outgoing_roundtrip(9, (size_t)WS_TEXT_MAX, (size_t)WS_TEXT_MAX); +} + +static int test_pop_incoming_preserves_exact_ws_text_max(void) +{ + char *payload; + char *text; + char session[32]; + const size_t payload_len = (size_t)WS_TEXT_MAX; + + ws_cleanup(); + ASSERT(ws_register_conn(10, (ws_conn_t)(intptr_t)10) == 0); + payload = malloc(payload_len + 1); + text = malloc((size_t)WS_TEXT_MAX); + ASSERT(payload != NULL); + ASSERT(text != NULL); + memset(payload, 'e', payload_len); + payload[payload_len] = '\0'; + ws_push_incoming(10, payload); + ASSERT(ws_pop_incoming(session, sizeof(session), text, (size_t)WS_TEXT_MAX, 500) == 1); + ASSERT(strlen(text) == payload_len); + ASSERT(memcmp(text, payload, payload_len) == 0); + free(payload); + free(text); + ws_cleanup(); + return 0; +} + static int test_register_conn_full_table(void) { int i; @@ -211,6 +281,9 @@ int main(void) RUN(test_push_incoming_msg_max()); RUN(test_send_to_rejects_oversized()); RUN(test_send_to_accepts_dispatch_sized_payload()); + RUN(test_send_to_accepts_ws_text_max_minus_one()); + RUN(test_send_to_preserves_exact_ws_text_max()); + RUN(test_pop_incoming_preserves_exact_ws_text_max()); RUN(test_next_conn_id_and_unregister()); RUN(test_send_to_rejects_bad_session()); RUN(test_dequeue_outgoing_and_pending()); From d69764f13fca668efea8d2ffb596314a6a4f3ce9 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:07:53 +0000 Subject: [PATCH 47/92] fix(webchat): size WS dest for NUL and write full 32 KiB frames Dest buffers are WS_TEXT_BUF_SIZE so strlen == WS_TEXT_MAX keeps its terminator. WRITEABLE uses <= via ws_text_payload_fits and logs skips instead of dropping a max frame after dequeue. Co-authored-by: Adrianno E. S. --- src/channels/webchat.c | 2 +- src/gateway/http_lws.c | 33 +++++++++++++++++++++------------ src/gateway/ws.h | 13 +++++++++++++ tests/test_ws.c | 24 +++++++++++++++--------- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/channels/webchat.c b/src/channels/webchat.c index 0aa356a..2dbc5d6 100644 --- a/src/channels/webchat.c +++ b/src/channels/webchat.c @@ -25,7 +25,7 @@ static int webchat_poll(channel_incoming_msg_t *out, int timeout_ms) if (!out) return -1; memset(out, 0, sizeof(*out)); char session_id[128]; - char text[WS_TEXT_MAX]; + char text[WS_TEXT_BUF_SIZE]; int r = ws_pop_incoming(session_id, sizeof(session_id), text, sizeof(text), timeout_ms); if (r != 1) return r; out->session_id = strdup(session_id); diff --git a/src/gateway/http_lws.c b/src/gateway/http_lws.c index 23ab070..468b180 100644 --- a/src/gateway/http_lws.c +++ b/src/gateway/http_lws.c @@ -410,6 +410,26 @@ int http_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, return 0; } +static void ws_on_writable(struct lws *wsi, int conn_id) +{ + unsigned char frame[LWS_PRE + WS_TEXT_BUF_SIZE]; + size_t dest_size; + size_t len_out = 0; + + dest_size = sizeof(frame) - (size_t)LWS_PRE; + if (!ws_dequeue_outgoing(conn_id, (char *)(frame + LWS_PRE), dest_size, &len_out)) + return; + if (ws_text_payload_fits(len_out)) { + if (lws_write(wsi, frame + LWS_PRE, len_out, LWS_WRITE_TEXT) < 0) + return; + } else { + fprintf(stderr, "shellclaw: ws: drop outgoing payload len=%zu cap=%d\n", + len_out, WS_TEXT_MAX); + } + if (ws_has_pending_outgoing(conn_id)) + lws_callback_on_writable(wsi); +} + int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, void *in, size_t len) { @@ -437,18 +457,7 @@ int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, case LWS_CALLBACK_SERVER_WRITEABLE: { int conn_id = (int)(intptr_t)lws_wsi_user(wsi); if (conn_id <= 0) break; - char buf[WS_TEXT_MAX]; - size_t len_out = 0; - if (ws_dequeue_outgoing(conn_id, buf, sizeof(buf), &len_out)) { - unsigned char frame[LWS_PRE + WS_TEXT_MAX]; - if (len_out < sizeof(frame) - LWS_PRE) { - memcpy(frame + LWS_PRE, buf, len_out); - if (lws_write(wsi, frame + LWS_PRE, len_out, LWS_WRITE_TEXT) < 0) - break; - } - if (ws_has_pending_outgoing(conn_id)) - lws_callback_on_writable(wsi); - } + ws_on_writable(wsi, conn_id); break; } case LWS_CALLBACK_RECEIVE: { diff --git a/src/gateway/ws.h b/src/gateway/ws.h index d80274a..9c781e9 100644 --- a/src/gateway/ws.h +++ b/src/gateway/ws.h @@ -14,6 +14,19 @@ extern "C" { /** Max WebSocket text payload (matches RESPONSE_BUF_SIZE in dispatch.c). */ #define WS_TEXT_MAX (32 * 1024) +/** C-string dest bytes for a WS_TEXT_MAX payload, including the NUL. */ +#define WS_TEXT_BUF_SIZE (WS_TEXT_MAX + 1) + +/** + * True when @p len_out bytes fit in a WS_TEXT_MAX on-wire text frame. + * Inclusive so a full 32 KiB payload is written after dequeue, not dropped. + * + * Example: `if (ws_text_payload_fits(len_out)) lws_write(...)` + */ +static inline int ws_text_payload_fits(size_t len_out) +{ + return len_out <= (size_t)WS_TEXT_MAX; +} /** Opaque WebSocket connection handle (lws wsi cast to void*). */ typedef void *ws_conn_t; diff --git a/tests/test_ws.c b/tests/test_ws.c index 4552674..af53973 100644 --- a/tests/test_ws.c +++ b/tests/test_ws.c @@ -32,7 +32,7 @@ static int test_send_to_accepts_dispatch_sized_payload(void) { char *payload; - char buf[WS_TEXT_MAX]; + char buf[WS_TEXT_BUF_SIZE]; size_t len_out; const size_t payload_len = 9999; @@ -84,16 +84,12 @@ static int assert_outgoing_roundtrip(int conn_id, size_t payload_len, size_t des /** Dispatch copy_response_to_buf leaves one byte for NUL (32767). */ static int test_send_to_accepts_ws_text_max_minus_one(void) { - return assert_outgoing_roundtrip(9, (size_t)WS_TEXT_MAX - 1, (size_t)WS_TEXT_MAX); + return assert_outgoing_roundtrip(9, (size_t)WS_TEXT_MAX - 1, (size_t)WS_TEXT_BUF_SIZE); } -/** - * Production dest is currently WS_TEXT_MAX; dequeue then clamps - * strlen == dest_size and the memcpy of len+1 is not a NUL. - */ static int test_send_to_preserves_exact_ws_text_max(void) { - return assert_outgoing_roundtrip(9, (size_t)WS_TEXT_MAX, (size_t)WS_TEXT_MAX); + return assert_outgoing_roundtrip(9, (size_t)WS_TEXT_MAX, (size_t)WS_TEXT_BUF_SIZE); } static int test_pop_incoming_preserves_exact_ws_text_max(void) @@ -106,21 +102,30 @@ static int test_pop_incoming_preserves_exact_ws_text_max(void) ws_cleanup(); ASSERT(ws_register_conn(10, (ws_conn_t)(intptr_t)10) == 0); payload = malloc(payload_len + 1); - text = malloc((size_t)WS_TEXT_MAX); + text = malloc((size_t)WS_TEXT_BUF_SIZE); ASSERT(payload != NULL); ASSERT(text != NULL); memset(payload, 'e', payload_len); payload[payload_len] = '\0'; ws_push_incoming(10, payload); - ASSERT(ws_pop_incoming(session, sizeof(session), text, (size_t)WS_TEXT_MAX, 500) == 1); + ASSERT(ws_pop_incoming(session, sizeof(session), text, (size_t)WS_TEXT_BUF_SIZE, 500) == 1); ASSERT(strlen(text) == payload_len); ASSERT(memcmp(text, payload, payload_len) == 0); + ASSERT(text[payload_len] == '\0'); free(payload); free(text); ws_cleanup(); return 0; } +static int test_ws_text_payload_fits(void) +{ + ASSERT(ws_text_payload_fits(0) == 1); + ASSERT(ws_text_payload_fits((size_t)WS_TEXT_MAX) == 1); + ASSERT(ws_text_payload_fits((size_t)WS_TEXT_MAX + 1) == 0); + return 0; +} + static int test_register_conn_full_table(void) { int i; @@ -284,6 +289,7 @@ int main(void) RUN(test_send_to_accepts_ws_text_max_minus_one()); RUN(test_send_to_preserves_exact_ws_text_max()); RUN(test_pop_incoming_preserves_exact_ws_text_max()); + RUN(test_ws_text_payload_fits()); RUN(test_next_conn_id_and_unregister()); RUN(test_send_to_rejects_bad_session()); RUN(test_dequeue_outgoing_and_pending()); From ca106b308065d040e7ceb44fbd3e052db1bf3e1e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:07:53 +0000 Subject: [PATCH 48/92] docs(webchat): changelog WS dest NUL and inclusive frame write Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7486f..8a19202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed -- WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. +- WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. - Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. - Multi-round ReAct copies tool results into the in-flight message list so a later round cannot overwrite earlier outputs. From 50b8aec9d4624c9645bb095a1c8505828c854634 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:35:45 +0000 Subject: [PATCH 49/92] test(discord): reject route edge cases in helpers Cover null payload, bot authors, empty author ids, and guild messages without a bot user id so allowlist gating cannot silently widen. Co-authored-by: Adrianno E. S. --- tests/test_discord_helpers.c | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_discord_helpers.c b/tests/test_discord_helpers.c index 954bfdb..05eea0c 100644 --- a/tests/test_discord_helpers.c +++ b/tests/test_discord_helpers.c @@ -99,6 +99,39 @@ static int test_route_message_create_fixture(void) return 0; } +/** + * Negative cases so allowlist/mention gating cannot silently widen: + * null payload is invalid; bot authors, empty author ids, and guild + * traffic without bot identity must be ignored (not accepted). + */ +static int test_route_message_create_rejects_edge_cases(void) +{ + const char *allowed[] = { "user-42" }; + const char *bot_author = "{\"author\":{\"id\":\"user-42\",\"bot\":true},\"channel_id\":\"chan-1\"}"; + const char *empty_author_id = "{\"author\":{\"id\":\"\",\"bot\":false},\"channel_id\":\"chan-1\"}"; + const char *guild_empty_bot = "{\"author\":{\"id\":\"user-42\",\"bot\":false}," + "\"guild_id\":\"g1\",\"channel_id\":\"chan-2\"," + "\"mentions\":[{\"id\":\"bot-9\"}]}"; + cJSON *bot; + cJSON *empty_id; + cJSON *guild; + char sess[128]; + + bot = cJSON_Parse(bot_author); + empty_id = cJSON_Parse(empty_author_id); + guild = cJSON_Parse(guild_empty_bot); + ASSERT(bot && empty_id && guild); + ASSERT(discord_helpers_route_message_create(NULL, allowed, 1, "bot-9", sess, sizeof(sess)) == -1); + ASSERT(discord_helpers_route_message_create(bot, allowed, 1, "bot-9", sess, sizeof(sess)) == 0); + ASSERT(discord_helpers_route_message_create(empty_id, allowed, 1, "bot-9", sess, sizeof(sess)) == 0); + ASSERT(discord_helpers_route_message_create(guild, allowed, 1, "", sess, sizeof(sess)) == 0); + ASSERT(discord_helpers_route_message_create(guild, allowed, 1, NULL, sess, sizeof(sess)) == 0); + cJSON_Delete(bot); + cJSON_Delete(empty_id); + cJSON_Delete(guild); + return 0; +} + int main(void) { RUN(test_allow_entry_equals()); @@ -108,6 +141,7 @@ int main(void) RUN(test_backoff_math()); RUN(test_lifecycle_str()); RUN(test_route_message_create_fixture()); + RUN(test_route_message_create_rejects_edge_cases()); printf("test_discord_helpers: all tests passed\n"); return 0; } From c81a39ce1d750d829f318a76f4054401f47990b1 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:35:45 +0000 Subject: [PATCH 50/92] docs(discord): changelog helper route edge-case tests Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a19202..7943a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - `POST /asap` rejects serialized JSON-RPC larger than the 64 KiB gateway HTTP buffer (HTTP 500 / JSON-RPC `-32603`) instead of truncating the body. ### Added +- Discord helper tests reject null MESSAGE_CREATE payloads, bot authors, empty author ids, and guild messages without bot identity so allowlist/mention gating cannot silently widen. - Phase 5 documentation suite (`docs/SECURITY.md`, `docs/ASAP.md`, and related guides). - `CONTRIBUTING.md` with PR workflow and pre-tag `gpio-mockup` ritual. - Jetson-aware `[hardware]` defaults in `config.example.toml` and `.env.example`. From 09043f0ea344a18843a90871794fd15b113bd5c7 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:44:28 +0000 Subject: [PATCH 51/92] test(discord): lock DM bot-id skip and cleared session on ignore DMs with NULL or empty bot_user_id must still route. Ignore paths must leave session_out as an empty string after the helper clears it. Co-authored-by: Adrianno E. S. --- tests/test_discord_helpers.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_discord_helpers.c b/tests/test_discord_helpers.c index 05eea0c..c226c13 100644 --- a/tests/test_discord_helpers.c +++ b/tests/test_discord_helpers.c @@ -90,6 +90,8 @@ static int test_route_message_create_fixture(void) ASSERT(dm && g0 && g1); ASSERT(discord_helpers_route_message_create(dm, allowed, 1, "bot-9", sess, sizeof(sess)) == 1); ASSERT(strcmp(sess, "discord:c:chan-1") == 0); + ASSERT(discord_helpers_route_message_create(dm, allowed, 1, NULL, sess, sizeof(sess)) == 1); + ASSERT(discord_helpers_route_message_create(dm, allowed, 1, "", sess, sizeof(sess)) == 1); ASSERT(discord_helpers_route_message_create(g0, allowed, 1, "bot-9", sess, sizeof(sess)) == 0); ASSERT(discord_helpers_route_message_create(g1, allowed, 1, "bot-9", sess, sizeof(sess)) == 1); ASSERT(strcmp(sess, "discord:c:chan-2") == 0); @@ -121,11 +123,17 @@ static int test_route_message_create_rejects_edge_cases(void) empty_id = cJSON_Parse(empty_author_id); guild = cJSON_Parse(guild_empty_bot); ASSERT(bot && empty_id && guild); + memset(sess, 'x', sizeof(sess)); + sess[sizeof(sess) - 1] = '\0'; ASSERT(discord_helpers_route_message_create(NULL, allowed, 1, "bot-9", sess, sizeof(sess)) == -1); ASSERT(discord_helpers_route_message_create(bot, allowed, 1, "bot-9", sess, sizeof(sess)) == 0); + ASSERT(sess[0] == '\0'); ASSERT(discord_helpers_route_message_create(empty_id, allowed, 1, "bot-9", sess, sizeof(sess)) == 0); + ASSERT(sess[0] == '\0'); ASSERT(discord_helpers_route_message_create(guild, allowed, 1, "", sess, sizeof(sess)) == 0); + ASSERT(sess[0] == '\0'); ASSERT(discord_helpers_route_message_create(guild, allowed, 1, NULL, sess, sizeof(sess)) == 0); + ASSERT(sess[0] == '\0'); cJSON_Delete(bot); cJSON_Delete(empty_id); cJSON_Delete(guild); From b1f1288006ed9fbd9d0d130ff7bebee612ecca52 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:45:23 +0000 Subject: [PATCH 52/92] fix(discord): route MESSAGE_CREATE through helper gating Call discord_helpers_route_message_create from the channel so helper tests cover live bot/allowlist/mention policy. Empty content, strdup, and queue stay in discord.c. Co-authored-by: Adrianno E. S. --- src/channels/discord.c | 88 ++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 55 deletions(-) diff --git a/src/channels/discord.c b/src/channels/discord.c index f29bbe1..2f36530 100644 --- a/src/channels/discord.c +++ b/src/channels/discord.c @@ -294,16 +294,32 @@ static char *discord_build_heartbeat_seq(uint64_t seq) return out; } -static int discord_is_user_allowed(const config_t *cfg, const char *author_id) +static int discord_try_route_message(struct discord_ctx *dc, cJSON *d, + char *sess_buf, size_t sess_sz) { - int n = config_discord_allowed_user_ids_count(cfg); + int n; int i; - for (i = 0; i < n; i++) { - const char *allow = config_discord_allowed_user_id(cfg, i); - if (discord_helpers_allow_entry_equals(allow, author_id)) - return 1; + int route; + const char **allowed; + char *bid; + + n = config_discord_allowed_user_ids_count(dc->cfg); + allowed = NULL; + if (n > 0) { + allowed = malloc(sizeof(*allowed) * (size_t)n); + if (!allowed) + return 0; + for (i = 0; i < n; i++) + allowed[i] = config_discord_allowed_user_id(dc->cfg, i); } - return 0; + pthread_mutex_lock(&dc->lock); + bid = dc->bot_user_id ? strdup(dc->bot_user_id) : NULL; + pthread_mutex_unlock(&dc->lock); + route = discord_helpers_route_message_create(d, (const char *const *)allowed, n, bid, + sess_buf, sess_sz); + free(allowed); + free(bid); + return route == 1; } static void discord_queue_enqueue(struct discord_ctx *dc, channel_incoming_msg_t *msg) @@ -340,68 +356,30 @@ static void discord_abs_timeout_ms(int timeout_ms, struct timespec *out) static void discord_on_message_create(struct discord_ctx *dc, cJSON *d) { cJSON *author; - cJSON *bot_flag; cJSON *aid_item; - char *author_id = NULL; - cJSON *guild_id; - int is_guild; - char *bid = NULL; - cJSON *ch; + char *author_id; cJSON *content; const char *txt; char sess_buf[128]; channel_incoming_msg_t m = { 0 }; + if (!dc || !dc->cfg || !d || !cJSON_IsObject(d)) return; - author = cJSON_GetObjectItem(d, "author"); - if (!cJSON_IsObject(author)) + if (!discord_try_route_message(dc, d, sess_buf, sizeof(sess_buf))) return; - bot_flag = cJSON_GetObjectItem(author, "bot"); - if (cJSON_IsTrue(bot_flag)) - return; - aid_item = cJSON_GetObjectItem(author, "id"); - if (cJSON_IsString(aid_item) && aid_item->valuestring) - author_id = strdup(aid_item->valuestring); - if (!author_id) - return; - if (!discord_is_user_allowed(dc->cfg, author_id)) { - free(author_id); - return; - } - guild_id = cJSON_GetObjectItem(d, "guild_id"); - is_guild = cJSON_IsString(guild_id) && guild_id->valuestring && guild_id->valuestring[0] != '\0'; - if (is_guild) { - cJSON *mentions; - int mention_ok; - pthread_mutex_lock(&dc->lock); - bid = dc->bot_user_id ? strdup(dc->bot_user_id) : NULL; - pthread_mutex_unlock(&dc->lock); - mentions = cJSON_GetObjectItem(d, "mentions"); - mention_ok = bid != NULL && - discord_helpers_mentions_include_bot(mentions, bid); - free(bid); - if (!mention_ok) { - free(author_id); - return; - } - } - ch = cJSON_GetObjectItem(d, "channel_id"); - if (!cJSON_IsString(ch) || !ch->valuestring || ch->valuestring[0] == '\0') { - free(author_id); - return; - } content = cJSON_GetObjectItem(d, "content"); txt = ""; if (cJSON_IsString(content) && content->valuestring) txt = content->valuestring; - if (!txt[0]) { - free(author_id); + if (!txt[0]) return; - } - if (discord_helpers_session_id_from_channel(ch->valuestring, sess_buf, sizeof(sess_buf)) != 0) { - free(author_id); + author = cJSON_GetObjectItem(d, "author"); + aid_item = cJSON_IsObject(author) ? cJSON_GetObjectItem(author, "id") : NULL; + if (!cJSON_IsString(aid_item) || !aid_item->valuestring) + return; + author_id = strdup(aid_item->valuestring); + if (!author_id) return; - } m.session_id = strdup(sess_buf); if (!m.session_id) { free(author_id); From 6ea298f2bf5a54d655a6fb00f90bb4ca025701a6 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:45:23 +0000 Subject: [PATCH 53/92] docs(discord): changelog MESSAGE_CREATE helper routing Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7943a76..77a4fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Jetson-aware `[hardware]` defaults in `config.example.toml` and `.env.example`. ### Changed +- Discord `MESSAGE_CREATE` routing calls `discord_helpers_route_message_create` so helper allowlist/mention tests cover live gating; empty content, strdup, and queue stay in `discord.c`. - `main` is the active line. On-device Jetson sign-off is a known pending item, not a merge gate ([`docs/JETSON_SIGNOFF.md`](docs/JETSON_SIGNOFF.md)). - Gateway `/health` `version` matches `SHELLCLAW_RELEASE_VERSION`. From c8e4f3299dbc55491ef29650ea95dcdffe596bb1 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sun, 13 Sep 2026 23:47:24 +0000 Subject: [PATCH 54/92] fix(discord): narrow allowlist loop index for cppcheck Co-authored-by: Adrianno E. S. --- src/channels/discord.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/channels/discord.c b/src/channels/discord.c index 2f36530..3dabbd4 100644 --- a/src/channels/discord.c +++ b/src/channels/discord.c @@ -298,7 +298,6 @@ static int discord_try_route_message(struct discord_ctx *dc, cJSON *d, char *sess_buf, size_t sess_sz) { int n; - int i; int route; const char **allowed; char *bid; @@ -306,6 +305,8 @@ static int discord_try_route_message(struct discord_ctx *dc, cJSON *d, n = config_discord_allowed_user_ids_count(dc->cfg); allowed = NULL; if (n > 0) { + int i; + allowed = malloc(sizeof(*allowed) * (size_t)n); if (!allowed) return 0; From 8905d1ca30f515ae2dadd76b1b76f5abb0e7105f Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 14 Sep 2026 00:35:30 +0000 Subject: [PATCH 55/92] fix(discord): grow gateway RX for trailing NUL to avoid heap OOB discord_rx_append grew only when cap < rx_len+len, then wrote a NUL at rx_len+len. Two 64KiB LWS fragments filled a doubled buffer exactly, so the terminator was one byte past the heap block (typical READY payloads). Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + src/channels/discord.c | 29 +++++--------- src/channels/discord_helpers.c | 36 ++++++++++++++++++ src/channels/discord_helpers.h | 12 +++++- tests/test_discord_helpers.c | 69 ++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a4fb8..55b58f1 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 +- Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. - Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. diff --git a/src/channels/discord.c b/src/channels/discord.c index 3dabbd4..0b52b97 100644 --- a/src/channels/discord.c +++ b/src/channels/discord.c @@ -143,6 +143,7 @@ struct discord_ctx { char *rx_buf; size_t rx_len; size_t rx_cap; + int rx_skip; int heartbeat_interval_ms; uint64_t last_seq; char *session_id; @@ -176,30 +177,14 @@ static uint64_t discord_now_ms(void) static void discord_rx_reset(struct discord_ctx *dc) { dc->rx_len = 0; + dc->rx_skip = 0; } /** Appends one RX fragment; returns 0 ok, -1 overflow. */ static int discord_rx_append(struct discord_ctx *dc, const void *in, size_t len) { - if (len > RX_MAX || dc->rx_len > RX_MAX - len) - return -1; - if (dc->rx_cap < dc->rx_len + len) { - size_t need = dc->rx_len + len + 1; - size_t ncap = dc->rx_cap ? dc->rx_cap * 2 : 4096; - while (ncap < need && ncap < RX_MAX) - ncap *= 2; - if (need > RX_MAX) - return -1; - char *p = realloc(dc->rx_buf, ncap); - if (!p) - return -1; - dc->rx_buf = p; - dc->rx_cap = ncap; - } - memcpy(dc->rx_buf + dc->rx_len, in, len); - dc->rx_len += len; - dc->rx_buf[dc->rx_len] = '\0'; - return 0; + return discord_helpers_rx_append(&dc->rx_buf, &dc->rx_len, &dc->rx_cap, in, len, + RX_MAX); } static int discord_send_json(struct lws *wsi, const char *json) @@ -538,9 +523,15 @@ static int callback_discord(struct lws *wsi, enum lws_callback_reasons reason, } break; case LWS_CALLBACK_CLIENT_RECEIVE: + if (dc->rx_skip) { + if (lws_is_final_fragment(wsi)) + dc->rx_skip = 0; + break; + } if (discord_rx_append(dc, in, len) != 0) { fprintf(stderr, "shellclaw: discord: gateway payload too large\n"); discord_rx_reset(dc); + dc->rx_skip = !lws_is_final_fragment(wsi); break; } if (lws_is_final_fragment(wsi)) { diff --git a/src/channels/discord_helpers.c b/src/channels/discord_helpers.c index 1e6e00b..9ee1389 100644 --- a/src/channels/discord_helpers.c +++ b/src/channels/discord_helpers.c @@ -6,6 +6,7 @@ #include "channels/discord_helpers.h" #include "channels/channel.h" #include +#include #include const char *discord_lifecycle_str(discord_lifecycle_t lc) @@ -139,3 +140,38 @@ int discord_helpers_send_backoff_ms(int attempt, double retry_after_sec, int jit sleep_ms = cap_ms; return sleep_ms; } + +int discord_helpers_rx_append(char **buf, size_t *len, size_t *cap, const void *in, + size_t chunk_len, size_t max_cap) +{ + size_t need; + + if (!buf || !len || !cap || (!in && chunk_len != 0) || max_cap < 1) + return -1; + if (chunk_len > max_cap || *len > max_cap - chunk_len) + return -1; + need = *len + chunk_len + 1; + if (need > max_cap) + return -1; + if (*cap < need) { + size_t ncap = *cap ? *cap * 2 : 4096; + char *p; + + while (ncap < need) + ncap *= 2; + if (ncap > max_cap) + ncap = max_cap; + if (ncap < need) + return -1; + p = realloc(*buf, ncap); + if (!p) + return -1; + *buf = p; + *cap = ncap; + } + if (chunk_len > 0) + memcpy(*buf + *len, in, chunk_len); + *len += chunk_len; + (*buf)[*len] = '\0'; + return 0; +} diff --git a/src/channels/discord_helpers.h b/src/channels/discord_helpers.h index 7632741..d89a58a 100644 --- a/src/channels/discord_helpers.h +++ b/src/channels/discord_helpers.h @@ -1,7 +1,7 @@ /** * @file discord_helpers.h * @brief Pure helpers shared by Discord channel and unit tests (allowlist, session id, mentions, - * REST 429 backoff calculation). + * REST 429 backoff calculation, Gateway RX append). */ #ifndef SHELLCLAW_DISCORD_HELPERS_H @@ -53,6 +53,16 @@ int discord_helpers_route_message_create(const cJSON *payload, const char *const int allowed_count, const char *bot_user_id, char *session_out, size_t session_outsz); +/** + * Append one Gateway RX fragment, growing so payload plus a trailing NUL always fit. + * + * @return 0 on success, -1 if the total would exceed @p max_cap or allocation failed. + * + * Example: two 64 KiB LWS fragments must grow past 131072 so the NUL fits. + */ +int discord_helpers_rx_append(char **buf, size_t *len, size_t *cap, const void *in, + size_t chunk_len, size_t max_cap); + #ifdef __cplusplus } #endif diff --git a/tests/test_discord_helpers.c b/tests/test_discord_helpers.c index c226c13..658bd73 100644 --- a/tests/test_discord_helpers.c +++ b/tests/test_discord_helpers.c @@ -7,6 +7,7 @@ #include "channels/channel.h" #include "cJSON.h" #include +#include #include #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) @@ -140,6 +141,72 @@ static int test_route_message_create_rejects_edge_cases(void) return 0; } +/** Exact payload fill of a power-of-two cap must still reserve one byte for NUL. */ +static int test_rx_append_grows_for_nul(void) +{ + char *buf; + size_t len; + size_t cap; + char a[4095]; + char one; + char *big; + char *big2; + + buf = NULL; + len = 0; + cap = 0; + one = 'Z'; + memset(a, 'A', sizeof(a)); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, a, sizeof(a), 512 * 1024) == 0); + ASSERT(len == sizeof(a)); + ASSERT(cap >= len + 1); + ASSERT(buf[len] == '\0'); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, &one, 1, 512 * 1024) == 0); + ASSERT(len == 4096); + ASSERT(cap >= 4097); + ASSERT(buf[4096] == '\0'); + ASSERT(buf[4095] == 'Z'); + + free(buf); + buf = NULL; + len = 0; + cap = 0; + big = malloc(65536); + big2 = malloc(65536); + ASSERT(big && big2); + memset(big, 'B', 65536); + memset(big2, 'C', 65536); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, big, 65536, 512 * 1024) == 0); + ASSERT(len == 65536); + ASSERT(cap == 131072); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, big2, 65536, 512 * 1024) == 0); + ASSERT(len == 131072); + ASSERT(cap >= 131073); + ASSERT(buf[131072] == '\0'); + ASSERT(buf[0] == 'B' && buf[65535] == 'B'); + ASSERT(buf[65536] == 'C' && buf[131071] == 'C'); + free(big); + free(big2); + free(buf); + return 0; +} + +static int test_rx_append_rejects_over_max(void) +{ + char *buf; + size_t len; + size_t cap; + char chunk[16]; + + buf = NULL; + len = 0; + cap = 0; + memset(chunk, 'x', sizeof(chunk)); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, chunk, sizeof(chunk), 8) != 0); + ASSERT(buf == NULL); + return 0; +} + int main(void) { RUN(test_allow_entry_equals()); @@ -150,6 +217,8 @@ int main(void) RUN(test_lifecycle_str()); RUN(test_route_message_create_fixture()); RUN(test_route_message_create_rejects_edge_cases()); + RUN(test_rx_append_grows_for_nul()); + RUN(test_rx_append_rejects_over_max()); printf("test_discord_helpers: all tests passed\n"); return 0; } From d97452a7497398774179285b0b61941c26beb0e6 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 14 Sep 2026 00:36:06 +0000 Subject: [PATCH 56/92] fix(gateway): raise WebChat WS rx_buffer_size so inbound JSON is not split LWS delivers at most rx_buffer_size bytes per RECEIVE. The ws protocol used 256 with no fragment reassembly, so dashboard messages over ~220 characters were parsed as incomplete JSON and dropped. Size the buffer to fit a full WebChat frame (WS_TEXT_MAX plus JSON envelope). Distinct from outbound #62. Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + src/gateway/http.c | 5 ++++- src/gateway/http_lws.h | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b58f1..57ce925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). +- WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. - Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. diff --git a/src/gateway/http.c b/src/gateway/http.c index 313a600..44f3333 100644 --- a/src/gateway/http.c +++ b/src/gateway/http.c @@ -37,11 +37,14 @@ static const struct lws_protocols protocols[] = { { .name = "ws", .callback = ws_callback, - .rx_buffer_size = 256, + .rx_buffer_size = WS_RX_BUFFER_SIZE, }, { .name = NULL }, }; +_Static_assert(WS_RX_BUFFER_SIZE >= (size_t)WS_TEXT_MAX + 32, + "WebChat rx_buffer_size must fit JSON envelope around WS_TEXT_MAX"); + static const struct lws_http_mount mount_http = { .mountpoint = "/", .origin = "http", diff --git a/src/gateway/http_lws.h b/src/gateway/http_lws.h index f14556e..45954b3 100644 --- a/src/gateway/http_lws.h +++ b/src/gateway/http_lws.h @@ -10,6 +10,7 @@ #include "core/version.h" #include "gateway/auth.h" #include "gateway/lws_compat.h" +#include "gateway/ws.h" #include #include #include @@ -24,6 +25,8 @@ extern "C" { #define CONFIG_BODY_MAX 65536 #define BODY_BUF_SIZE CONFIG_BODY_MAX #define ASAP_BODY_MAX (1024 * 1024) +/** Per-callback WebChat RX. Must fit `{"type":"message","text":...}` at WS_TEXT_MAX. */ +#define WS_RX_BUFFER_SIZE (WS_TEXT_MAX + 64) enum http_method { HTTP_GET = 1, From 080c174f7d791adef7d1e334d438c5fd12ef06d0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 01:27:48 -0300 Subject: [PATCH 57/92] fix(file): write to the intended path and reject dangling symlinks Ancestor lookup is membership only: do not fopen the resolved parent, which truncated a workspace file treated as a directory. Dangling symlinks fail closed via lstat and O_NOFOLLOW instead of creating host files outside the workspace. Refs: #67, #90 --- CHANGELOG.md | 1 + src/tools/file.c | 158 ++++++++++++++++++++++++++++++++++------------ tests/test_file.c | 134 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 254 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ce925..6c61e4a 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 +- `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Dangling workspace symlinks are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/tools/file.c b/src/tools/file.c index e8170b1..4219fed 100644 --- a/src/tools/file.c +++ b/src/tools/file.c @@ -2,6 +2,9 @@ * @file file.c * @brief File tool: read_file, write_file, list_dir with workspace boundary check. */ +#if defined(__APPLE__) +#define _DARWIN_C_SOURCE +#endif #define _POSIX_C_SOURCE 200809L #define _GNU_SOURCE @@ -10,6 +13,8 @@ #include "core/config.h" #include "cJSON.h" #include +#include +#include #include #include #include @@ -31,49 +36,134 @@ void tool_file_set_config(const config_t *cfg) g_file_cfg = cfg; } +static int resolved_is_under_workspace(const char *resolved) +{ + char ws_resolved[PATH_MAX]; + const char *workspace; + size_t ws_len; + + if (!resolved || !g_file_cfg) return 0; + workspace = config_workspace_path(g_file_cfg); + if (!workspace || workspace[0] == '\0') return 0; + if (realpath(workspace, ws_resolved) == NULL) return 0; + ws_len = strlen(ws_resolved); + if (strncmp(resolved, ws_resolved, ws_len) != 0) return 0; + if (resolved[ws_len] != '\0' && resolved[ws_len] != '/') return 0; + return 1; +} + +static int path_is_dangling_symlink(const char *path) +{ + struct stat lst; + + if (!path) return 0; + if (lstat(path, &lst) != 0) return 0; + return S_ISLNK(lst.st_mode) ? 1 : 0; +} + static int path_within_workspace(const char *path, char *resolved, size_t resolved_size) { + const char *workspace; + char path_copy[PATH_MAX]; + if (!path || path[0] == '\0') return 0; if (!g_file_cfg || !config_workspace_only(g_file_cfg)) { snprintf(resolved, resolved_size, "%s", path); return 1; } - const char *workspace = config_workspace_path(g_file_cfg); - if (!workspace || workspace[0] == '\0') { - return 0; /* Deny: cannot validate without workspace path */ - } - char ws_resolved[PATH_MAX]; - if (realpath(workspace, ws_resolved) == NULL) return 0; - if (realpath(path, resolved) != NULL) { - size_t ws_len = strlen(ws_resolved); - if (strncmp(resolved, ws_resolved, ws_len) != 0) return 0; - if (resolved[ws_len] != '\0' && resolved[ws_len] != '/') return 0; - return 1; - } - char path_copy[PATH_MAX]; + workspace = config_workspace_path(g_file_cfg); + if (!workspace || workspace[0] == '\0') + return 0; + if (realpath(path, resolved) != NULL) + return resolved_is_under_workspace(resolved); + /* Dangling symlink: ancestor prefix is not enough — open() would follow it. */ + if (path_is_dangling_symlink(path)) + return 0; snprintf(path_copy, sizeof(path_copy), "%s", path); for (;;) { char *dir = dirname(path_copy); if (!dir || dir[0] == '\0') break; - if (realpath(dir, resolved) != NULL) { - size_t ws_len = strlen(ws_resolved); - if (strncmp(resolved, ws_resolved, ws_len) != 0) return 0; - if (resolved[ws_len] != '\0' && resolved[ws_len] != '/') return 0; - return 1; - } + if (realpath(dir, resolved) != NULL) + return resolved_is_under_workspace(resolved); if (strcmp(dir, ".") == 0 || strcmp(dir, "/") == 0) break; snprintf(path_copy, sizeof(path_copy), "%s", dir); } return 0; } +/* + * Membership via ancestor is not the write target. Using that ancestor as + * fopen() would truncate a file treated as a directory (#67). + */ +static int resolve_workspace_write_path(const char *path, char *safe_path, size_t cap) +{ + char parent[PATH_MAX]; + char path_copy[PATH_MAX]; + struct stat st; + const char *base; + char *dir; + int n; + + if (path_is_dangling_symlink(path)) + return 0; + if (realpath(path, safe_path) != NULL) { + if (stat(safe_path, &st) != 0 || !S_ISREG(st.st_mode)) return 0; + return resolved_is_under_workspace(safe_path); + } + if (snprintf(path_copy, sizeof(path_copy), "%s", path) >= (int)sizeof(path_copy)) + return 0; + dir = dirname(path_copy); + if (!dir || realpath(dir, parent) == NULL) return 0; + if (stat(parent, &st) != 0 || !S_ISDIR(st.st_mode)) return 0; + if (!resolved_is_under_workspace(parent)) return 0; + base = strrchr(path, '/'); + base = base ? base + 1 : path; + if (base[0] == '\0' || strcmp(base, ".") == 0 || strcmp(base, "..") == 0) + return 0; + n = snprintf(safe_path, cap, "%s/%s", parent, base); + return n > 0 && (size_t)n < cap; +} + +static int open_write_nofollow(const char *safe_path, int ws_only, + char *result_buf, size_t max_len, FILE **out) +{ + int flags = O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC; + int fd; + FILE *f; + + if (ws_only) + flags |= O_NOFOLLOW; + fd = open(safe_path, flags, 0644); + if (fd < 0) { + if (ws_only && errno == ELOOP) + snprintf(result_buf, max_len, "{\"error\":\"path outside workspace\"}"); + else + snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); + return -1; + } + f = fdopen(fd, "w"); + if (!f) { + close(fd); + snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); + return -1; + } + *out = f; + return 0; +} + static int file_read(const char *path, char *result_buf, size_t max_len) { char resolved[PATH_MAX]; + int ws_only; if (!path_within_workspace(path, resolved, sizeof(resolved))) { snprintf(result_buf, max_len, "{\"error\":\"path outside workspace\"}"); return -1; } + ws_only = g_file_cfg ? config_workspace_only(g_file_cfg) : 0; + if (ws_only && realpath(path, resolved) == NULL) { + snprintf(result_buf, max_len, "{\"error\":\"cannot open file\"}"); + return -1; + } FILE *f = fopen(resolved, "rb"); if (!f) { snprintf(result_buf, max_len, "{\"error\":\"cannot open file\"}"); @@ -104,31 +194,15 @@ static int file_write(const char *path, const char *content, char *result_buf, s } int ws_only = g_file_cfg ? config_workspace_only(g_file_cfg) : 0; char safe_path[PATH_MAX]; + FILE *f; if (!ws_only) { snprintf(safe_path, sizeof(safe_path), "%s", path); - } else { - struct stat st; - if (stat(resolved, &st) == 0 && S_ISREG(st.st_mode)) { - snprintf(safe_path, sizeof(safe_path), "%s", resolved); - } else { - const char *base = strrchr(path, '/'); - base = base ? base + 1 : path; - size_t res_len = strlen(resolved); - size_t base_len = strlen(base); - if (res_len + 1 + base_len >= sizeof(safe_path)) { - snprintf(result_buf, max_len, "{\"error\":\"path too long\"}"); - return -1; - } - memcpy(safe_path, resolved, res_len); - safe_path[res_len] = '/'; - memcpy(safe_path + res_len + 1, base, base_len + 1); - } - } - FILE *f = fopen(safe_path, "w"); - if (!f) { + } else if (!resolve_workspace_write_path(path, safe_path, sizeof(safe_path))) { snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); return -1; } + if (open_write_nofollow(safe_path, ws_only, result_buf, max_len, &f) != 0) + return -1; if (content) { size_t len = strlen(content); if (fwrite(content, 1, len, f) != len) { @@ -145,10 +219,16 @@ static int file_write(const char *path, const char *content, char *result_buf, s static int file_list(const char *path, char *result_buf, size_t max_len) { char resolved[PATH_MAX]; + int ws_only; if (!path_within_workspace(path, resolved, sizeof(resolved))) { snprintf(result_buf, max_len, "{\"error\":\"path outside workspace\"}"); return -1; } + ws_only = g_file_cfg ? config_workspace_only(g_file_cfg) : 0; + if (ws_only && realpath(path, resolved) == NULL) { + snprintf(result_buf, max_len, "{\"error\":\"cannot list directory\"}"); + return -1; + } DIR *d = opendir(resolved); if (!d) { snprintf(result_buf, max_len, "{\"error\":\"cannot list directory\"}"); diff --git a/tests/test_file.c b/tests/test_file.c index b4087ac..dd4f173 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -196,6 +196,137 @@ static void test_symlink_escape_rejected(void) rmdir(tmpdir); } +static int slurp_file(const char *path, char *buf, size_t cap) +{ + FILE *f = fopen(path, "rb"); + size_t n; + if (!f) return -1; + n = fread(buf, 1, cap - 1, f); + fclose(f); + buf[n] = '\0'; + return 0; +} + +static void test_write_does_not_truncate_file_used_as_directory(void) +{ + char tmpdir[PATH_MAX]; + char victim[PATH_MAX]; + char nested[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 128]; + char buf[256]; + char kept[64]; + config_t *cfg; + const tool_t *t; + FILE *f; + int r; + + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_filedir_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(victim, sizeof(victim), "%s/important.md", tmpdir); + f = fopen(victim, "w"); + MU_ASSERT(f != NULL, "create victim file"); + fputs("KEEP", f); + fclose(f); + cfg = make_ws_config(tmpdir, config_path, sizeof(config_path)); + MU_ASSERT(cfg != NULL, "file-as-dir: load config"); + tool_file_set_config(cfg); + t = tool_file_get(); + snprintf(nested, sizeof(nested), "%s/important.md/nested.txt", tmpdir); + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"PWNED\"}", nested); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "write through file-as-directory is rejected"); + MU_ASSERT(slurp_file(victim, kept, sizeof(kept)) == 0, "victim still readable"); + MU_ASSERT(strcmp(kept, "KEEP") == 0, "victim content preserved"); + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", nested); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "read through file-as-directory is rejected"); + MU_ASSERT(strstr(buf, "KEEP") == NULL, "read does not leak victim contents"); + config_free(cfg); + unlink(config_path); + unlink(victim); + rmdir(tmpdir); +} + +static void test_write_does_not_collapse_missing_parent_onto_basename(void) +{ + char tmpdir[PATH_MAX]; + char victim[PATH_MAX]; + char nested[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 128]; + char buf[256]; + char kept[64]; + config_t *cfg; + const tool_t *t; + FILE *f; + int r; + + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_missdir_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(victim, sizeof(victim), "%s/notes.md", tmpdir); + f = fopen(victim, "w"); + MU_ASSERT(f != NULL, "create same-basename victim"); + fputs("KEEP", f); + fclose(f); + cfg = make_ws_config(tmpdir, config_path, sizeof(config_path)); + MU_ASSERT(cfg != NULL, "missing-parent: load config"); + tool_file_set_config(cfg); + t = tool_file_get(); + snprintf(nested, sizeof(nested), "%s/missing_dir/notes.md", tmpdir); + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"PWNED\"}", nested); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "write with missing parent is rejected"); + MU_ASSERT(slurp_file(victim, kept, sizeof(kept)) == 0, "workspace notes.md still readable"); + MU_ASSERT(strcmp(kept, "KEEP") == 0, "missing parent does not overwrite same basename"); + config_free(cfg); + unlink(config_path); + unlink(victim); + rmdir(tmpdir); +} + +static void test_file_write_dangling_symlink_rejected(void) +{ + char tmpdir[PATH_MAX]; + char outside[PATH_MAX]; + char link_path[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 128]; + char buf[256]; + config_t *cfg; + const tool_t *t; + struct stat st; + int r; + + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_dangle_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(outside, sizeof(outside), "/tmp/sc_file_pwned_%d", (int)getpid()); + unlink(outside); + snprintf(link_path, sizeof(link_path), "%s/leak", tmpdir); + unlink(link_path); + if (symlink(outside, link_path) != 0) { + rmdir(tmpdir); + return; + } + cfg = make_ws_config(tmpdir, config_path, sizeof(config_path)); + MU_ASSERT(cfg != NULL, "dangling symlink: load config"); + tool_file_set_config(cfg); + t = tool_file_get(); + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"pwned\"}", + link_path); + r = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(r == -1, "write through dangling symlink rejected"); + MU_ASSERT(stat(outside, &st) != 0, "host path outside workspace not created"); + config_free(cfg); + unlink(config_path); + unlink(link_path); + unlink(outside); + rmdir(tmpdir); +} + int main(void) { MU_RUN(test_file_read_write_list); @@ -203,6 +334,9 @@ int main(void) MU_RUN(test_file_outside_workspace_rejected); MU_RUN(test_path_traversal_rejected); MU_RUN(test_symlink_escape_rejected); + MU_RUN(test_write_does_not_truncate_file_used_as_directory); + MU_RUN(test_write_does_not_collapse_missing_parent_onto_basename); + MU_RUN(test_file_write_dangling_symlink_rejected); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } From 723cd548aa3f555132e3f9abcb9749807fa05903 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 01:29:42 -0300 Subject: [PATCH 58/92] fix(file): persist workspace writes with atomic replace Write to path.tmp, fsync, then rename over the live file so a failed open or write cannot O_TRUNC an existing workspace document. Refs: #78 --- CHANGELOG.md | 1 + src/tools/file.c | 86 +++++++++++++++++++++++++++++++++-------------- tests/test_file.c | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c61e4a..863de91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Dangling workspace symlinks are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). +- `write_file` persists via temp+fsync+rename so a failed write cannot wipe an existing workspace file (#78). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/tools/file.c b/src/tools/file.c index 4219fed..7dcd501 100644 --- a/src/tools/file.c +++ b/src/tools/file.c @@ -13,7 +13,6 @@ #include "core/config.h" #include "cJSON.h" #include -#include #include #include #include @@ -124,30 +123,71 @@ static int resolve_workspace_write_path(const char *path, char *safe_path, size_ return n > 0 && (size_t)n < cap; } -static int open_write_nofollow(const char *safe_path, int ws_only, - char *result_buf, size_t max_len, FILE **out) +static void discard_file_tmp(int fd, char *tmp_path) { + if (fd >= 0) + (void)close(fd); + if (tmp_path) { + unlink(tmp_path); + free(tmp_path); + } +} + +static int write_all(int fd, const char *buf, size_t len) +{ + size_t off = 0; + + while (off < len) { + ssize_t n = write(fd, buf + off, len - off); + if (n <= 0) + return -1; + off += (size_t)n; + } + return 0; +} + +/* + * Temp+rename so O_TRUNC cannot wipe the live workspace file before the + * new bytes are durable (ENOSPC, EFBIG, or a non-writable parent dir). + */ +static int write_file_atomic(const char *path, const char *content, int ws_only) +{ + size_t path_len; + char *tmp_path; int flags = O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC; int fd; - FILE *f; + if (!path || !content) + return -1; + path_len = strlen(path); + tmp_path = malloc(path_len + 8); + if (!tmp_path) + return -1; + snprintf(tmp_path, path_len + 8, "%s.tmp", path); if (ws_only) flags |= O_NOFOLLOW; - fd = open(safe_path, flags, 0644); + fd = open(tmp_path, flags, 0644); if (fd < 0) { - if (ws_only && errno == ELOOP) - snprintf(result_buf, max_len, "{\"error\":\"path outside workspace\"}"); - else - snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); + free(tmp_path); return -1; } - f = fdopen(fd, "w"); - if (!f) { - close(fd); - snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); + if (write_all(fd, content, strlen(content)) != 0) { + discard_file_tmp(fd, tmp_path); + return -1; + } + if (fsync(fd) != 0) { + discard_file_tmp(fd, tmp_path); + return -1; + } + if (close(fd) != 0) { + discard_file_tmp(-1, tmp_path); + return -1; + } + if (rename(tmp_path, path) != 0) { + discard_file_tmp(-1, tmp_path); return -1; } - *out = f; + free(tmp_path); return 0; } @@ -188,30 +228,24 @@ static int file_read(const char *path, char *result_buf, size_t max_len) static int file_write(const char *path, const char *content, char *result_buf, size_t max_len) { char resolved[PATH_MAX]; + int ws_only; + char safe_path[PATH_MAX]; + if (!path_within_workspace(path, resolved, sizeof(resolved))) { snprintf(result_buf, max_len, "{\"error\":\"path outside workspace\"}"); return -1; } - int ws_only = g_file_cfg ? config_workspace_only(g_file_cfg) : 0; - char safe_path[PATH_MAX]; - FILE *f; + ws_only = g_file_cfg ? config_workspace_only(g_file_cfg) : 0; if (!ws_only) { snprintf(safe_path, sizeof(safe_path), "%s", path); } else if (!resolve_workspace_write_path(path, safe_path, sizeof(safe_path))) { snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); return -1; } - if (open_write_nofollow(safe_path, ws_only, result_buf, max_len, &f) != 0) + if (write_file_atomic(safe_path, content ? content : "", ws_only) != 0) { + snprintf(result_buf, max_len, "{\"error\":\"write failed\"}"); return -1; - if (content) { - size_t len = strlen(content); - if (fwrite(content, 1, len, f) != len) { - fclose(f); - snprintf(result_buf, max_len, "{\"error\":\"write failed\"}"); - return -1; - } } - fclose(f); snprintf(result_buf, max_len, "{\"status\":\"ok\"}"); return 0; } diff --git a/tests/test_file.c b/tests/test_file.c index dd4f173..2706d8c 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -327,6 +327,64 @@ static void test_file_write_dangling_symlink_rejected(void) rmdir(tmpdir); } +static void test_file_write_failure_preserves_existing(void) +{ + char tmpdir[PATH_MAX]; + char notes_path[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 128]; + char buf[256]; + config_t *cfg; + const tool_t *t; + int write_ret; + + /* + * Directory without write permission: creating path.tmp fails, but + * fopen/open O_TRUNC on the existing file still succeeds. Atomic + * replace must leave the original body intact. + */ + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_nowrite_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(notes_path, sizeof(notes_path), "%s/notes.md", tmpdir); + cfg = make_ws_config(tmpdir, config_path, sizeof(config_path)); + MU_ASSERT(cfg != NULL, "nowrite: load config"); + tool_file_set_config(cfg); + t = tool_file_get(); + + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"original notes that must survive\"}", + notes_path); + MU_ASSERT(t->execute(args, buf, sizeof(buf)) == 0, "seed original file"); + + MU_ASSERT(chmod(tmpdir, 0555) == 0, "make workspace dir non-writable"); + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"should not land\"}", + notes_path); + write_ret = t->execute(args, buf, sizeof(buf)); + MU_ASSERT(chmod(tmpdir, 0755) == 0, "restore workspace dir mode"); + + MU_ASSERT(write_ret != 0, "write into non-writable dir fails"); + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", notes_path); + MU_ASSERT(t->execute(args, buf, sizeof(buf)) == 0, "read after failed write"); + MU_ASSERT(strstr(buf, "original notes that must survive") != NULL, + "failed write must not wipe original"); + + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"recovered after chmod\"}", + notes_path); + MU_ASSERT(t->execute(args, buf, sizeof(buf)) == 0, "write recovers after chmod"); + snprintf(args, sizeof(args), "{\"operation\":\"read_file\",\"path\":\"%s\"}", notes_path); + MU_ASSERT(t->execute(args, buf, sizeof(buf)) == 0, "read recovered file"); + MU_ASSERT(strstr(buf, "recovered after chmod") != NULL, "recovered content present"); + + config_free(cfg); + unlink(config_path); + unlink(notes_path); + snprintf(notes_path, sizeof(notes_path), "%s/notes.md.tmp", tmpdir); + unlink(notes_path); + rmdir(tmpdir); +} + int main(void) { MU_RUN(test_file_read_write_list); @@ -337,6 +395,7 @@ int main(void) MU_RUN(test_write_does_not_truncate_file_used_as_directory); MU_RUN(test_write_does_not_collapse_missing_parent_onto_basename); MU_RUN(test_file_write_dangling_symlink_rejected); + MU_RUN(test_file_write_failure_preserves_existing); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } From 867008bee110d29c47fa797b6585cf15b78a08f2 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 01:30:43 -0300 Subject: [PATCH 59/92] fix(camera): fail closed on empty workspace_path and dangling capture paths workspace_only with an empty root no longer treats every path as allowed. Dangling symlink capture outputs are rejected instead of creating host files outside the workspace. Refs: #91, #90 --- CHANGELOG.md | 1 + src/hardware/hardware_camera.c | 20 ++++++++++-- src/hardware/hardware_camera.h | 10 ++++-- src/tools/hardware_tools.c | 10 ++++-- tests/test_hardware_camera.c | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 863de91..15bcac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Dangling workspace symlinks are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). - `write_file` persists via temp+fsync+rename so a failed write cannot wipe an existing workspace file (#78). +- Camera capture fails closed when `workspace_only` is on with an empty `workspace_path`, and rejects dangling symlink outputs (#91, #90). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/hardware/hardware_camera.c b/src/hardware/hardware_camera.c index 5fbf95d..f7e57f9 100644 --- a/src/hardware/hardware_camera.c +++ b/src/hardware/hardware_camera.c @@ -32,6 +32,8 @@ typedef enum camera_cli_kind { static int s_camera_ready; static char s_workspace[PATH_MAX]; +/** Non-zero when workspace_only is on (set_workspace with non-NULL). */ +static int s_workspace_enforced; static hardware_camera_spawn_fn s_test_spawn; static char *s_last_argv[HARDWARE_CAMERA_ARGV_MAX]; static char s_last_argv_storage[HARDWARE_CAMERA_ARGV_MAX][ARG_BUF_SZ]; @@ -108,13 +110,20 @@ static int path_inside_workspace(const char *path) char ws_resolved[PATH_MAX]; char resolved[PATH_MAX]; char path_copy[PATH_MAX]; + struct stat lst; - if (s_workspace[0] == '\0' || !path || path[0] == '\0') + /* workspace_only off (set_workspace(NULL)): no containment. */ + if (!s_workspace_enforced) return 1; + /* Enforced but missing/empty root or empty path: deny (file.c parity). */ + if (s_workspace[0] == '\0' || !path || path[0] == '\0') + return 0; if (realpath(s_workspace, ws_resolved) == NULL) return 0; if (realpath(path, resolved) != NULL) return resolved_under_workspace(resolved, ws_resolved); + if (lstat(path, &lst) == 0 && S_ISLNK(lst.st_mode)) + return 0; snprintf(path_copy, sizeof(path_copy), "%s", path); for (;;) { char *dir = dirname(path_copy); @@ -417,7 +426,13 @@ int hardware_camera_init(void) void hardware_camera_set_workspace(const char *workspace) { - if (!workspace || workspace[0] == '\0') { + if (!workspace) { + s_workspace[0] = '\0'; + s_workspace_enforced = 0; + return; + } + s_workspace_enforced = 1; + if (workspace[0] == '\0') { s_workspace[0] = '\0'; return; } @@ -436,6 +451,7 @@ void hardware_camera_shutdown(void) s_test_spawn = NULL; s_camera_ready = 0; s_workspace[0] = '\0'; + s_workspace_enforced = 0; s_spawn_timeout_ms = HARDWARE_CAMERA_SPAWN_TIMEOUT_MS; s_last_argv_count = 0; memset(s_last_argv, 0, sizeof(s_last_argv)); diff --git a/src/hardware/hardware_camera.h b/src/hardware/hardware_camera.h index 48bb014..67156f6 100644 --- a/src/hardware/hardware_camera.h +++ b/src/hardware/hardware_camera.h @@ -51,14 +51,18 @@ int hardware_camera_capture(board_id_t board, const char *camera_type, /** * Bind the file-tool workspace root used for caller-supplied capture paths. - * NULL or empty disables the check (auto temp files and unit tests). + * NULL disables containment (workspace_only off). Non-NULL enables enforcement; + * an empty string fails closed (deny caller paths) — same as write_file when + * workspace_path is missing/empty under workspace_only. + * + * Example: hardware_camera_set_workspace("") denies /tmp/out.jpg; NULL allows it. */ void hardware_camera_set_workspace(const char *workspace); /** * Return 1 if @p path may be used as a caller-supplied capture output. - * Auto temp (NULL/empty) is always allowed. When a workspace is bound, - * the path must resolve under that root (same policy as write_file). + * Auto temp (NULL/empty) is always allowed. When enforcement is on, the path + * must resolve under the bound root (same policy as write_file). */ int hardware_camera_output_allowed(const char *path); diff --git a/src/tools/hardware_tools.c b/src/tools/hardware_tools.c index 1667e15..9289be9 100644 --- a/src/tools/hardware_tools.c +++ b/src/tools/hardware_tools.c @@ -100,10 +100,14 @@ static const size_t HARDWARE_TOOL_COUNT = void tool_hardware_set_config(const config_t *cfg) { g_hw_cfg = cfg; - if (cfg && config_workspace_only(cfg)) - hardware_camera_set_workspace(config_workspace_path(cfg)); - else + if (cfg && config_workspace_only(cfg)) { + const char *ws = config_workspace_path(cfg); + + /* Pass "" when path is missing so camera fails closed like write_file. */ + hardware_camera_set_workspace(ws ? ws : ""); + } else { hardware_camera_set_workspace(NULL); + } } size_t tool_hardware_get_all(const tool_t **out, size_t max_count) diff --git a/tests/test_hardware_camera.c b/tests/test_hardware_camera.c index 3e27175..46fb2c1 100644 --- a/tests/test_hardware_camera.c +++ b/tests/test_hardware_camera.c @@ -7,6 +7,7 @@ #include "hardware/hardware_camera.h" #include #include +#include #include static char s_spawn_out_path[256]; @@ -214,6 +215,31 @@ static int test_output_path_outside_workspace_rejected(void) return 0; } +static int test_empty_workspace_enforced_denies_outside(void) +{ + char result[256]; + char err[128]; + const char *outside = "/tmp/shellclaw_cam_empty_ws_escape.jpg"; + + unlink(outside); + /* Non-NULL empty string: workspace_only on, path missing/empty. */ + hardware_camera_set_workspace(""); + ASSERT(hardware_camera_output_allowed(outside) == 0); + RUN(setup_mock()); + ASSERT(hardware_camera_capture(BOARD_JETSON_ORIN_NANO, "csi", "640x480", 75, 0, 0, + outside, result, sizeof(result), err, + sizeof(err)) == -1); + ASSERT(strstr(err, "workspace") != NULL); + ASSERT(s_spawn_called == 0); + ASSERT(access(outside, F_OK) != 0); + teardown(); + hardware_camera_init(); + hardware_camera_set_workspace(NULL); + ASSERT(hardware_camera_output_allowed(outside) == 1); + hardware_camera_shutdown(); + return 0; +} + static int test_output_path_inside_workspace_allowed(void) { char result[256]; @@ -234,6 +260,35 @@ static int test_output_path_inside_workspace_allowed(void) return 0; } +static int test_output_dangling_symlink_rejected(void) +{ + char result[256]; + char err[128]; + char ws[128]; + char link_path[256]; + char outside[256]; + struct stat st; + + ASSERT(test_runner_mkdtemp_path("shellclaw_cam_dangle", ws, sizeof(ws)) == 0); + snprintf(outside, sizeof(outside), "/tmp/sc_cam_pwned_%d.jpg", (int)getpid()); + unlink(outside); + snprintf(link_path, sizeof(link_path), "%s/shot.jpg", ws); + ASSERT(symlink(outside, link_path) == 0); + hardware_camera_set_workspace(ws); + RUN(setup_mock()); + ASSERT(hardware_camera_capture(BOARD_JETSON_ORIN_NANO, "csi", "640x480", 75, 0, 0, + link_path, result, sizeof(result), err, + sizeof(err)) == -1); + ASSERT(strstr(err, "workspace") != NULL); + ASSERT(s_spawn_called == 0); + ASSERT(stat(outside, &st) != 0); + teardown(); + unlink(link_path); + unlink(outside); + rmdir(ws); + return 0; +} + static int test_output_path_traversal_rejected(void) { char result[256]; @@ -605,7 +660,9 @@ int main(void) RUN(test_capture_argument_validation()); RUN(test_unsafe_output_path_rejected()); RUN(test_output_path_outside_workspace_rejected()); + RUN(test_empty_workspace_enforced_denies_outside()); RUN(test_output_path_inside_workspace_allowed()); + RUN(test_output_dangling_symlink_rejected()); RUN(test_output_path_traversal_rejected()); RUN(test_resolution_injection_rejected()); RUN(test_camera_type_injection_rejected()); From c466473570ebdaa4f832a2056419983f3c0d34b6 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 01:34:47 -0300 Subject: [PATCH 60/92] fix(cron): keep full SQLite TEXT for schedule and message Heap-copy TEXT columns instead of 127/511-byte buffers so a long interval schedule still parses after fire and a long reminder is not silently clipped. Refs: #73 --- CHANGELOG.md | 1 + src/core/memory.c | 56 ++++++++++++++++++++++++--------- src/core/memory.h | 27 +++++++++++++--- src/gateway/routes.c | 32 ++++++++++++++++--- src/tools/cron.c | 27 ++++++++++++---- tests/test_cron.c | 73 +++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 185 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ce925..c9fbbab 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 +- Cron job `schedule` and `message` are delivered as full SQLite TEXT instead of truncating to 127/511 bytes (#73). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/core/memory.c b/src/core/memory.c index 067b52c..e33adf5 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -358,6 +358,38 @@ static void copy_str_bounded(char *dst, size_t dst_size, const char *src) dst[n] = '\0'; } +static char *dup_sqlite_text(sqlite3_stmt *stmt, int col) +{ + const unsigned char *p = sqlite3_column_text(stmt, col); + return strdup(p ? (const char *)p : ""); +} + +void cron_job_row_free(cron_job_row_t *row) +{ + if (!row) return; + free(row->schedule); + free(row->message); + row->schedule = NULL; + row->message = NULL; +} + +static int fill_cron_job_row(sqlite3_stmt *stmt, cron_job_row_t *out) +{ + memset(out, 0, sizeof(*out)); + copy_str_bounded(out->id, sizeof(out->id), (const char *)sqlite3_column_text(stmt, 0)); + out->schedule = dup_sqlite_text(stmt, 1); + out->message = dup_sqlite_text(stmt, 2); + copy_str_bounded(out->channel, sizeof(out->channel), (const char *)sqlite3_column_text(stmt, 3)); + copy_str_bounded(out->recipient, sizeof(out->recipient), (const char *)sqlite3_column_text(stmt, 4)); + out->next_run = sqlite3_column_int64(stmt, 5); + out->enabled = sqlite3_column_int(stmt, 6); + if (!out->schedule || !out->message) { + cron_job_row_free(out); + return -1; + } + return 0; +} + int cron_job_create(const char *id, const char *schedule, const char *message, const char *channel, const char *recipient, long long next_run, int enabled) { @@ -425,13 +457,12 @@ int cron_job_list(cron_job_row_t *out, int max_count) if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; int count = 0; while (count < max_count && sqlite3_step(stmt) == SQLITE_ROW) { - copy_str_bounded(out[count].id, sizeof(out[count].id), (const char *)sqlite3_column_text(stmt, 0)); - copy_str_bounded(out[count].schedule, sizeof(out[count].schedule), (const char *)sqlite3_column_text(stmt, 1)); - copy_str_bounded(out[count].message, sizeof(out[count].message), (const char *)sqlite3_column_text(stmt, 2)); - copy_str_bounded(out[count].channel, sizeof(out[count].channel), (const char *)sqlite3_column_text(stmt, 3)); - copy_str_bounded(out[count].recipient, sizeof(out[count].recipient), (const char *)sqlite3_column_text(stmt, 4)); - out[count].next_run = sqlite3_column_int64(stmt, 5); - out[count].enabled = sqlite3_column_int(stmt, 6); + if (fill_cron_job_row(stmt, &out[count]) != 0) { + for (int i = 0; i < count; i++) + cron_job_row_free(&out[i]); + sqlite3_finalize(stmt); + return -1; + } count++; } sqlite3_finalize(stmt); @@ -448,13 +479,10 @@ int cron_job_get_next_due(long long now, cron_job_row_t *out) sqlite3_bind_int64(stmt, 1, now); int ret = 0; if (sqlite3_step(stmt) == SQLITE_ROW) { - copy_str_bounded(out->id, sizeof(out->id), (const char *)sqlite3_column_text(stmt, 0)); - copy_str_bounded(out->schedule, sizeof(out->schedule), (const char *)sqlite3_column_text(stmt, 1)); - copy_str_bounded(out->message, sizeof(out->message), (const char *)sqlite3_column_text(stmt, 2)); - copy_str_bounded(out->channel, sizeof(out->channel), (const char *)sqlite3_column_text(stmt, 3)); - copy_str_bounded(out->recipient, sizeof(out->recipient), (const char *)sqlite3_column_text(stmt, 4)); - out->next_run = sqlite3_column_int64(stmt, 5); - out->enabled = sqlite3_column_int(stmt, 6); + if (fill_cron_job_row(stmt, out) != 0) { + sqlite3_finalize(stmt); + return -1; + } ret = 1; } sqlite3_finalize(stmt); diff --git a/src/core/memory.h b/src/core/memory.h index f8149ea..c0a3970 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -120,17 +120,33 @@ int config_kv_get(const char *key, char *value_out, size_t max_len); */ int config_kv_set(const char *key, const char *value); -/** Row from cron_jobs table for list/get operations. */ +/** + * Row from cron_jobs table for list/get operations. + * + * schedule and message are heap copies of the SQLite TEXT columns. The + * caller must cron_job_row_free() each filled row. Example: + * cron_job_row_t row; + * memset(&row, 0, sizeof(row)); + * if (cron_job_get_next_due(now, &row) == 1) + * cron_job_row_free(&row); + */ typedef struct cron_job_row { char id[128]; - char schedule[128]; - char message[512]; + char *schedule; + char *message; char channel[64]; char recipient[64]; long long next_run; int enabled; } cron_job_row_t; +/** + * Free heap fields on a cron job row. Safe on NULL, zeroed, or already-freed rows. + * + * @param row Row to release (may be NULL). + */ +void cron_job_row_free(cron_job_row_t *row); + /** * Create a cron job. * @@ -163,7 +179,8 @@ int cron_job_update_next_run(const char *id, long long next_run); /** * List cron jobs into output array. * - * @param out Array to fill (caller-allocated). + * @param out Array to fill (caller-allocated). Heap fields are owned + * by the caller on success; on -1, no row is owned. * @param max_count Maximum jobs to return. * @return Number of jobs written, or -1 on error. */ @@ -173,7 +190,7 @@ int cron_job_list(cron_job_row_t *out, int max_count); * Get the next due job (next_run <= now, enabled). * * @param now Current Unix timestamp. - * @param out Filled with job data if found. + * @param out Filled with job data if found. Caller must cron_job_row_free(). * @return 1 if found, 0 if none, -1 on error. */ int cron_job_get_next_due(long long now, cron_job_row_t *out); diff --git a/src/gateway/routes.c b/src/gateway/routes.c index db64a95..100d066 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -390,30 +390,52 @@ static void handle_session_delete(const char *id, char *buf, size_t size, int *s json_response(buf, size, status, "{\"ok\":true}"); } +static void free_cron_job_rows(cron_job_row_t *rows, int n) +{ + int i; + if (!rows || n <= 0) return; + for (i = 0; i < n; i++) + cron_job_row_free(&rows[i]); +} + static void handle_cron_list(char *buf, size_t size, int *status) { cron_job_row_t *rows = calloc(64, sizeof(cron_job_row_t)); + int n; + int i; if (!rows) { json_error(buf, size, status, 500, "Out of memory"); return; } - int n = cron_job_list(rows, 64); + n = cron_job_list(rows, 64); + if (n < 0) { + free(rows); + json_error(buf, size, status, 500, "Internal error"); + return; + } cJSON *arr = cJSON_CreateArray(); - if (!arr) { free(rows); json_error(buf, size, status, 500, "Internal error"); return; } - for (int i = 0; i < n; i++) { + if (!arr) { + free_cron_job_rows(rows, n); + free(rows); + json_error(buf, size, status, 500, "Internal error"); + return; + } + for (i = 0; i < n; i++) { cJSON *obj = cJSON_CreateObject(); if (!obj) { + free_cron_job_rows(rows, n); free(rows); cJSON_Delete(arr); json_error(buf, size, status, 500, "Internal error"); return; } cJSON_AddItemToObject(obj, "id", cJSON_CreateString(rows[i].id)); - cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule)); - cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message)); + cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule ? rows[i].schedule : "")); + cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message ? rows[i].message : "")); cJSON_AddItemToObject(obj, "channel", cJSON_CreateString(rows[i].channel)); cJSON_AddItemToObject(obj, "recipient", cJSON_CreateString(rows[i].recipient)); cJSON_AddItemToObject(obj, "next_run", cJSON_CreateNumber((double)rows[i].next_run)); cJSON_AddItemToObject(obj, "enabled", cJSON_CreateBool(rows[i].enabled)); cJSON_AddItemToArray(arr, obj); } + free_cron_job_rows(rows, n); free(rows); json_print_to_buf(arr, buf, size, status); cJSON_Delete(arr); diff --git a/src/tools/cron.c b/src/tools/cron.c index eb46e18..0c5bf94 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -194,9 +194,10 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) row.recipient[0] ? row.recipient : "default"); out->session_id = strdup(session_id); out->user_id = strdup(row.id); - out->text = strdup(row.message); + out->text = strdup(row.message ? row.message : ""); out->attachments = NULL; out->attachments_count = 0; + cron_job_row_free(&row); return 1; } @@ -251,21 +252,35 @@ static int cron_tool_execute(const char *args_json, char *result_buf, size_t max int ret = 0; if (strcmp(operation, "list") == 0) { cron_job_row_t rows[64]; - int n = cron_job_list(rows, 64); + int n; + int i; + memset(rows, 0, sizeof(rows)); + n = cron_job_list(rows, 64); + if (n < 0) { + cJSON_Delete(root); + snprintf(result_buf, max_len, "{\"error\":\"failed to list jobs\"}"); + return -1; + } cJSON *arr = cJSON_CreateArray(); - if (!arr) { cJSON_Delete(root); snprintf(result_buf, max_len, "{\"error\":\"out of memory\"}"); return -1; } - for (int i = 0; i < n; i++) { + if (!arr) { + for (i = 0; i < n; i++) cron_job_row_free(&rows[i]); + cJSON_Delete(root); + snprintf(result_buf, max_len, "{\"error\":\"out of memory\"}"); + return -1; + } + for (i = 0; i < n; i++) { cJSON *obj = cJSON_CreateObject(); if (!obj) break; cJSON_AddItemToObject(obj, "id", cJSON_CreateString(rows[i].id)); - cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule)); - cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message)); + cJSON_AddItemToObject(obj, "schedule", cJSON_CreateString(rows[i].schedule ? rows[i].schedule : "")); + cJSON_AddItemToObject(obj, "message", cJSON_CreateString(rows[i].message ? rows[i].message : "")); cJSON_AddItemToObject(obj, "channel", cJSON_CreateString(rows[i].channel)); cJSON_AddItemToObject(obj, "recipient", cJSON_CreateString(rows[i].recipient)); cJSON_AddItemToObject(obj, "next_run", cJSON_CreateNumber((double)rows[i].next_run)); cJSON_AddItemToObject(obj, "enabled", cJSON_CreateBool(rows[i].enabled)); cJSON_AddItemToArray(arr, obj); } + for (i = 0; i < n; i++) cron_job_row_free(&rows[i]); char *s = cJSON_PrintUnformatted(arr); cJSON_Delete(arr); if (s) { diff --git a/tests/test_cron.c b/tests/test_cron.c index ea21c0c..e30dae5 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -5,6 +5,7 @@ #include "tools/cron.h" #include "core/memory.h" +#include "channels/channel.h" #include #include #include @@ -74,16 +75,22 @@ static int test_cron_job_crud_and_due(void) ASSERT(cron_parse_next_run("at:9999999999", now, &next) == 0); ASSERT(cron_job_create("job1", "at:9999999999", "Remind me", "cli", "default", next, 1) == 0); cron_job_row_t rows[16]; + memset(rows, 0, sizeof(rows)); int n = cron_job_list(rows, 16); ASSERT(n == 1); ASSERT(strcmp(rows[0].id, "job1") == 0); ASSERT(strcmp(rows[0].message, "Remind me") == 0); + for (int i = 0; i < n; i++) cron_job_row_free(&rows[i]); cron_job_row_t due; + memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(now, &due) == 0); ASSERT(cron_job_get_next_due(9999999999, &due) == 1); ASSERT(strcmp(due.id, "job1") == 0); + cron_job_row_free(&due); ASSERT(cron_job_toggle("job1") == 0); + memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(9999999999, &due) == 0); + cron_job_row_free(&due); ASSERT(cron_job_toggle("job1") == 0); ASSERT(cron_job_delete("job1") == 0); ASSERT(cron_job_list(rows, 16) == 0); @@ -108,9 +115,12 @@ static int test_cron_tool_execute(void) ASSERT(cron_tool->execute("{\"operation\":\"list\"}", buf, sizeof(buf)) == 0); ASSERT(strstr(buf, "test") != NULL); cron_job_row_t rows[16]; + memset(rows, 0, sizeof(rows)); int n = cron_job_list(rows, 16); ASSERT(n >= 1); - const char *id = rows[0].id; + char id[128]; + snprintf(id, sizeof(id), "%s", rows[0].id); + for (int i = 0; i < n; i++) cron_job_row_free(&rows[i]); char del_json[256]; snprintf(del_json, sizeof(del_json), "{\"operation\":\"delete\",\"id\":\"%s\"}", id); ASSERT(cron_tool->execute(del_json, buf, sizeof(buf)) == 0); @@ -131,14 +141,73 @@ static int test_one_shot_detection(void) ASSERT(memory_init(path) == 0); ASSERT(cron_job_create("oneshot1", "at:9999999999", "One-shot", "cli", "default", 9999999999, 1) == 0); cron_job_row_t due; + memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(9999999999, &due) == 1); ASSERT(cron_is_one_shot(due.schedule) == 1); + cron_job_row_free(&due); cron_job_delete("oneshot1"); memory_cleanup(); remove(path); return 0; } +static int test_due_job_delivers_full_message(void) +{ + const char *path = "/tmp/shellclaw_test_cron_long_message.db"; + remove(path); + ASSERT(memory_init(path) == 0); + char message[600]; + memset(message, 'A', 599); + message[599] = '\0'; + long long now = (long long)time(NULL); + ASSERT(cron_job_create("longmsg", "interval:60", message, "cli", "default", now - 1, 1) == 0); + cron_job_row_t due; + memset(&due, 0, sizeof(due)); + ASSERT(cron_job_get_next_due(now, &due) == 1); + ASSERT(due.message != NULL); + ASSERT(strlen(due.message) == 599); + ASSERT(strcmp(due.message, message) == 0); + cron_job_row_free(&due); + const channel_t *ch = channel_cron_get(); + channel_incoming_msg_t msg; + memset(&msg, 0, sizeof(msg)); + ASSERT(ch->poll(&msg, 0) == 1); + ASSERT(msg.text != NULL); + ASSERT(strlen(msg.text) == 599); + ASSERT(strcmp(msg.text, message) == 0); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_long_interval_schedule_roundtrips(void) +{ + const char *path = "/tmp/shellclaw_test_cron_long_schedule.db"; + remove(path); + ASSERT(memory_init(path) == 0); + char zeros[121]; + memset(zeros, '0', 120); + zeros[120] = '\0'; + char schedule[160]; + snprintf(schedule, sizeof(schedule), "interval:%s60", zeros); + ASSERT(strlen(schedule) > 127); + long long parsed = 0; + long long now = (long long)time(NULL); + ASSERT(cron_parse_next_run(schedule, now, &parsed) == 0); + ASSERT(parsed == now + 60); + ASSERT(cron_job_create("longsched", schedule, "tick", "cli", "default", now - 1, 1) == 0); + cron_job_row_t due; + memset(&due, 0, sizeof(due)); + ASSERT(cron_job_get_next_due(now, &due) == 1); + ASSERT(due.schedule != NULL); + ASSERT(strcmp(due.schedule, schedule) == 0); + cron_job_row_free(&due); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -149,6 +218,8 @@ int main(void) RUN(test_cron_job_crud_and_due()); RUN(test_cron_tool_execute()); RUN(test_one_shot_detection()); + RUN(test_due_job_delivers_full_message()); + RUN(test_long_interval_schedule_roundtrips()); printf("test_cron: all tests passed\n"); return 0; } From dcf193b73495ef35667247e67780558f65108667 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 01:36:20 -0300 Subject: [PATCH 61/92] fix(cron): defer job commit until agent delivery succeeds cron_poll no longer deletes or reschedules a due job. Ack after agent_unlock and a successful channel send so a failed agent_run cannot drop the reminder. Refs: #57 --- CHANGELOG.md | 1 + Makefile | 4 +-- src/core/dispatch.c | 9 ++++++- src/core/memory.c | 21 ++++++++++++++++ src/core/memory.h | 9 +++++++ src/tools/cron.c | 34 +++++++++++++++++++------ src/tools/cron.h | 9 +++++++ tests/test_cron.c | 61 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 137 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fbbab..af8cdf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - Cron job `schedule` and `message` are delivered as full SQLite TEXT instead of truncating to 127/511 bytes (#73). +- Cron jobs are committed (delete/advance) only after successful agent delivery, so a failed `agent_run` cannot drop a reminder (#57). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/Makefile b/Makefile index 922d76f..056d8c2 100644 --- a/Makefile +++ b/Makefile @@ -626,9 +626,9 @@ test_context: tests/test_context.c $(CONTEXT_TEST_OBJS) $(CONFIG_O) $(TOML_O) $( $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -DSHELLCLAW_CONTEXT_TEST -o $(BINDIR)/$@ tests/test_context.c $(CONTEXT_TEST_OBJS) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) -pthread $(DSYM_SCRIPT) -test_dispatch: tests/test_dispatch.c $(DISPATCH_O) $(BOOTSTRAP_DISPATCH_STUB_O) $(AGENT_O) $(ROUTER_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(PROVIDER_COMMON_O) $(MEMORY_O) $(SQLITE3_O) $(SKILL_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) +test_dispatch: tests/test_dispatch.c $(DISPATCH_O) $(BOOTSTRAP_DISPATCH_STUB_O) $(AGENT_O) $(ROUTER_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(PROVIDER_COMMON_O) $(MEMORY_O) $(SQLITE3_O) $(SKILL_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(CRON_O) $(CRYPTO_LINK) $(CHANNEL_COMMON_O) @mkdir -p $(BINDIR) - $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_dispatch.c $(DISPATCH_O) $(BOOTSTRAP_DISPATCH_STUB_O) $(AGENT_O) $(ROUTER_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(PROVIDER_COMMON_O) $(MEMORY_O) $(SQLITE3_O) $(SKILL_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) -pthread + $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_dispatch.c $(DISPATCH_O) $(BOOTSTRAP_DISPATCH_STUB_O) $(AGENT_O) $(ROUTER_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(PROVIDER_COMMON_O) $(MEMORY_O) $(SQLITE3_O) $(SKILL_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(CRON_O) $(CRYPTO_LINK) $(CHANNEL_COMMON_O) $(LDLIBS) -pthread $(DSYM_SCRIPT) RELOAD_TEST_OBJS := $(RELOAD_O) $(BOOTSTRAP_DISPATCH_STUB_O) $(TOOL_RELOAD_STUB_O) $(RELOAD_CHANNEL_STUB_O) $(HTTP_RELOAD_STUB_O) $(CONFIG_O) $(TOML_O) \ diff --git a/src/core/dispatch.c b/src/core/dispatch.c index a9a4803..f48b4e1 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -9,6 +9,7 @@ #include "core/bootstrap.h" #include "core/memory.h" #include "core/version.h" +#include "tools/cron.h" #include #include @@ -42,5 +43,11 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) agent_unlock(); if (err != 0 && resp_buf[0] == '\0') snprintf(resp_buf, sizeof(resp_buf), "Error: agent failed (code %d)", err); - return ch->send(msg->session_id, resp_buf, NULL, 0); + { + int send_err = ch->send(msg->session_id, resp_buf, NULL, 0); + if (send_err == 0 && err == 0 && ch->name && strcmp(ch->name, "cron") == 0 && + msg->user_id) + cron_ack_delivery(msg->user_id); + return send_err; + } } diff --git a/src/core/memory.c b/src/core/memory.c index e33adf5..06897da 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -489,6 +489,27 @@ int cron_job_get_next_due(long long now, cron_job_row_t *out) return ret; } +int cron_job_get_by_id(const char *id, cron_job_row_t *out) +{ + const char *sql; + sqlite3_stmt *stmt = NULL; + int ret = 0; + + if (!g_db || !id || !out) return -1; + sql = "SELECT id, schedule, message, channel, recipient, next_run, enabled FROM cron_jobs WHERE id = ?1"; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT); + if (sqlite3_step(stmt) == SQLITE_ROW) { + if (fill_cron_job_row(stmt, out) != 0) { + sqlite3_finalize(stmt); + return -1; + } + ret = 1; + } + sqlite3_finalize(stmt); + return ret; +} + void memory_cleanup(void) { if (g_db) { diff --git a/src/core/memory.h b/src/core/memory.h index c0a3970..8ae7869 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -195,6 +195,15 @@ int cron_job_list(cron_job_row_t *out, int max_count); */ int cron_job_get_next_due(long long now, cron_job_row_t *out); +/** + * Load a cron job by id. + * + * @param id Job id. + * @param out Filled with job data if found. Caller must cron_job_row_free(). + * @return 1 if found, 0 if missing, -1 on error. + */ +int cron_job_get_by_id(const char *id, cron_job_row_t *out); + /** * Release resources and close the database. Safe to call multiple times. */ diff --git a/src/tools/cron.c b/src/tools/cron.c index 0c5bf94..7daea3b 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -179,14 +179,6 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) cron_job_row_t row; memset(&row, 0, sizeof(row)); if (cron_job_get_next_due(now, &row) != 1) return 0; - int is_one_shot = cron_is_one_shot(row.schedule); - if (is_one_shot) { - cron_job_delete(row.id); - } else { - long long next = 0; - if (cron_parse_next_run(row.schedule, now, &next) == 0) - cron_job_update_next_run(row.id, next); - } memset(out, 0, sizeof(*out)); char session_id[256]; snprintf(session_id, sizeof(session_id), "%s:%s", @@ -198,9 +190,35 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) out->attachments = NULL; out->attachments_count = 0; cron_job_row_free(&row); + if (!out->session_id || !out->user_id || !out->text) { + channel_incoming_msg_clear(out); + return -1; + } return 1; } +int cron_ack_delivery(const char *job_id) +{ + cron_job_row_t row; + long long now; + long long next = 0; + int rc; + + if (!job_id || !job_id[0]) return -1; + memset(&row, 0, sizeof(row)); + if (cron_job_get_by_id(job_id, &row) != 1) return -1; + if (cron_is_one_shot(row.schedule)) { + rc = cron_job_delete(row.id); + cron_job_row_free(&row); + return rc; + } + now = (long long)time(NULL); + rc = cron_parse_next_run(row.schedule, now, &next); + cron_job_row_free(&row); + if (rc != 0) return -1; + return cron_job_update_next_run(job_id, next); +} + static int cron_send(const char *recipient, const char *text, const channel_attachment_t *attachments, size_t att_count) { diff --git a/src/tools/cron.h b/src/tools/cron.h index 1635d89..dcafeff 100644 --- a/src/tools/cron.h +++ b/src/tools/cron.h @@ -33,6 +33,15 @@ int cron_parse_next_run(const char *schedule, long long now, long long *next_out */ int cron_is_one_shot(const char *schedule); +/** + * Commit cron delivery after the agent successfully handles a fired job. + * One-shot jobs are deleted; recurring jobs advance next_run. + * + * @param job_id Job id from cron poll user_id field. + * @return 0 on success, non-zero on error. + */ +int cron_ack_delivery(const char *job_id); + /** * Get the cron channel (poll returns due jobs, send routes to target channel). */ diff --git a/tests/test_cron.c b/tests/test_cron.c index e30dae5..b404af6 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -208,6 +208,65 @@ static int test_long_interval_schedule_roundtrips(void) return 0; } +static int test_cron_ack_delivery_deferred(void) +{ + const char *path = "/tmp/shellclaw_test_cron_ack.db"; + remove(path); + ASSERT(memory_init(path) == 0); + long long now = (long long)time(NULL); + long long due_at = now - 10; + ASSERT(cron_job_create("oneshot_ack", "at:9999999999", "Fire me", "cli", "default", due_at, 1) == 0); + cron_job_row_t row; + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_next_due(now, &row) == 1); + ASSERT(strcmp(row.id, "oneshot_ack") == 0); + cron_job_row_free(&row); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("oneshot_ack", &row) == 1); + cron_job_row_free(&row); + ASSERT(cron_ack_delivery("oneshot_ack") == 0); + ASSERT(cron_job_get_by_id("oneshot_ack", &row) == 0); + ASSERT(cron_job_create("interval_ack", "interval:3600", "Repeat", "cli", "default", due_at, 1) == 0); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_next_due(now, &row) == 1); + long long before_ack = row.next_run; + cron_job_row_free(&row); + ASSERT(cron_ack_delivery("interval_ack") == 0); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("interval_ack", &row) == 1); + ASSERT(row.next_run > before_ack); + cron_job_row_free(&row); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_cron_poll_keeps_job_until_ack(void) +{ + const channel_t *cron_ch = channel_cron_get(); + ASSERT(cron_ch != NULL); + const char *path = "/tmp/shellclaw_test_cron_poll.db"; + remove(path); + ASSERT(memory_init(path) == 0); + long long now = (long long)time(NULL); + ASSERT(cron_job_create("poll_keep", "at:9999999999", "Due now", "cli", "default", now - 1, 1) == 0); + channel_incoming_msg_t msg; + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 0) == 1); + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "poll_keep") == 0); + cron_job_row_t row; + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("poll_keep", &row) == 1); + cron_job_row_free(&row); + channel_incoming_msg_clear(&msg); + ASSERT(cron_ack_delivery("poll_keep") == 0); + ASSERT(cron_job_get_by_id("poll_keep", &row) == 0); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -220,6 +279,8 @@ int main(void) RUN(test_one_shot_detection()); RUN(test_due_job_delivers_full_message()); RUN(test_long_interval_schedule_roundtrips()); + RUN(test_cron_ack_delivery_deferred()); + RUN(test_cron_poll_keeps_job_until_ack()); printf("test_cron: all tests passed\n"); return 0; } From 45a88969a6a142f73ffdb53912d0fa312e16abbd Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 01:40:46 -0300 Subject: [PATCH 62/92] fix(cron): advance recurring jobs past the firing minute Search next_run from the next minute with a 366-day window so monthly exprs still advance after a fire. Fail-closed on ack parse errors by bumping next_run by a year instead of leaving the job due. Refs: #65 --- CHANGELOG.md | 1 + src/tools/cron.c | 14 ++++-- src/tools/cron.h | 6 ++- tests/test_cron.c | 124 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af8cdf2..fdaf2f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - Cron job `schedule` and `message` are delivered as full SQLite TEXT instead of truncating to 127/511 bytes (#73). - Cron jobs are committed (delete/advance) only after successful agent delivery, so a failed `agent_run` cannot drop a reminder (#57). +- Recurring cron jobs search the next run from the following minute with a 366-day window; ack fail-closes unparseable schedules to now+365d so they cannot re-fire every poll (#65). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/tools/cron.c b/src/tools/cron.c index 7daea3b..585a0c9 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -21,7 +21,8 @@ #define CRON_PREFIX_INTERVAL "interval:" #define CRON_PREFIX_AT "at:" #define CRON_PREFIX_CRON "cron:" -#define CRON_MAX_ITER_MINUTES (8 * 24 * 60) +/* Cover at least one leap year so monthly/yearly exprs can advance after firing. */ +#define CRON_MAX_ITER_MINUTES (366 * 24 * 60) static int parse_field(const char *s, int *out, int min_val, int max_val) { @@ -104,7 +105,13 @@ static long long cron_next_from_expr(const char *cron_part, long long now) { int fields[10]; if (parse_cron_expr(cron_part, fields) != 0) return -1; - time_t t = (time_t)now; + /* + * Start at the beginning of the *next* minute. Returning the current + * minute would leave next_run <= now after a fire, so the job would + * re-deliver every poll until the minute rolled over — and forever if + * the following match was outside the search window. + */ + time_t t = (time_t)(now - (now % 60) + 60); struct tm tm; if (!localtime_r(&t, &tm)) return -1; int min = tm.tm_min, hour = tm.tm_hour, mday = tm.tm_mday, mon = tm.tm_mon + 1, wday = tm.tm_wday; @@ -215,7 +222,8 @@ int cron_ack_delivery(const char *job_id) now = (long long)time(NULL); rc = cron_parse_next_run(row.schedule, now, &next); cron_job_row_free(&row); - if (rc != 0) return -1; + if (rc != 0) + next = now + 365LL * 24 * 3600; return cron_job_update_next_run(job_id, next); } diff --git a/src/tools/cron.h b/src/tools/cron.h index dcafeff..803196d 100644 --- a/src/tools/cron.h +++ b/src/tools/cron.h @@ -14,13 +14,15 @@ extern "C" { #endif /** - * Parse schedule and compute next_run from current time. + * Parse schedule and compute next_run strictly after `now`. * Formats: "cron:min hour dom month dow", "interval:N", "at:unix_ts". * Cron: 5 fields, * or N or N-M. dow 0-6 (Sun-Sat). + * Cron expressions start searching at the next minute boundary so a just-fired + * job cannot remain due in the same minute. * * @param schedule Schedule string. * @param now Current Unix timestamp. - * @param next_out Output: next run time. + * @param next_out Output: next run time (always > now on success for cron:/interval:). * @return 0 on success, -1 on parse error. */ int cron_parse_next_run(const char *schedule, long long now, long long *next_out); diff --git a/tests/test_cron.c b/tests/test_cron.c index b404af6..5856182 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -2,6 +2,7 @@ * @file test_cron.c * @brief Unit tests for cron: schedule parsing, next_run, one-shot. */ +#define _POSIX_C_SOURCE 200809L #include "tools/cron.h" #include "core/memory.h" @@ -42,7 +43,7 @@ static int test_cron_expr_next_run(void) long long now = 1700000000; long long next = 0; ASSERT(cron_parse_next_run("0 0 * * *", now, &next) == 0); - ASSERT(next >= now); + ASSERT(next > now); return 0; } @@ -51,7 +52,58 @@ static int test_cron_expr_with_prefix(void) long long now = 1700000000; long long next = 0; ASSERT(cron_parse_next_run("cron:0 0 * * *", now, &next) == 0); - ASSERT(next >= now); + ASSERT(next > now); + return 0; +} + +static int test_cron_expr_skips_current_minute(void) +{ + long long now = 1700000017; + time_t t = (time_t)now; + struct tm tm; + char schedule[64]; + long long next = 0; + time_t next_t; + struct tm next_tm; + + ASSERT(localtime_r(&t, &tm) != NULL); + snprintf(schedule, sizeof(schedule), "cron:%d %d * * *", tm.tm_min, tm.tm_hour); + ASSERT(cron_parse_next_run(schedule, now, &next) == 0); + ASSERT(next > now); + ASSERT(next >= (now - (now % 60) + 60)); + next_t = (time_t)next; + ASSERT(localtime_r(&next_t, &next_tm) != NULL); + ASSERT(next_tm.tm_min == tm.tm_min); + ASSERT(next_tm.tm_hour == tm.tm_hour); + return 0; +} + +static int test_cron_expr_monthly_beyond_eight_days(void) +{ + time_t t = 1700000000; + struct tm tm; + long long now; + long long next = 0; + time_t next_t; + struct tm next_tm; + + ASSERT(localtime_r(&t, &tm) != NULL); + tm.tm_mday = 15; + tm.tm_hour = 12; + tm.tm_min = 0; + tm.tm_sec = 0; + tm.tm_isdst = -1; + t = mktime(&tm); + ASSERT(t != (time_t)-1); + now = (long long)t; + ASSERT(cron_parse_next_run("cron:0 0 1 * *", now, &next) == 0); + ASSERT(next > now); + ASSERT(next - now > 8LL * 24 * 3600); + next_t = (time_t)next; + ASSERT(localtime_r(&next_t, &next_tm) != NULL); + ASSERT(next_tm.tm_mday == 1); + ASSERT(next_tm.tm_hour == 0); + ASSERT(next_tm.tm_min == 0); return 0; } @@ -267,12 +319,78 @@ static int test_cron_poll_keeps_job_until_ack(void) return 0; } +static int test_cron_ack_advances_recurring_past_due_minute(void) +{ + const char *path = "/tmp/shellclaw_test_cron_ack_advance.db"; + const channel_t *cron_ch; + channel_incoming_msg_t msg; + cron_job_row_t rows[4]; + long long now; + time_t t; + struct tm tm; + char schedule[64]; + + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + t = (time_t)now; + ASSERT(localtime_r(&t, &tm) != NULL); + snprintf(schedule, sizeof(schedule), "cron:%d %d * * *", tm.tm_min, tm.tm_hour); + ASSERT(cron_job_create("poll_adv1", schedule, "due now", "cli", "default", now - 1, 1) == 0); + cron_ch = channel_cron_get(); + ASSERT(cron_ch != NULL && cron_ch->poll != NULL); + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 0) == 1); + ASSERT(msg.user_id != NULL); + ASSERT(cron_ack_delivery(msg.user_id) == 0); + channel_incoming_msg_clear(&msg); + memset(rows, 0, sizeof(rows)); + ASSERT(cron_job_list(rows, 4) == 1); + ASSERT(strcmp(rows[0].id, "poll_adv1") == 0); + ASSERT(rows[0].next_run > now); + ASSERT(rows[0].next_run >= (now - (now % 60) + 60)); + cron_job_row_free(&rows[0]); + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 0) == 0); + cron_job_delete("poll_adv1"); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_cron_ack_fail_closed_on_parse_error(void) +{ + const char *path = "/tmp/shellclaw_test_cron_ack_fail_closed.db"; + cron_job_row_t row; + long long now; + long long floor_next; + long long ceil_next; + + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + ASSERT(cron_job_create("bad_sched", "cron:not-a-schedule", "msg", "cli", "default", now - 1, 1) == 0); + ASSERT(cron_ack_delivery("bad_sched") == 0); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("bad_sched", &row) == 1); + floor_next = now + 365LL * 24 * 3600 - 2; + ceil_next = now + 365LL * 24 * 3600 + 2; + ASSERT(row.next_run >= floor_next); + ASSERT(row.next_run <= ceil_next); + cron_job_row_free(&row); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); RUN(test_at_one_shot()); RUN(test_cron_expr_next_run()); RUN(test_cron_expr_with_prefix()); + RUN(test_cron_expr_skips_current_minute()); + RUN(test_cron_expr_monthly_beyond_eight_days()); RUN(test_invalid_schedule()); RUN(test_cron_job_crud_and_due()); RUN(test_cron_tool_execute()); @@ -281,6 +399,8 @@ int main(void) RUN(test_long_interval_schedule_roundtrips()); RUN(test_cron_ack_delivery_deferred()); RUN(test_cron_poll_keeps_job_until_ack()); + RUN(test_cron_ack_advances_recurring_past_due_minute()); + RUN(test_cron_ack_fail_closed_on_parse_error()); printf("test_cron: all tests passed\n"); return 0; } From 088ef193c61ed5495cc85de23c7853953cc056b0 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 02:33:52 -0300 Subject: [PATCH 63/92] fix(file): unique write sidecar and honest leaf-symlink reject Use mkstemp in the parent directory so write_file cannot O_TRUNC a sibling path.tmp. Rename the helper and changelog to match fail-closed rejection of every leaf symlink, including in-workspace aliases. Refs: #94 --- CHANGELOG.md | 6 +-- src/tools/file.c | 56 +++++++++++++++------------- tests/test_file.c | 93 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15bcac1..b43fa0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed -- `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Dangling workspace symlinks are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). -- `write_file` persists via temp+fsync+rename so a failed write cannot wipe an existing workspace file (#78). -- Camera capture fails closed when `workspace_only` is on with an empty `workspace_path`, and rejects dangling symlink outputs (#91, #90). +- `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Leaf workspace symlinks (dangling or an in-workspace alias) are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). +- `write_file` persists via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe an existing workspace file and a sibling `path.tmp` is not truncated (#78). +- Camera capture fails closed when `workspace_only` is on with an empty `workspace_path`, and rejects leaf symlink outputs (#91, #90). - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/tools/file.c b/src/tools/file.c index 7dcd501..2fd08dd 100644 --- a/src/tools/file.c +++ b/src/tools/file.c @@ -51,7 +51,7 @@ static int resolved_is_under_workspace(const char *resolved) return 1; } -static int path_is_dangling_symlink(const char *path) +static int path_is_symlink(const char *path) { struct stat lst; @@ -75,8 +75,8 @@ static int path_within_workspace(const char *path, char *resolved, size_t resolv return 0; if (realpath(path, resolved) != NULL) return resolved_is_under_workspace(resolved); - /* Dangling symlink: ancestor prefix is not enough — open() would follow it. */ - if (path_is_dangling_symlink(path)) + /* Leaf symlink: ancestor prefix is not enough — open() would follow it. */ + if (path_is_symlink(path)) return 0; snprintf(path_copy, sizeof(path_copy), "%s", path); for (;;) { @@ -103,7 +103,7 @@ static int resolve_workspace_write_path(const char *path, char *safe_path, size_ char *dir; int n; - if (path_is_dangling_symlink(path)) + if (path_is_symlink(path)) return 0; if (realpath(path, safe_path) != NULL) { if (stat(safe_path, &st) != 0 || !S_ISREG(st.st_mode)) return 0; @@ -123,14 +123,12 @@ static int resolve_workspace_write_path(const char *path, char *safe_path, size_ return n > 0 && (size_t)n < cap; } -static void discard_file_tmp(int fd, char *tmp_path) +static void discard_file_tmp(int fd, const char *tmp_path) { if (fd >= 0) (void)close(fd); - if (tmp_path) { - unlink(tmp_path); - free(tmp_path); - } + if (tmp_path && tmp_path[0] != '\0') + (void)unlink(tmp_path); } static int write_all(int fd, const char *buf, size_t len) @@ -147,28 +145,35 @@ static int write_all(int fd, const char *buf, size_t len) } /* - * Temp+rename so O_TRUNC cannot wipe the live workspace file before the - * new bytes are durable (ENOSPC, EFBIG, or a non-writable parent dir). + * Unique temp+rename so O_TRUNC cannot wipe the live file, and a sibling + * named path.tmp is not used as the sidecar (ENOSPC, EFBIG, or a + * non-writable parent dir). mkstemp uses O_EXCL, so a planted symlink at + * the random name cannot be followed (the path.tmp #90 shape). */ -static int write_file_atomic(const char *path, const char *content, int ws_only) +static int write_file_atomic(const char *path, const char *content) { - size_t path_len; - char *tmp_path; - int flags = O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC; + char path_copy[PATH_MAX]; + char tmp_path[PATH_MAX]; + char *dir; int fd; + int n; if (!path || !content) return -1; - path_len = strlen(path); - tmp_path = malloc(path_len + 8); - if (!tmp_path) + if (snprintf(path_copy, sizeof(path_copy), "%s", path) >= (int)sizeof(path_copy)) + return -1; + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') + return -1; + n = snprintf(tmp_path, sizeof(tmp_path), "%s/.sc-write-XXXXXX", dir); + if (n < 0 || (size_t)n >= sizeof(tmp_path)) return -1; - snprintf(tmp_path, path_len + 8, "%s.tmp", path); - if (ws_only) - flags |= O_NOFOLLOW; - fd = open(tmp_path, flags, 0644); - if (fd < 0) { - free(tmp_path); + fd = mkstemp(tmp_path); + if (fd < 0) + return -1; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + if (fchmod(fd, 0644) != 0) { + discard_file_tmp(fd, tmp_path); return -1; } if (write_all(fd, content, strlen(content)) != 0) { @@ -187,7 +192,6 @@ static int write_file_atomic(const char *path, const char *content, int ws_only) discard_file_tmp(-1, tmp_path); return -1; } - free(tmp_path); return 0; } @@ -242,7 +246,7 @@ static int file_write(const char *path, const char *content, char *result_buf, s snprintf(result_buf, max_len, "{\"error\":\"cannot write file\"}"); return -1; } - if (write_file_atomic(safe_path, content ? content : "", ws_only) != 0) { + if (write_file_atomic(safe_path, content ? content : "") != 0) { snprintf(result_buf, max_len, "{\"error\":\"write failed\"}"); return -1; } diff --git a/tests/test_file.c b/tests/test_file.c index 2706d8c..a21bb2e 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -339,9 +339,9 @@ static void test_file_write_failure_preserves_existing(void) int write_ret; /* - * Directory without write permission: creating path.tmp fails, but - * fopen/open O_TRUNC on the existing file still succeeds. Atomic - * replace must leave the original body intact. + * Directory without write permission: creating the mkstemp sidecar + * fails, but fopen/open O_TRUNC on the existing file still succeeds. + * Atomic replace must leave the original body intact. */ snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_nowrite_%d", (int)getpid()); if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; @@ -385,6 +385,91 @@ static void test_file_write_failure_preserves_existing(void) rmdir(tmpdir); } +static void test_write_does_not_clobber_sibling_tmp(void) +{ + char tmpdir[PATH_MAX]; + char notes_path[PATH_MAX]; + char sibling_tmp[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 128]; + char buf[256]; + char kept[64]; + config_t *cfg; + const tool_t *t; + FILE *f; + + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_sibtmp_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(notes_path, sizeof(notes_path), "%s/notes.md", tmpdir); + snprintf(sibling_tmp, sizeof(sibling_tmp), "%s/notes.md.tmp", tmpdir); + f = fopen(sibling_tmp, "w"); + MU_ASSERT(f != NULL, "create sibling notes.md.tmp"); + fputs("SIBLING KEEP", f); + fclose(f); + cfg = make_ws_config(tmpdir, config_path, sizeof(config_path)); + MU_ASSERT(cfg != NULL, "sibling tmp: load config"); + tool_file_set_config(cfg); + t = tool_file_get(); + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"NEW NOTES\"}", + notes_path); + MU_ASSERT(t->execute(args, buf, sizeof(buf)) == 0, "write notes.md succeeds"); + MU_ASSERT(slurp_file(notes_path, kept, sizeof(kept)) == 0, "notes.md readable"); + MU_ASSERT(strcmp(kept, "NEW NOTES") == 0, "notes.md has new content"); + MU_ASSERT(slurp_file(sibling_tmp, kept, sizeof(kept)) == 0, "sibling tmp still readable"); + MU_ASSERT(strcmp(kept, "SIBLING KEEP") == 0, "write must not O_TRUNC notes.md.tmp"); + config_free(cfg); + unlink(config_path); + unlink(notes_path); + unlink(sibling_tmp); + rmdir(tmpdir); +} + +static void test_write_in_workspace_symlink_alias_rejected(void) +{ + char tmpdir[PATH_MAX]; + char notes_path[PATH_MAX]; + char alias_path[PATH_MAX]; + char config_path[PATH_MAX]; + char args[PATH_MAX + 128]; + char buf[256]; + char kept[64]; + config_t *cfg; + const tool_t *t; + FILE *f; + + snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_alias_%d", (int)getpid()); + if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + snprintf(notes_path, sizeof(notes_path), "%s/notes.md", tmpdir); + snprintf(alias_path, sizeof(alias_path), "%s/alias.md", tmpdir); + f = fopen(notes_path, "w"); + MU_ASSERT(f != NULL, "create notes.md"); + fputs("KEEP", f); + fclose(f); + unlink(alias_path); + if (symlink(notes_path, alias_path) != 0 && symlink("notes.md", alias_path) != 0) { + unlink(notes_path); + rmdir(tmpdir); + return; + } + cfg = make_ws_config(tmpdir, config_path, sizeof(config_path)); + MU_ASSERT(cfg != NULL, "alias: load config"); + tool_file_set_config(cfg); + t = tool_file_get(); + snprintf(args, sizeof(args), + "{\"operation\":\"write_file\",\"path\":\"%s\",\"content\":\"PWNED\"}", + alias_path); + MU_ASSERT(t->execute(args, buf, sizeof(buf)) == -1, + "write through in-workspace symlink alias is rejected"); + MU_ASSERT(slurp_file(notes_path, kept, sizeof(kept)) == 0, "notes.md still readable"); + MU_ASSERT(strcmp(kept, "KEEP") == 0, "alias write must not change notes.md"); + config_free(cfg); + unlink(config_path); + unlink(alias_path); + unlink(notes_path); + rmdir(tmpdir); +} + int main(void) { MU_RUN(test_file_read_write_list); @@ -396,6 +481,8 @@ int main(void) MU_RUN(test_write_does_not_collapse_missing_parent_onto_basename); MU_RUN(test_file_write_dangling_symlink_rejected); MU_RUN(test_file_write_failure_preserves_existing); + MU_RUN(test_write_does_not_clobber_sibling_tmp); + MU_RUN(test_write_in_workspace_symlink_alias_rejected); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } From 2c3f8f4043e9553a1441381bd12f1e9bfc054b5a Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 02:36:58 -0300 Subject: [PATCH 64/92] fix(dispatch): ack cron jobs after slash-command send /reset and /status returned after ch->send without cron_ack_delivery, so a cron reminder of those commands stayed due and re-fired every poll. Ack after a successful send on every cron path, re-take agent_lock around the SQL, and surface a failed UPDATE to the caller. Refs: #57 --- CHANGELOG.md | 1 + Makefile | 2 +- src/core/agent.h | 5 ++- src/core/dispatch.c | 40 +++++++++++++---- tests/test_dispatch.c | 101 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdaf2f1..39b1350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Cron job `schedule` and `message` are delivered as full SQLite TEXT instead of truncating to 127/511 bytes (#73). - Cron jobs are committed (delete/advance) only after successful agent delivery, so a failed `agent_run` cannot drop a reminder (#57). - Recurring cron jobs search the next run from the following minute with a 366-day window; ack fail-closes unparseable schedules to now+365d so they cannot re-fire every poll (#65). +- Cron `/reset` and `/status` jobs are acked after a successful channel send, matching the agent-run path, so they cannot stay due and re-fire every poll. - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/Makefile b/Makefile index 056d8c2..44f10b4 100644 --- a/Makefile +++ b/Makefile @@ -212,7 +212,7 @@ $(RELOAD_CHANNEL_STUB_O): tests/stubs/reload_channel_stub.c src/channels/channel $(HTTP_RELOAD_STUB_O): tests/stubs/http_reload_stub.c src/gateway/http.h src/core/config.h $(CC) $(CFLAGS) $(INC) -c -o $@ tests/stubs/http_reload_stub.c -$(DISPATCH_O): src/core/dispatch.c src/core/dispatch.h src/core/agent.h src/core/bootstrap.h src/core/memory.h src/channels/channel.h +$(DISPATCH_O): src/core/dispatch.c src/core/dispatch.h src/core/agent.h src/core/bootstrap.h src/core/memory.h src/channels/channel.h src/tools/cron.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/dispatch.c $(TOML_O): vendor/tomlc99/toml.c vendor/tomlc99/toml.h diff --git a/src/core/agent.h b/src/core/agent.h index 41d8373..c530aef 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -54,6 +54,8 @@ int agent_run(const config_t *cfg, const char *session_id, const char *user_mess * Every agent_run() caller (main-loop handle_message, inbound ASAP HTTP, * WebSocket dispatcher) must hold this mutex for the duration of agent_run(). * /reset in handle_message must also hold it around session_delete(). + * After a successful cron ch->send, handle_message re-takes it around + * cron_ack_delivery() (SQLite amalgamation is SQLITE_THREADSAFE=0). * Inbound mcp.tool_call must hold it around tool execute (see #60). * Inbound state.query must hold it around memory_get_row_counts() (see #60). * Release before channel I/O (ch->send). The mutex is not recursive. @@ -62,7 +64,8 @@ void agent_lock(void); /** * Release the global agent mutex after agent_run(), a locked session_delete(), - * inbound mcp.tool_call execute, or a locked state.query memory read. + * inbound mcp.tool_call execute, a locked state.query memory read, or a + * locked cron_ack_delivery(). */ void agent_unlock(void); diff --git a/src/core/dispatch.c b/src/core/dispatch.c index f48b4e1..44b1479 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -15,6 +15,34 @@ #define RESPONSE_BUF_SIZE (32 * 1024) +static int maybe_ack_cron(const channel_t *ch, const channel_incoming_msg_t *msg) +{ + int ack_err; + if (!ch || !ch->name || strcmp(ch->name, "cron") != 0) + return 0; + if (!msg || !msg->user_id) + return 0; + /* SQLITE_THREADSAFE=0: serialize ack SQL with other g_db writers. */ + agent_lock(); + ack_err = cron_ack_delivery(msg->user_id); + agent_unlock(); + return ack_err; +} + +static int send_then_maybe_ack_cron(const channel_t *ch, const channel_incoming_msg_t *msg, + const char *text, int agent_err) +{ + int send_err; + if (!ch || !ch->send || !msg) + return -1; + send_err = ch->send(msg->session_id, text, NULL, 0); + if (send_err != 0) + return send_err; + if (agent_err != 0) + return 0; + return maybe_ack_cron(ch, msg); +} + int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) { const char *text = msg->text ? msg->text : ""; @@ -25,13 +53,13 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) agent_lock(); session_delete(msg->session_id); agent_unlock(); - return ch->send(msg->session_id, "Session cleared.", NULL, 0); + return send_then_maybe_ack_cron(ch, msg, "Session cleared.", 0); } if (strcmp(text, "/status") == 0) { char buf[128]; snprintf(buf, sizeof(buf), "ShellClaw %s — agent ready.", SHELLCLAW_RELEASE_VERSION); - return ch->send(msg->session_id, buf, NULL, 0); + return send_then_maybe_ack_cron(ch, msg, buf, 0); } char resp_buf[RESPONSE_BUF_SIZE]; agent_tool_t flat_tools[SHELLCLAW_MAX_TOOLS]; @@ -43,11 +71,5 @@ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) agent_unlock(); if (err != 0 && resp_buf[0] == '\0') snprintf(resp_buf, sizeof(resp_buf), "Error: agent failed (code %d)", err); - { - int send_err = ch->send(msg->session_id, resp_buf, NULL, 0); - if (send_err == 0 && err == 0 && ch->name && strcmp(ch->name, "cron") == 0 && - msg->user_id) - cron_ack_delivery(msg->user_id); - return send_err; - } + return send_then_maybe_ack_cron(ch, msg, resp_buf, err); } diff --git a/tests/test_dispatch.c b/tests/test_dispatch.c index 074d78c..2478f42 100644 --- a/tests/test_dispatch.c +++ b/tests/test_dispatch.c @@ -19,6 +19,7 @@ void bootstrap_add_tool_for_test(const tool_t *tool); #include #include #include +#include #include #define ASSERT(c) \ @@ -65,6 +66,14 @@ static const channel_t mock_channel = { .cleanup = NULL, }; +static const channel_t cron_mock_channel = { + .name = "cron", + .init = NULL, + .poll = NULL, + .send = mock_send, + .cleanup = NULL, +}; + static int spy_init(const config_t *cfg) { (void)cfg; @@ -340,6 +349,96 @@ static int test_dispatch_forwards_full_hardware_tool_table(void) return 0; } +static int test_cron_slash_commands_ack_jobs(void) +{ + const char *db_path = "build/test_dispatch_cron_slash.db"; + char tmpl[] = "/tmp/shellclaw_test_dispatch_cron_slash_XXXXXX"; + channel_incoming_msg_t msg = {0}; + config_t *cfg = NULL; + cron_job_row_t row; + long long now; + int fd; + reset_send_spy(); + memory_cleanup(); + remove(db_path); + ASSERT(memory_init(db_path) == 0); + now = (long long)time(NULL); + ASSERT(cron_job_create("cron_reset", "at:9999999999", "/reset", "cli", "default", + now - 1, 1) == 0); + fd = mkstemp(tmpl); + ASSERT(fd >= 0); + close(fd); + ASSERT(write_minimal_toml(tmpl) == 0); + cfg = load_minimal_cfg(tmpl); + ASSERT(cfg != NULL); + bootstrap_set_cfg(cfg); + bootstrap_reset_tools_for_test(); + msg.session_id = "cli:default"; + msg.user_id = "cron_reset"; + msg.text = "/reset"; + ASSERT(handle_message(&cron_mock_channel, &msg) == 0); + ASSERT(strstr(g_last_text, "Session cleared") != NULL); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("cron_reset", &row) == 0); + ASSERT(cron_job_create("cron_status", "interval:3600", "/status", "cli", "default", + now - 1, 1) == 0); + msg.user_id = "cron_status"; + msg.text = "/status"; + ASSERT(handle_message(&cron_mock_channel, &msg) == 0); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("cron_status", &row) == 1); + ASSERT(row.next_run > now - 1); + cron_job_row_free(&row); + msg.user_id = "missing_cron_job"; + ASSERT(handle_message(&cron_mock_channel, &msg) != 0); + ASSERT(agent_mutex_is_locked_for_test() == 0); + config_free(cfg); + unlink(tmpl); + memory_cleanup(); + remove(db_path); + return 0; +} + +static int test_cron_agent_failure_does_not_ack(void) +{ + const char *db_path = "build/test_dispatch_cron_fail.db"; + char tmpl[] = "/tmp/shellclaw_test_dispatch_cron_fail_XXXXXX"; + channel_incoming_msg_t msg = {0}; + config_t *cfg = NULL; + cron_job_row_t row; + long long due_at; + int fd; + reset_send_spy(); + memory_cleanup(); + remove(db_path); + ASSERT(memory_init(db_path) == 0); + due_at = (long long)time(NULL) - 1; + ASSERT(cron_job_create("cron_fail", "interval:60", "hello", "cli", "default", + due_at, 1) == 0); + fd = mkstemp(tmpl); + ASSERT(fd >= 0); + close(fd); + ASSERT(write_minimal_toml(tmpl) == 0); + cfg = load_minimal_cfg(tmpl); + ASSERT(cfg != NULL); + bootstrap_set_cfg(cfg); + bootstrap_set_provider_for_test(&fail_provider); + bootstrap_reset_tools_for_test(); + msg.session_id = "cli:default"; + msg.user_id = "cron_fail"; + msg.text = "hello"; + ASSERT(handle_message(&cron_mock_channel, &msg) == 0); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("cron_fail", &row) == 1); + ASSERT(row.next_run == due_at); + cron_job_row_free(&row); + config_free(cfg); + unlink(tmpl); + memory_cleanup(); + remove(db_path); + return 0; +} + static int test_handle_message_holds_agent_mutex(void) { channel_incoming_msg_t msg = {0}; @@ -377,6 +476,8 @@ int main(void) RUN(test_normal_message_uses_provider()); RUN(test_dispatch_forwards_full_hardware_tool_table()); RUN(test_handle_message_holds_agent_mutex()); + RUN(test_cron_slash_commands_ack_jobs()); + RUN(test_cron_agent_failure_does_not_ack()); puts("test_dispatch OK"); return 0; } From a9937e54b4e310cb16ab0947593293f908cece3c Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 02:37:11 -0300 Subject: [PATCH 65/92] fix(cron): wait before re-offer and cap TEXT cron_poll must not mutate the DB, so a still-due job is retried in process: honor timeout_ms before offering the same id again. Reject schedule/message TEXT above 32 KiB at create and at read instead of clipping, and drive poll-ack-poll for a >127-byte interval schedule. Refs: #73 --- CHANGELOG.md | 2 ++ src/core/memory.c | 8 ++++- src/core/memory.h | 3 ++ src/tools/cron.c | 43 +++++++++++++++++++++++--- tests/test_cron.c | 78 ++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 124 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b1350..331ff2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Cron jobs are committed (delete/advance) only after successful agent delivery, so a failed `agent_run` cannot drop a reminder (#57). - Recurring cron jobs search the next run from the following minute with a 366-day window; ack fail-closes unparseable schedules to now+365d so they cannot re-fire every poll (#65). - Cron `/reset` and `/status` jobs are acked after a successful channel send, matching the agent-run path, so they cannot stay due and re-fire every poll. +- `cron_poll` honors `timeout_ms` before re-offering the same still-due job (process-local; the DB is not mutated) so a failed delivery cannot busy-spin the daemon. +- Cron `schedule` and `message` TEXT is rejected above 32 KiB at create and at read, instead of unbounded `strdup`. - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. diff --git a/src/core/memory.c b/src/core/memory.c index 06897da..6757d57 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -360,7 +360,11 @@ static void copy_str_bounded(char *dst, size_t dst_size, const char *src) static char *dup_sqlite_text(sqlite3_stmt *stmt, int col) { - const unsigned char *p = sqlite3_column_text(stmt, col); + const unsigned char *p; + int nbytes = sqlite3_column_bytes(stmt, col); + if (nbytes > CRON_JOB_TEXT_MAX) + return NULL; + p = sqlite3_column_text(stmt, col); return strdup(p ? (const char *)p : ""); } @@ -394,6 +398,8 @@ int cron_job_create(const char *id, const char *schedule, const char *message, const char *channel, const char *recipient, long long next_run, int enabled) { if (!g_db || !id || !schedule || !message) return -1; + if (strlen(schedule) > (size_t)CRON_JOB_TEXT_MAX) return -1; + if (strlen(message) > (size_t)CRON_JOB_TEXT_MAX) return -1; const char *ch = channel ? channel : ""; const char *rec = recipient ? recipient : ""; const char *sql = "INSERT INTO cron_jobs(id, schedule, message, channel, recipient, next_run, enabled) " diff --git a/src/core/memory.h b/src/core/memory.h index 8ae7869..676a547 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -120,6 +120,9 @@ int config_kv_get(const char *key, char *value_out, size_t max_len); */ int config_kv_set(const char *key, const char *value); +/** Max bytes accepted for cron schedule/message TEXT (create and read). */ +#define CRON_JOB_TEXT_MAX (32 * 1024) + /** * Row from cron_jobs table for list/get operations. * diff --git a/src/tools/cron.c b/src/tools/cron.c index 585a0c9..6114621 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -178,16 +178,50 @@ static int cron_init(const config_t *cfg) return 0; } +static char s_offered_id[128]; +static struct timespec s_offered_mono; + +static void cron_wait_if_reoffer(const char *job_id, int timeout_ms) +{ + struct timespec now; + struct timespec remain; + long elapsed_ms; + long wait_ms; + if (timeout_ms <= 0 || !job_id || job_id[0] == '\0') + return; + if (s_offered_id[0] == '\0' || strcmp(s_offered_id, job_id) != 0) + return; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) + return; + elapsed_ms = (now.tv_sec - s_offered_mono.tv_sec) * 1000L + + (now.tv_nsec - s_offered_mono.tv_nsec) / 1000000L; + if (elapsed_ms >= timeout_ms) + return; + wait_ms = timeout_ms - elapsed_ms; + remain.tv_sec = wait_ms / 1000; + remain.tv_nsec = (wait_ms % 1000) * 1000000L; + nanosleep(&remain, NULL); +} + +static void cron_mark_offered(const char *job_id) +{ + if (!job_id) + return; + snprintf(s_offered_id, sizeof(s_offered_id), "%s", job_id); + clock_gettime(CLOCK_MONOTONIC, &s_offered_mono); +} + static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) { - if (!out) return -1; - (void)timeout_ms; - long long now = (long long)time(NULL); cron_job_row_t row; + char session_id[256]; + long long now; + if (!out) return -1; + now = (long long)time(NULL); memset(&row, 0, sizeof(row)); if (cron_job_get_next_due(now, &row) != 1) return 0; + cron_wait_if_reoffer(row.id, timeout_ms); memset(out, 0, sizeof(*out)); - char session_id[256]; snprintf(session_id, sizeof(session_id), "%s:%s", row.channel[0] ? row.channel : "cli", row.recipient[0] ? row.recipient : "default"); @@ -196,6 +230,7 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) out->text = strdup(row.message ? row.message : ""); out->attachments = NULL; out->attachments_count = 0; + cron_mark_offered(row.id); cron_job_row_free(&row); if (!out->session_id || !out->user_id || !out->text) { channel_incoming_msg_clear(out); diff --git a/tests/test_cron.c b/tests/test_cron.c index 5856182..d213768 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -236,25 +236,44 @@ static int test_due_job_delivers_full_message(void) static int test_long_interval_schedule_roundtrips(void) { const char *path = "/tmp/shellclaw_test_cron_long_schedule.db"; + const channel_t *ch; + channel_incoming_msg_t msg; + cron_job_row_t due; + cron_job_row_t after; + char zeros[121]; + char schedule[160]; + long long parsed = 0; + long long now; remove(path); ASSERT(memory_init(path) == 0); - char zeros[121]; memset(zeros, '0', 120); zeros[120] = '\0'; - char schedule[160]; snprintf(schedule, sizeof(schedule), "interval:%s60", zeros); ASSERT(strlen(schedule) > 127); - long long parsed = 0; - long long now = (long long)time(NULL); + now = (long long)time(NULL); ASSERT(cron_parse_next_run(schedule, now, &parsed) == 0); ASSERT(parsed == now + 60); ASSERT(cron_job_create("longsched", schedule, "tick", "cli", "default", now - 1, 1) == 0); - cron_job_row_t due; memset(&due, 0, sizeof(due)); ASSERT(cron_job_get_next_due(now, &due) == 1); ASSERT(due.schedule != NULL); ASSERT(strcmp(due.schedule, schedule) == 0); cron_job_row_free(&due); + ch = channel_cron_get(); + memset(&msg, 0, sizeof(msg)); + ASSERT(ch->poll(&msg, 0) == 1); + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "longsched") == 0); + ASSERT(cron_ack_delivery(msg.user_id) == 0); + channel_incoming_msg_clear(&msg); + memset(&after, 0, sizeof(after)); + ASSERT(cron_job_get_by_id("longsched", &after) == 1); + ASSERT(after.next_run > now); + ASSERT(after.schedule != NULL); + ASSERT(strcmp(after.schedule, schedule) == 0); + cron_job_row_free(&after); + memset(&msg, 0, sizeof(msg)); + ASSERT(ch->poll(&msg, 0) == 0); memory_cleanup(); remove(path); return 0; @@ -383,6 +402,53 @@ static int test_cron_ack_fail_closed_on_parse_error(void) return 0; } +static int test_cron_poll_waits_before_reoffer(void) +{ + const char *path = "/tmp/shellclaw_test_cron_reoffer.db"; + const channel_t *cron_ch; + channel_incoming_msg_t msg; + struct timespec t0; + struct timespec t1; + long elapsed_ms; + long long now; + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + ASSERT(cron_job_create("reoffer", "interval:60", "again", "cli", "default", now - 1, 1) == 0); + cron_ch = channel_cron_get(); + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 0) == 1); + channel_incoming_msg_clear(&msg); + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 80) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + ASSERT(elapsed_ms >= 40); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_cron_job_rejects_oversized_text(void) +{ + const char *path = "/tmp/shellclaw_test_cron_text_cap.db"; + char *too_big; + remove(path); + ASSERT(memory_init(path) == 0); + too_big = malloc((size_t)CRON_JOB_TEXT_MAX + 2); + ASSERT(too_big != NULL); + memset(too_big, 'A', (size_t)CRON_JOB_TEXT_MAX + 1); + too_big[CRON_JOB_TEXT_MAX + 1] = '\0'; + ASSERT(cron_job_create("bigmsg", "interval:60", too_big, "cli", "default", 1, 1) != 0); + ASSERT(cron_job_create("bigsched", too_big, "tick", "cli", "default", 1, 1) != 0); + free(too_big); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -401,6 +467,8 @@ int main(void) RUN(test_cron_poll_keeps_job_until_ack()); RUN(test_cron_ack_advances_recurring_past_due_minute()); RUN(test_cron_ack_fail_closed_on_parse_error()); + RUN(test_cron_poll_waits_before_reoffer()); + RUN(test_cron_job_rejects_oversized_text()); printf("test_cron: all tests passed\n"); return 0; } From a6da4986af74b3ea1d8f0306fbf7469e7072682e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 12:33:35 -0300 Subject: [PATCH 66/92] fix(asap): free inbound response payload once on envelope OOM Transfer payload ownership to the envelope so a failed strdup cleanup does not cJSON_Delete the same object twice. --- CHANGELOG.md | 1 + src/asap/server.c | 6 ++++-- tests/test_asap_server.c | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2e71b..3bcd0df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Gateway `/health` `version` matches `SHELLCLAW_RELEASE_VERSION`. ### Security +- Inbound ASAP response builder no longer double-frees the payload cJSON when a required envelope field cannot be allocated (unauthenticated `POST /asap` `state.query` / `task.cancel`). - Gateway shutdown joins the HTTP thread before `auth_cleanup`, so in-flight `/api/*`, `/pair`, and WebSocket upgrades cannot call `auth_validate_token` / `auth_pair` on a freed `auth_ctx`. - Gateway listen bind now uses `gateway.host` (`lws` `info.iface`). `host = "127.0.0.1"` is loopback-only. Bind-all forms (`0.0.0.0`, `*`, `::`, `[::]`, empty) require `allow_bind_all`. - Camera auto-output keeps the exclusive `mkstemp` inode (no unlink + `${tmpl}.jpg` sibling). diff --git a/src/asap/server.c b/src/asap/server.c index b62cf0d..3aea332 100644 --- a/src/asap/server.c +++ b/src/asap/server.c @@ -84,6 +84,7 @@ static int fill_response_envelope(asap_envelope_t *out, const asap_envelope_t *i const char *payload_type, cJSON *payload) { char ulid_buf[ULID_STRING_LEN + 1]; + cJSON *owned_payload; if (!out || !in || !payload_type || !payload) { if (payload) cJSON_Delete(payload); return -32603; @@ -99,13 +100,14 @@ static int fill_response_envelope(asap_envelope_t *out, const asap_envelope_t *i out->sender = in->recipient ? strdup(in->recipient) : NULL; out->recipient = in->sender ? strdup(in->sender) : NULL; out->payload_type = strdup(payload_type); - out->payload = payload; + /* Ownership of payload moves to out; asap_envelope_clear frees it once. */ + owned_payload = payload; + out->payload = owned_payload; if (in->correlation_id) out->correlation_id = strdup(in->correlation_id); if (in->trace_id) out->trace_id = strdup(in->trace_id); if (!out->id || !out->asap_version || !out->sender || !out->recipient || !out->payload_type) { - cJSON_Delete(payload); asap_envelope_clear(out); asap_envelope_init(out); return -32603; diff --git a/tests/test_asap_server.c b/tests/test_asap_server.c index 2408c58..7d10490 100644 --- a/tests/test_asap_server.c +++ b/tests/test_asap_server.c @@ -775,6 +775,38 @@ static int test_tool_execute_nonzero_reports_error(void) return 0; } +/* + * fill_response_envelope used to cJSON_Delete(payload) after assigning + * out->payload, then asap_envelope_clear(out) deleted the same object. + * HTTP parse always supplies sender/recipient, so production hits this on + * post-attach strdup OOM; dropping sender here is the same cleanup path. + */ +static int test_response_builder_missing_sender_does_not_double_free(void) +{ + asap_envelope_t in; + asap_envelope_t out; + asap_server_ctx_t ctx; + char err[128]; + cJSON *pl; + int rc; + + pl = cJSON_CreateObject(); + ASSERT(pl != NULL); + ASSERT(cJSON_AddNullToObject(pl, "task_id") != NULL); + ASSERT(wrap_build(&in, "task.cancel", pl) == 0); + free(in.sender); + in.sender = NULL; + memset(&ctx, 0, sizeof ctx); + asap_envelope_init(&out); + rc = asap_server_handle(&in, &out, &ctx, err, sizeof err); + ASSERT(rc == -32603); + ASSERT(strstr(err, "envelope") != NULL); + ASSERT(out.payload == NULL); + teardown_env(&in); + teardown_env(&out); + return 0; +} + static int submit_task_request(asap_server_ctx_t *ctx, const char *sender, const char *input) { asap_envelope_t in; @@ -1055,6 +1087,7 @@ int main(void) r |= test_mcp_omitted_arguments_defaults_to_empty_object(); r |= test_tool_call_hook_overrides_builtin_dispatch(); r |= test_tool_execute_nonzero_reports_error(); + r |= test_response_builder_missing_sender_does_not_double_free(); r |= test_trust_sender_rejects_blank_sender_when_list_nonempty(); r |= test_mcp_tool_call_holds_agent_mutex(); r |= test_mcp_tool_call_hook_holds_agent_mutex(); From c99e6af63fb6370b6a702cf0e4b6a11b704b11fb Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 12:33:29 -0300 Subject: [PATCH 67/92] fix(gateway): persist pairing tokens with atomic replace Write auth_tokens.json via unique temp+fsync+rename so O_TRUNC cannot wipe existing pairing tokens on ENOSPC or crash. Fail closed when bearer RNG fails so pairing does not persist an uninitialized token. Refs: #71, #92 --- CHANGELOG.md | 2 + src/gateway/auth.c | 86 ++++++++++++++++++++++++++++++----- tests/test_auth.c | 111 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2e71b..61c63c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - Gateway `/health` `version` matches `SHELLCLAW_RELEASE_VERSION`. ### Security +- `auth_pair` persists tokens via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe `auth_tokens.json` (#71). +- `auth_pair` fails closed when bearer RNG fails (no uninitialized token, no tokens-file write, pairing code kept) (#92). - Gateway shutdown joins the HTTP thread before `auth_cleanup`, so in-flight `/api/*`, `/pair`, and WebSocket upgrades cannot call `auth_validate_token` / `auth_pair` on a freed `auth_ctx`. - Gateway listen bind now uses `gateway.host` (`lws` `info.iface`). `host = "127.0.0.1"` is loopback-only. Bind-all forms (`0.0.0.0`, `*`, `::`, `[::]`, empty) require `allow_bind_all`. - Camera auto-output keeps the exclusive `mkstemp` inode (no unlink + `${tmpl}.jpg` sibling). diff --git a/src/gateway/auth.c b/src/gateway/auth.c index 578066e..cc62ee8 100644 --- a/src/gateway/auth.c +++ b/src/gateway/auth.c @@ -2,6 +2,9 @@ * @file auth.c * @brief Pairing code generation, bearer token store, and /pair brute-force lockout. */ +#if defined(__APPLE__) +#define _DARWIN_C_SOURCE +#endif #define _POSIX_C_SOURCE 200809L #include "gateway/auth.h" @@ -10,6 +13,7 @@ #include "cJSON.h" #include #include +#include #include #include #include @@ -166,6 +170,73 @@ static int ensure_tokens_dir(const char *path) return 0; } +static void discard_tokens_tmp(int fd, const char *tmp_path) +{ + if (fd >= 0) + (void)close(fd); + if (tmp_path && tmp_path[0] != '\0') + (void)unlink(tmp_path); +} + +static int auth_write_all(int fd, const char *buf, size_t len) +{ + size_t off = 0; + + while (off < len) { + ssize_t n = write(fd, buf + off, len - off); + if (n <= 0) + return -1; + off += (size_t)n; + } + return 0; +} + +/* + * Unique temp+rename so O_TRUNC cannot wipe auth_tokens.json before the new + * JSON is fully on disk (ENOSPC / crash / fdopen failure). mkstemp uses O_EXCL + * so a planted path.tmp symlink is not followed (the #90 shape). + */ +static int write_tokens_atomic(const char *path, const char *json) +{ + char path_copy[PATH_MAX]; + char tmp_path[PATH_MAX]; + char *dir; + int fd; + int n; + + if (!path || !json) + return -1; + if (snprintf(path_copy, sizeof(path_copy), "%s", path) >= (int)sizeof(path_copy)) + return -1; + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') + return -1; + n = snprintf(tmp_path, sizeof(tmp_path), "%s/.sc-auth-XXXXXX", dir); + if (n < 0 || (size_t)n >= sizeof(tmp_path)) + return -1; + fd = mkstemp(tmp_path); + if (fd < 0) + return -1; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + if (auth_write_all(fd, json, strlen(json)) != 0) { + discard_tokens_tmp(fd, tmp_path); + return -1; + } + if (fsync(fd) != 0) { + discard_tokens_tmp(fd, tmp_path); + return -1; + } + if (close(fd) != 0) { + discard_tokens_tmp(-1, tmp_path); + return -1; + } + if (rename(tmp_path, path) != 0) { + discard_tokens_tmp(-1, tmp_path); + return -1; + } + return 0; +} + int auth_pair(auth_ctx_t *ctx, const char *code, char *token_out, size_t token_size) { if (!ctx || !ctx->tokens_path || !code || !token_out || token_size == 0) return -1; @@ -174,7 +245,9 @@ int auth_pair(auth_ctx_t *ctx, const char *code, char *token_out, size_t token_s !constant_time_cmp(code, ctx->pending_pairing_code, PAIRING_CODE_LEN)) return -1; char new_token[TOKEN_LEN + 1]; - generate_random_hex(new_token, TOKEN_LEN); + /* Fail closed: never persist or return an uninitialized bearer on RNG/OOM. */ + if (generate_random_hex(new_token, TOKEN_LEN) != 0) + return -1; /* Read existing tokens and append (multi-device support). */ cJSON *arr = NULL; { @@ -204,19 +277,10 @@ int auth_pair(auth_ctx_t *ctx, const char *code, char *token_out, size_t token_s free(json); return -1; } - int fd = open(ctx->tokens_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); - if (fd < 0) { - free(json); - return -1; - } - FILE *out = fdopen(fd, "w"); - if (!out) { - close(fd); + if (write_tokens_atomic(ctx->tokens_path, json) != 0) { free(json); return -1; } - fprintf(out, "%s", json); - fclose(out); free(json); size_t copy_len = (size_t)TOKEN_LEN < token_size - 1 ? (size_t)TOKEN_LEN : token_size - 1; memcpy(token_out, new_token, copy_len); diff --git a/tests/test_auth.c b/tests/test_auth.c index aa7f48d..85001cb 100644 --- a/tests/test_auth.c +++ b/tests/test_auth.c @@ -2,13 +2,20 @@ * @file test_auth.c * @brief Unit tests for auth module: pairing code, token validation. */ +#if defined(__APPLE__) +#define _DARWIN_C_SOURCE +#endif #define _POSIX_C_SOURCE 200809L #include "gateway/auth.h" +#include "crypto/crypto.h" #include "cJSON.h" +#include #include #include #include +#include +#include #include #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) @@ -176,6 +183,62 @@ static int test_auth_validate_token(void) return 0; } +static int test_auth_pair_write_failure_preserves_existing_tokens(void) +{ + char dir_template[] = "/tmp/shellclaw_auth_atomic_XXXXXX"; + char path[512]; + char token[64]; + char *dir; + char *code; + auth_ctx_t *ctx; + FILE *tokens_file; + struct rlimit old_lim; + struct rlimit new_lim; + int pair_ret; + const char *existing = "existingtokenexistingtokenexist01"; + + dir = mkdtemp(dir_template); + ASSERT(dir != NULL); + snprintf(path, sizeof(path), "%s/auth_tokens.json", dir); + + ctx = auth_init(path); + ASSERT(ctx != NULL); + code = auth_get_or_create_pairing_code(ctx); + ASSERT(code != NULL); + + /* Pairing code is in memory; tokens file already has a device (append window). */ + tokens_file = fopen(path, "w"); + ASSERT(tokens_file != NULL); + ASSERT(fprintf(tokens_file, "[\"%s\"]", existing) > 0); + ASSERT(fclose(tokens_file) == 0); + ASSERT(auth_validate_token(ctx, existing) == 1); + + ASSERT(getrlimit(RLIMIT_FSIZE, &old_lim) == 0); + new_lim = old_lim; + new_lim.rlim_cur = 8; + (void)signal(SIGXFSZ, SIG_IGN); + ASSERT(setrlimit(RLIMIT_FSIZE, &new_lim) == 0); + + memset(token, 0, sizeof(token)); + pair_ret = auth_pair(ctx, code, token, sizeof(token)); + ASSERT(setrlimit(RLIMIT_FSIZE, &old_lim) == 0); + + ASSERT(pair_ret != 0); + ASSERT(token[0] == '\0'); + ASSERT(auth_validate_token(ctx, existing) == 1); + + memset(token, 0, sizeof(token)); + ASSERT(auth_pair(ctx, code, token, sizeof(token)) == 0); + ASSERT(auth_validate_token(ctx, existing) == 1); + ASSERT(auth_validate_token(ctx, token) == 1); + + free(code); + auth_cleanup(ctx); + unlink(path); + ASSERT(rmdir(dir) == 0); + return 0; +} + static int test_auth_pairing_code_single_use(void) { const char *path = "/tmp/shellclaw_test_tokens_singleuse.json"; @@ -302,6 +365,49 @@ static int test_pair_lockout_null_ip(void) return 0; } +/** + * auth_pair must fail closed when bearer RNG fails: do not return success, + * do not write auth_tokens.json, and keep the pending pairing code usable. + */ +static int test_auth_pair_fails_closed_on_urandom_failure(void) +{ + const char *path = "/tmp/shellclaw_test_tokens_urandom_fail.json"; + auth_ctx_t *ctx; + char *code; + char token[64]; + struct stat st; + int pair_ret; + int paired_after; + + unlink(path); + ctx = auth_init(path); + ASSERT(ctx != NULL); + code = auth_get_or_create_pairing_code(ctx); + ASSERT(code != NULL); + + memset(token, 0x41, sizeof(token)); + token[sizeof(token) - 1] = '\0'; + crypto_test_force_urandom_fail(1); + pair_ret = auth_pair(ctx, code, token, sizeof(token)); + crypto_test_clear_force_urandom_fail(); + ASSERT(pair_ret != 0); + + /* Must not leave a success-looking empty/garbage bearer or tokens file. */ + ASSERT(token[0] == 'A'); + ASSERT(stat(path, &st) != 0); + + memset(token, 0, sizeof(token)); + paired_after = auth_pair(ctx, code, token, sizeof(token)); + ASSERT(paired_after == 0); + ASSERT(strlen(token) == TEST_TOKEN_HEX_LEN); + ASSERT(auth_validate_token(ctx, token) == 1); + + free(code); + auth_cleanup(ctx); + unlink(path); + return 0; +} + static int test_auth_pair_rejects_malformed_code(void) { const char *path = "/tmp/shellclaw_test_tokens_malformed.json"; @@ -463,12 +569,17 @@ int main(void) if (test_auth_pairing_code_single_use() != 0) { fprintf(stderr, "test_auth_pairing_code_single_use failed\n"); failed++; } if (test_auth_pair_rejects_without_pending_code() != 0) { fprintf(stderr, "test_auth_pair_rejects_without_pending_code failed\n"); failed++; } if (test_auth_validate_token() != 0) { fprintf(stderr, "test_auth_validate_token failed\n"); failed++; } + if (test_auth_pair_write_failure_preserves_existing_tokens() != 0) { + fprintf(stderr, "test_auth_pair_write_failure_preserves_existing_tokens failed\n"); + failed++; + } if (test_auth_multi_token() != 0) { fprintf(stderr, "test_auth_multi_token failed\n"); failed++; } if (test_pair_lockout_triggers_after_max_fails() != 0) { fprintf(stderr, "test_pair_lockout_triggers_after_max_fails failed\n"); failed++; } if (test_pair_lockout_expires() != 0) { fprintf(stderr, "test_pair_lockout_expires failed\n"); failed++; } if (test_pair_lockout_clear_on_success() != 0) { fprintf(stderr, "test_pair_lockout_clear_on_success failed\n"); failed++; } if (test_pair_lockout_independent_ips() != 0) { fprintf(stderr, "test_pair_lockout_independent_ips failed\n"); failed++; } if (test_pair_lockout_null_ip() != 0) { fprintf(stderr, "test_pair_lockout_null_ip failed\n"); failed++; } + if (test_auth_pair_fails_closed_on_urandom_failure() != 0) { fprintf(stderr, "test_auth_pair_fails_closed_on_urandom_failure failed\n"); failed++; } if (test_auth_pair_rejects_malformed_code() != 0) { fprintf(stderr, "test_auth_pair_rejects_malformed_code failed\n"); failed++; } if (test_auth_pair_evicts_oldest_at_cap() != 0) { fprintf(stderr, "test_auth_pair_evicts_oldest_at_cap failed\n"); failed++; } if (test_auth_validate_token_rejects_length_mismatch() != 0) { fprintf(stderr, "test_auth_validate_token_rejects_length_mismatch failed\n"); failed++; } From 0fe4653183255e6ebea3bc5c7a9f81dadcd5bfc5 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 12:33:29 -0300 Subject: [PATCH 68/92] fix(skills): persist skill files with atomic replace Write skill markdown via unique temp+fsync+rename so fopen("w") cannot wipe an existing skill on ENOSPC or crash. Refs: #77 --- CHANGELOG.md | 1 + src/core/skill.c | 85 +++++++++++++++++++++++++++++++++++++++------- tests/test_skill.c | 58 ++++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c63c6..dec26be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Leaf workspace symlinks (dangling or an in-workspace alias) are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). - `write_file` persists via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe an existing workspace file and a sibling `path.tmp` is not truncated (#78). +- Skill create/update persist via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe an existing skill file (#77). - Camera capture fails closed when `workspace_only` is on with an empty `workspace_path`, and rejects leaf symlink outputs (#91, #90). - Cron job `schedule` and `message` are delivered as full SQLite TEXT instead of truncating to 127/511 bytes (#73). - Cron jobs are committed (delete/advance) only after successful agent delivery, so a failed `agent_run` cannot drop a reminder (#57). diff --git a/src/core/skill.c b/src/core/skill.c index 2d9b976..7401b9a 100644 --- a/src/core/skill.c +++ b/src/core/skill.c @@ -3,6 +3,9 @@ * @brief Skill loader: scan skills directory for .md files and concatenate contents. * Hot-reload via inotify (Linux) or kqueue (macOS). */ +#if defined(__APPLE__) +#define _DARWIN_C_SOURCE +#endif #define _POSIX_C_SOURCE 200809L #include "config.h" @@ -10,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -372,6 +376,73 @@ int skill_get_description(const config_t *cfg, const char *name, char *out_buf, return 0; } +static void discard_skill_tmp(int fd, const char *tmp_path) +{ + if (fd >= 0) + (void)close(fd); + if (tmp_path && tmp_path[0] != '\0') + (void)unlink(tmp_path); +} + +static int skill_write_all(int fd, const char *buf, size_t len) +{ + size_t off = 0; + + while (off < len) { + ssize_t n = write(fd, buf + off, len - off); + if (n <= 0) + return -1; + off += (size_t)n; + } + return 0; +} + +/* + * Unique temp+rename so fopen("w") cannot wipe an existing skill before the + * new content is fully on disk (ENOSPC / EFBIG / crash). mkstemp uses O_EXCL + * so a planted path.tmp symlink is not followed (the #90 shape). + */ +static int write_skill_atomic(const char *path, const char *content) +{ + char path_copy[PATH_MAX]; + char tmp_path[PATH_MAX]; + char *dir; + int fd; + int n; + + if (!path || !content) + return -1; + if (snprintf(path_copy, sizeof(path_copy), "%s", path) >= (int)sizeof(path_copy)) + return -1; + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') + return -1; + n = snprintf(tmp_path, sizeof(tmp_path), "%s/.sc-skill-XXXXXX", dir); + if (n < 0 || (size_t)n >= sizeof(tmp_path)) + return -1; + fd = mkstemp(tmp_path); + if (fd < 0) + return -1; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + if (skill_write_all(fd, content, strlen(content)) != 0) { + discard_skill_tmp(fd, tmp_path); + return -1; + } + if (fsync(fd) != 0) { + discard_skill_tmp(fd, tmp_path); + return -1; + } + if (close(fd) != 0) { + discard_skill_tmp(-1, tmp_path); + return -1; + } + if (rename(tmp_path, path) != 0) { + discard_skill_tmp(-1, tmp_path); + return -1; + } + return 0; +} + int skill_create(const config_t *cfg, const char *name, const char *content) { if (!cfg || !name || !content) return -1; @@ -382,12 +453,7 @@ int skill_create(const config_t *cfg, const char *name, const char *content) fclose(f); return -1; } - f = fopen(path, "w"); - if (!f) return -1; - size_t len = strlen(content); - size_t written = fwrite(content, 1, len, f); - fclose(f); - return (written == len) ? 0 : -1; + return write_skill_atomic(path, content); } int skill_update(const config_t *cfg, const char *name, const char *content) @@ -395,12 +461,7 @@ int skill_update(const config_t *cfg, const char *name, const char *content) if (!cfg || !name || !content) return -1; char path[MAX_PATH_LEN]; if (build_skill_path(cfg, name, path, sizeof(path)) != 0) return -1; - FILE *f = fopen(path, "w"); - if (!f) return -1; - size_t len = strlen(content); - size_t written = fwrite(content, 1, len, f); - fclose(f); - return (written == len) ? 0 : -1; + return write_skill_atomic(path, content); } int skill_delete(const config_t *cfg, const char *name) diff --git a/tests/test_skill.c b/tests/test_skill.c index 7fde121..dd49bf3 100644 --- a/tests/test_skill.c +++ b/tests/test_skill.c @@ -5,10 +5,13 @@ #include "core/config.h" #include "core/skill.h" +#include +#include #include #include #include -#include +#include +#include #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) #define RUN(t) do { int r = (t); if (r) return r; } while (0) @@ -194,6 +197,58 @@ static int test_skill_crud(void) return 0; } +/* + * skill_update used fopen("w") which truncates before write. A failed write + * (ENOSPC/EFBIG) wiped the live skill. Atomic temp+rename must preserve it. + */ +static int test_skill_update_write_failure_preserves_existing(void) +{ + char cmd[256]; + char content[256]; + char oversized[512]; + struct rlimit old_lim; + struct rlimit new_lim; + int update_ret; + size_t i; + config_t *cfg = NULL; + char errbuf[256]; + + snprintf(cmd, sizeof(cmd), "rm -rf \"%s\" && mkdir -p \"%s\"", TMP_DIR, TMP_DIR); + ASSERT(system(cmd) == 0); + ASSERT(write_minimal_config(TMP_DIR) == 0); + ASSERT(config_load(TMP_CONFIG, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(skill_create(cfg, "keepme", "# Keep\nOriginal skill body that must survive") == 0); + ASSERT(skill_get_content(cfg, "keepme", content, sizeof(content)) == 0); + ASSERT(strstr(content, "Original skill body that must survive") != NULL); + + for (i = 0; i < sizeof(oversized) - 1; i++) + oversized[i] = 'A'; + oversized[sizeof(oversized) - 1] = '\0'; + + ASSERT(getrlimit(RLIMIT_FSIZE, &old_lim) == 0); + new_lim = old_lim; + new_lim.rlim_cur = 8; + (void)signal(SIGXFSZ, SIG_IGN); + ASSERT(setrlimit(RLIMIT_FSIZE, &new_lim) == 0); + + update_ret = skill_update(cfg, "keepme", oversized); + ASSERT(setrlimit(RLIMIT_FSIZE, &old_lim) == 0); + + ASSERT(update_ret != 0); + ASSERT(skill_get_content(cfg, "keepme", content, sizeof(content)) == 0); + ASSERT(strstr(content, "Original skill body that must survive") != NULL); + + ASSERT(skill_update(cfg, "keepme", "# Keep\nRecovered after limit") == 0); + ASSERT(skill_get_content(cfg, "keepme", content, sizeof(content)) == 0); + ASSERT(strstr(content, "Recovered after limit") != NULL); + + config_free(cfg); + remove(TMP_CONFIG); + snprintf(cmd, sizeof(cmd), "rm -rf \"%s\"", TMP_DIR); + (void)system(cmd); + return 0; +} + static int test_hot_reload_watch(void) { char cmd[256]; @@ -231,6 +286,7 @@ int main(void) RUN(test_missing_dir_no_crash()); RUN(test_system_prompt_base_order()); RUN(test_skill_crud()); + RUN(test_skill_update_write_failure_preserves_existing()); RUN(test_hot_reload_watch()); printf("test_skill: all tests passed\n"); return 0; From b5d543cdb5d48475ead130de44a24661792cdcf7 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 12:49:46 -0300 Subject: [PATCH 69/92] fix(tools): stop unsandboxed shell hang after output cap When sandbox is off, filling the result buffer left the drain loop and blocked forever in waitpid, freezing the agent. Truncated copies also skipped the NUL terminator. Reap leftover children like sandbox_exec, kill the process group, and always NUL-terminate the capture buffer. Refs: #69 --- src/tools/shell.c | 67 +++++++++++++++++++++++++++++++++++++--------- tests/test_shell.c | 42 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/tools/shell.c b/src/tools/shell.c index 89f125d..46aac41 100644 --- a/src/tools/shell.c +++ b/src/tools/shell.c @@ -19,12 +19,14 @@ #include "sandbox/sandbox.h" #include "sandbox/allowlist.h" #include "cJSON.h" +#include #include #include #include #include #include #include +#include #include #define DEFAULT_TIMEOUT_SEC 60 @@ -56,6 +58,47 @@ static int fallback_is_blocked(const char *cmd) static const config_t *g_shell_cfg; +static void kill_command_tree(pid_t pid) +{ + if (kill(-pid, SIGKILL) != 0) + (void)kill(pid, SIGKILL); +} + +/* Kill leftover children after drain (timeout or output cap), matching sandbox_exec. */ +static int reap_running_child(pid_t pid) +{ + int status = 0; + int wr = waitpid(pid, &status, WNOHANG); + int retries; + struct timespec ts; + if (wr != 0) + return 0; + kill_command_tree(pid); + for (retries = 0; retries < 40; retries++) { + wr = waitpid(pid, &status, WNOHANG); + if (wr != 0) + return 1; + ts.tv_sec = 0; + ts.tv_nsec = 50 * 1000 * 1000; + (void)nanosleep(&ts, NULL); + } + (void)waitpid(pid, &status, 0); + return 1; +} + +static void append_unsandboxed_output(char *result_buf, size_t max_len, size_t *total, + const char *chunk, size_t n) +{ + size_t add = n; + if (*total + add >= max_len - 1) + add = max_len - 1 - *total; + if (add == 0) + return; + memcpy(result_buf + *total, chunk, add); + *total += add; + result_buf[*total] = '\0'; +} + /* ------------------------------------------------------------------ */ /* Unsandboxed execution (fork + poll + waitpid) */ /* ------------------------------------------------------------------ */ @@ -68,7 +111,6 @@ static int run_unsandboxed(const char *command, int timeout_sec, size_t total = 0; int timed_out = 0; int elapsed_ms = 0; - int status; char buf[256]; if (pipe(pipefd) != 0) { snprintf(result_buf, max_len, "{\"error\":\"pipe failed\"}"); @@ -86,9 +128,11 @@ static int run_unsandboxed(const char *command, int timeout_sec, dup2(pipefd[1], STDOUT_FILENO); dup2(pipefd[1], STDERR_FILENO); close(pipefd[1]); + (void)setpgid(0, 0); execl("/bin/sh", "sh", "-c", command, (char *)NULL); _exit(127); } + (void)setpgid(pid, pid); close(pipefd[1]); result_buf[0] = '\0'; while (total < max_len - 1 && elapsed_ms < timeout_sec * 1000) { @@ -102,28 +146,27 @@ static int run_unsandboxed(const char *command, int timeout_sec, rem = timeout_sec * 1000 - elapsed_ms; if (rem > 5000) rem = 5000; r = poll(&pfd, 1, rem); - if (r < 0) break; + if (r < 0) { + if (errno == EINTR) continue; + break; + } if (r == 0) { elapsed_ms += rem; if (elapsed_ms >= timeout_sec * 1000) { timed_out = 1; - kill(pid, SIGKILL); + kill_command_tree(pid); break; } continue; } - n = read(pipefd[0], buf, sizeof(buf) - 1); + n = read(pipefd[0], buf, sizeof(buf)); if (n <= 0) break; - buf[n] = '\0'; - { - size_t add = (size_t)n; - if (total + add >= max_len - 1) add = max_len - 1 - total; - memcpy(result_buf + total, buf, add + 1); - total += add; - } + append_unsandboxed_output(result_buf, max_len, &total, buf, (size_t)n); } + result_buf[total] = '\0'; close(pipefd[0]); - waitpid(pid, &status, 0); + if (reap_running_child(pid)) + timed_out = 1; if (timed_out && total < max_len - 32) snprintf(result_buf + total, max_len - total, "\n[Command timed out]"); return 0; diff --git a/tests/test_shell.c b/tests/test_shell.c index 8b43d94..0687664 100644 --- a/tests/test_shell.c +++ b/tests/test_shell.c @@ -6,8 +6,11 @@ #include "tools/tool.h" #include "tools/shell.h" #include "core/config.h" +#include #include +#include #include +#include static int tests_run = 0; static int tests_failed = 0; @@ -68,6 +71,44 @@ static void test_shell_missing_command(void) MU_ASSERT(r == -1, "missing command returns -1"); } +static void output_cap_hang_watchdog(int sig) +{ + (void)sig; + fprintf(stderr, "FAIL: unsandboxed shell hung after filling the output cap\n"); + _exit(2); +} + +static int buf_has_nul(const char *buf, size_t n) +{ + size_t i; + for (i = 0; i < n; i++) { + if (buf[i] == '\0') + return 1; + } + return 0; +} + +static void test_shell_caps_output_without_hanging(void) +{ + const tool_t *t = tool_shell_get(); + char buf[64]; + int r; + tool_shell_set_config(NULL); + memset(buf, 'B', sizeof(buf)); + signal(SIGALRM, output_cap_hang_watchdog); + alarm(5); + /* Fill the 64-byte cap, then sleep so the child stays alive without + * writing (SIGPIPE will not reap it). Unsandboxed waitpid used to block + * forever on this path (#69). + */ + r = t->execute("{\"command\":\"printf '%080d' 0; sleep 9999\"}", buf, + sizeof(buf)); + alarm(0); + signal(SIGALRM, SIG_DFL); + MU_ASSERT(r == 0, "capped shell command returns"); + MU_ASSERT(buf_has_nul(buf, sizeof(buf)), "capped output is NUL-terminated"); +} + int main(void) { MU_RUN(test_shell_blocked_rm_rf); @@ -75,6 +116,7 @@ int main(void) MU_RUN(test_shell_ls_succeeds); MU_RUN(test_shell_invalid_json); MU_RUN(test_shell_missing_command); + MU_RUN(test_shell_caps_output_without_hanging); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } From 82b62cbf85e9c52cd3a0d16c05d01f2912479751 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 12:50:58 -0300 Subject: [PATCH 70/92] fix(gateway): honor 1 MiB POST /asap body cap Skip the 64 KiB static buffer check when POST /asap uses the dynamic body so legal envelopes up to ASAP_BODY_MAX are not 413'd. --- CHANGELOG.md | 1 + src/gateway/asap_http_body.c | 7 ++++++ src/gateway/asap_http_body.h | 12 +++++++++ src/gateway/http_lws.c | 3 ++- tests/test_asap_http_body.c | 18 ++++++++++++++ tests/test_gateway_http.c | 47 +++++++++++++++++++++++++++++++++++- 6 files changed, 86 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bcd0df..3f11d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - `memory_init` no longer deletes an existing SQLite DB when `sqlite3_open` fails (permissions or transient I/O). - 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. +- `POST /asap` honors the 1 MiB dynamic body cap instead of 413'ing envelopes above the 64 KiB static `PUT /api/config` buffer. - 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`. - `POST /asap` rejects serialized JSON-RPC larger than the 64 KiB gateway HTTP buffer (HTTP 500 / JSON-RPC `-32603`) instead of truncating the body. diff --git a/src/gateway/asap_http_body.c b/src/gateway/asap_http_body.c index 7ca5a2d..e62f562 100644 --- a/src/gateway/asap_http_body.c +++ b/src/gateway/asap_http_body.c @@ -44,6 +44,13 @@ int asap_http_body_parse_content_length(const char *cl_buf, long *cl_out) return 0; } +int asap_http_body_exceeds_static_cap(const asap_http_body_t *body, long content_length) +{ + if (!body || body->use_dyn_body) + return 0; + return content_length > (long)BODY_BUF_SIZE; +} + int asap_http_body_init_from_request(struct lws *wsi, asap_http_body_t *body) { char cl_buf[32] = {0}; diff --git a/src/gateway/asap_http_body.h b/src/gateway/asap_http_body.h index 1eb474d..8ad59aa 100644 --- a/src/gateway/asap_http_body.h +++ b/src/gateway/asap_http_body.h @@ -30,6 +30,18 @@ typedef struct asap_http_body { */ int asap_http_body_parse_content_length(const char *cl_buf, long *cl_out); +/** + * True when Content-Length exceeds the static POST/PUT cap (BODY_BUF_SIZE). + * POST /asap uses a 1 MiB dynamic buffer (use_dyn_body); skip the static cap + * so envelopes between 64 KiB and ASAP_BODY_MAX are not 413'd. + * + * Example: asap_http_body_exceeds_static_cap(&body, 70000) is 0 when + * body.use_dyn_body is set, and 1 for the default static buffer. + * + * @return 1 if the static cap applies and is exceeded; 0 otherwise. + */ +int asap_http_body_exceeds_static_cap(const asap_http_body_t *body, long content_length); + /** * For POST /asap: validate Content-Length and allocate dynamic buffer. * @return 0 ok, -1 body too large, -2 allocation failure; non-/asap returns 0. diff --git a/src/gateway/http_lws.c b/src/gateway/http_lws.c index 468b180..86ecb79 100644 --- a/src/gateway/http_lws.c +++ b/src/gateway/http_lws.c @@ -273,7 +273,8 @@ int http_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user, lws_callback_on_writable(wsi); } else { long cl = 0; - if (http_body_content_length(wsi, &cl) == 0 && cl > (long)BODY_BUF_SIZE) + if (http_body_content_length(wsi, &cl) == 0 && + asap_http_body_exceeds_static_cap(&conn->body, cl)) conn->body.body_too_large = 1; conn->body.body[0] = '\0'; conn->body.body_len = 0; diff --git a/tests/test_asap_http_body.c b/tests/test_asap_http_body.c index 5e7df11..9af8c37 100644 --- a/tests/test_asap_http_body.c +++ b/tests/test_asap_http_body.c @@ -6,6 +6,7 @@ #include "gateway/asap_http_body.h" #include +#include #include #define ASSERT(c) do { \ @@ -67,6 +68,19 @@ static int test_static_append_sets_too_large(void) return 0; } +static int test_exceeds_static_cap(void) +{ + asap_http_body_t body; + + memset(&body, 0, sizeof(body)); + ASSERT(asap_http_body_exceeds_static_cap(NULL, (long)BODY_BUF_SIZE + 1) == 0); + ASSERT(asap_http_body_exceeds_static_cap(&body, (long)BODY_BUF_SIZE) == 0); + ASSERT(asap_http_body_exceeds_static_cap(&body, (long)BODY_BUF_SIZE + 1) == 1); + body.use_dyn_body = 1; + ASSERT(asap_http_body_exceeds_static_cap(&body, (long)BODY_BUF_SIZE + 1) == 0); + return 0; +} + int main(void) { int failed = 0; @@ -86,6 +100,10 @@ int main(void) fprintf(stderr, "test_static_append_sets_too_large failed\n"); failed++; } + if (test_exceeds_static_cap() != 0) { + fprintf(stderr, "test_exceeds_static_cap failed\n"); + failed++; + } if (failed == 0) printf("test_asap_http_body: all tests passed\n"); return failed ? 1 : 0; diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 2bb9456..6d087b5 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -874,7 +874,8 @@ static int test_asap_body_over_max(void) long code; char *body = NULL; const char payload[] = "{}"; - int r = http_post_raw(gw_url("/asap"), payload, sizeof(payload) - 1, "1000001", + /* ASAP_BODY_MAX is 1 MiB (1048576). 1000001 is still under that cap. */ + int r = http_post_raw(gw_url("/asap"), payload, sizeof(payload) - 1, "1048577", &code, &body); ASSERT(r == 0); ASSERT(code == 413); @@ -883,6 +884,46 @@ static int test_asap_body_over_max(void) return 0; } +/* + * POST /asap allocates a 1 MiB dynamic buffer. A leftover Content-Length check + * still compared against the 64 KiB static cap used by PUT /api/config, so a + * valid state.query just over 64 KiB was 413'd before parse. + */ +static int test_asap_body_over_static_cap_accepted(void) +{ + enum { PAD = 70000 }; + const char prefix[] = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"asap.send\",\"params\":{" + "\"id\":\"e1\",\"asap_version\":\"2.1\"," + "\"sender\":\"urn:from\",\"recipient\":\"urn:to\"," + "\"payload_type\":\"state.query\",\"payload\":{\"pad\":\""; + const char suffix[] = "\"}}}"; + size_t prefix_len = strlen(prefix); + size_t suffix_len = strlen(suffix); + size_t total = prefix_len + (size_t)PAD + suffix_len; + char *payload; + long code = 0; + char *body = NULL; + int r; + + payload = malloc(total + 1U); + ASSERT(payload != NULL); + memcpy(payload, prefix, prefix_len); + memset(payload + prefix_len, 'A', (size_t)PAD); + memcpy(payload + prefix_len + (size_t)PAD, suffix, suffix_len + 1U); + ASSERT(total > 65536U); + ASSERT(total < (1024U * 1024U)); + r = http_post(gw_url("/asap"), payload, &code, &body); + free(payload); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "\"result\"") != NULL); + ASSERT(strstr(body, "sessions") != NULL); + free(body); + return 0; +} + static int test_health_wellknown(void) { long code; @@ -1572,6 +1613,10 @@ int main(int argc, char **argv) fprintf(stderr, "test_asap_body_over_max failed\n"); failed++; } + if (test_asap_body_over_static_cap_accepted() != 0) { + fprintf(stderr, "test_asap_body_over_static_cap_accepted failed\n"); + failed++; + } if (test_asap_invalid_body() != 0) { fprintf(stderr, "test_asap_invalid_body failed\n"); failed++; } if (test_asap_missing_fields() != 0) { fprintf(stderr, "test_asap_missing_fields failed\n"); failed++; } if (test_asap_task_request() != 0) { fprintf(stderr, "test_asap_task_request failed\n"); failed++; } From 76a763a3ce49aa8f69090bf555fb36b1ca612e22 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 13:32:50 -0300 Subject: [PATCH 71/92] fix(tools): kill unsandboxed shell process group before wait waitpid(WNOHANG) skipping kill_command_tree leaked background grandchildren after the shell exited. Signal the group first, assert no leftover sleep argv, and record the hang/NUL fix in CHANGELOG. Refs: #96 --- CHANGELOG.md | 1 + src/tools/shell.c | 9 ++- tests/test_shell.c | 156 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2e71b..c2a9eb4 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 +- Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Leaf workspace symlinks (dangling or an in-workspace alias) are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). - `write_file` persists via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe an existing workspace file and a sibling `path.tmp` is not truncated (#78). - Camera capture fails closed when `workspace_only` is on with an empty `workspace_path`, and rejects leaf symlink outputs (#91, #90). diff --git a/src/tools/shell.c b/src/tools/shell.c index 46aac41..5638e59 100644 --- a/src/tools/shell.c +++ b/src/tools/shell.c @@ -64,16 +64,19 @@ static void kill_command_tree(pid_t pid) (void)kill(pid, SIGKILL); } -/* Kill leftover children after drain (timeout or output cap), matching sandbox_exec. */ +/* Always SIGKILL the process group first so background grandchildren cannot + * leak after the shell has already exited (SIGPIPE, or `cmd &`). Then reap + * the tracked pid. Matching the hang fix for #69 / #96. */ static int reap_running_child(pid_t pid) { int status = 0; - int wr = waitpid(pid, &status, WNOHANG); + int wr; int retries; struct timespec ts; + kill_command_tree(pid); + wr = waitpid(pid, &status, WNOHANG); if (wr != 0) return 0; - kill_command_tree(pid); for (retries = 0; retries < 40; retries++) { wr = waitpid(pid, &status, WNOHANG); if (wr != 0) diff --git a/tests/test_shell.c b/tests/test_shell.c index 0687664..97c1fd4 100644 --- a/tests/test_shell.c +++ b/tests/test_shell.c @@ -6,10 +6,14 @@ #include "tools/tool.h" #include "tools/shell.h" #include "core/config.h" +#include +#include +#include #include #include #include #include +#include #include static int tests_run = 0; @@ -88,13 +92,134 @@ static int buf_has_nul(const char *buf, size_t n) return 0; } +static int argv0_is_sleep(const char *arg0) +{ + const char *base; + if (!arg0 || !arg0[0]) + return 0; + base = strrchr(arg0, '/'); + base = base ? base + 1 : arg0; + return strcmp(base, "sleep") == 0; +} + +static int cmdline_is_sleep_marker(const char *buf, size_t n, const char *marker) +{ + size_t i = 0; + const char *arg1; + if (n == 0 || !marker) + return 0; + while (i < n && buf[i] != '\0') + i++; + if (i >= n || i + 1 >= n) + return 0; + if (!argv0_is_sleep(buf)) + return 0; + arg1 = buf + i + 1; + return strcmp(arg1, marker) == 0; +} + +static int count_sleep_argv_proc(const char *marker) +{ + DIR *dir; + struct dirent *ent; + int count = 0; + dir = opendir("/proc"); + if (!dir) + return -1; + while ((ent = readdir(dir)) != NULL) { + char path[64]; + char buf[256]; + ssize_t n; + int fd; + if (!isdigit((unsigned char)ent->d_name[0])) + continue; + if (snprintf(path, sizeof(path), "/proc/%s/cmdline", ent->d_name) + >= (int)sizeof(path)) + continue; + fd = open(path, O_RDONLY); + if (fd < 0) + continue; + n = read(fd, buf, sizeof(buf) - 1); + close(fd); + if (n <= 0) + continue; + buf[n] = '\0'; + if (cmdline_is_sleep_marker(buf, (size_t)n + 1, marker)) + count++; + } + closedir(dir); + return count; +} + +static int ps_line_is_sleep_marker(char *line, const char *marker) +{ + char *s = line; + char *nl; + char *base; + size_t marker_len; + nl = strchr(line, '\n'); + if (nl) + *nl = '\0'; + while (*s == ' ' || *s == '\t') + s++; + base = strrchr(s, '/'); + base = base ? base + 1 : s; + marker_len = strlen(marker); + if (strncmp(base, "sleep ", 6) != 0) + return 0; + return strcmp(base + 6, marker) == 0 && marker_len > 0; +} + +static int count_sleep_argv_ps(const char *marker) +{ + FILE *fp; + char line[256]; + int count = 0; + fp = popen("ps -axo args=", "r"); + if (!fp) + return -1; + while (fgets(line, sizeof(line), fp) != NULL) { + if (ps_line_is_sleep_marker(line, marker)) + count++; + } + (void)pclose(fp); + return count; +} + +/* Linux CI: /proc cmdline. Darwin (no /proc): ps args. argv0 must be sleep. */ +static int count_sleep_argv(const char *marker) +{ + int n = count_sleep_argv_proc(marker); + if (n >= 0) + return n; + return count_sleep_argv_ps(marker); +} + +static int sleep_argv_did_not_grow(const char *marker, int before) +{ + struct timespec ts; + int i; + for (i = 0; i < 20; i++) { + int now = count_sleep_argv(marker); + if (now >= 0 && now <= before) + return 1; + ts.tv_sec = 0; + ts.tv_nsec = 50 * 1000 * 1000; + (void)nanosleep(&ts, NULL); + } + return 0; +} + static void test_shell_caps_output_without_hanging(void) { const tool_t *t = tool_shell_get(); char buf[64]; int r; + int before; tool_shell_set_config(NULL); memset(buf, 'B', sizeof(buf)); + before = count_sleep_argv("9999"); + MU_ASSERT(before >= 0, "can count sleep 9999 processes"); signal(SIGALRM, output_cap_hang_watchdog); alarm(5); /* Fill the 64-byte cap, then sleep so the child stays alive without @@ -106,7 +231,37 @@ static void test_shell_caps_output_without_hanging(void) alarm(0); signal(SIGALRM, SIG_DFL); MU_ASSERT(r == 0, "capped shell command returns"); + MU_ASSERT(buf[0] == '0', "capped output starts with truncated zeros"); MU_ASSERT(buf_has_nul(buf, sizeof(buf)), "capped output is NUL-terminated"); + MU_ASSERT(sleep_argv_did_not_grow("9999", before), + "sequential sleep 9999 did not leak"); +} + +static void test_shell_caps_output_kills_background_sleep(void) +{ + const tool_t *t = tool_shell_get(); + char buf[64]; + int r; + int before; + tool_shell_set_config(NULL); + memset(buf, 'B', sizeof(buf)); + before = count_sleep_argv("9998"); + MU_ASSERT(before >= 0, "can count sleep 9998 processes"); + signal(SIGALRM, output_cap_hang_watchdog); + alarm(5); + /* Shell can exit after printf while the background sleep stays in the + * process group. Skipping kill_command_tree when waitpid already reaped + * the shell leaked that grandchild (#96). + */ + r = t->execute("{\"command\":\"trap '' HUP; sleep 9998 & printf '%080d' 0\"}", + buf, sizeof(buf)); + alarm(0); + signal(SIGALRM, SIG_DFL); + MU_ASSERT(r == 0, "background capped shell command returns"); + MU_ASSERT(buf[0] == '0', "background capped output is truncated zeros"); + MU_ASSERT(buf_has_nul(buf, sizeof(buf)), "background capped output is NUL-terminated"); + MU_ASSERT(sleep_argv_did_not_grow("9998", before), + "background sleep 9998 did not leak"); } int main(void) @@ -117,6 +272,7 @@ int main(void) MU_RUN(test_shell_invalid_json); MU_RUN(test_shell_missing_command); MU_RUN(test_shell_caps_output_without_hanging); + MU_RUN(test_shell_caps_output_kills_background_sleep); printf("%d tests run, %d failed\n", tests_run, tests_failed); return tests_failed ? 1 : 0; } From 7db8f6b68ee2aa960c7adf129325d00a13870502 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 13:32:50 -0300 Subject: [PATCH 72/92] test(tools): expose POSIX popen and nanosleep in test_shell Linux glibc + -Werror hid popen/pclose/nanosleep without a feature test macro, so CI failed to compile the leftover-sleep scanner. Refs: #96 --- tests/test_shell.c | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_shell.c b/tests/test_shell.c index 97c1fd4..ceb9551 100644 --- a/tests/test_shell.c +++ b/tests/test_shell.c @@ -2,6 +2,7 @@ * @file test_shell.c * @brief Unit tests for shell tool: safe commands, blocklist, timeout. */ +#define _POSIX_C_SOURCE 200809L #include "tools/tool.h" #include "tools/shell.h" From 3678a095ce208d770de96205fe989849b65dec44 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 13:33:19 -0300 Subject: [PATCH 73/92] fix(gateway): apply dashboard JSON config and reload live settings Dashboard PUT /api/config sent JSON while the handler wrote raw TOML and never swapped live provider/gateway settings. Patch known fields into config.toml and reload so GET reflects the save immediately. Refs: #58 --- Makefile | 20 +- scripts/coverage.sh | 2 +- src/core/config_patch.c | 418 ++++++++++++++++++++++++++++++++++++++ src/core/config_patch.h | 39 ++++ src/core/main.c | 13 +- src/core/reload.c | 11 +- src/gateway/routes.c | 87 +++++++- tests/test_config_patch.c | 136 +++++++++++++ tests/test_gateway_http.c | 32 +++ tests/test_reload.c | 39 ++++ 10 files changed, 777 insertions(+), 20 deletions(-) create mode 100644 src/core/config_patch.c create mode 100644 src/core/config_patch.h create mode 100644 tests/test_config_patch.c diff --git a/Makefile b/Makefile index 44f10b4..afb29ba 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,7 @@ SKILL_O := src/core/skill.o AGENT_O := src/core/agent.o DAEMON_O := src/core/daemon.o RELOAD_O := src/core/reload.o +CONFIG_PATCH_O := src/core/config_patch.o BOOTSTRAP_O := src/core/bootstrap.o DISPATCH_O := src/core/dispatch.o # Vendor @@ -156,7 +157,7 @@ BOOTSTRAP_DISPATCH_STUB_O := tests/stubs/bootstrap_dispatch_stub.o TOOL_RELOAD_STUB_O := tests/stubs/tool_reload_stub.o RELOAD_CHANNEL_STUB_O := tests/stubs/reload_channel_stub.o HTTP_RELOAD_STUB_O := tests/stubs/http_reload_stub.o -CORE_OBJS := $(CONFIG_O) $(MAIN_O) $(MEMORY_O) $(SKILL_O) $(AGENT_O) $(DAEMON_O) $(RELOAD_O) $(BOOTSTRAP_O) $(DISPATCH_O) +CORE_OBJS := $(CONFIG_O) $(MAIN_O) $(MEMORY_O) $(SKILL_O) $(AGENT_O) $(DAEMON_O) $(RELOAD_O) $(CONFIG_PATCH_O) $(BOOTSTRAP_O) $(DISPATCH_O) VENDOR_OBJS := $(TOML_O) $(SQLITE3_O) $(CJSON_O) OBJS := $(CORE_OBJS) $(VENDOR_OBJS) PROVIDER_OBJS := $(PROVIDER_COMMON_O) $(STUB_O) $(ROUTER_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) @@ -197,6 +198,9 @@ $(DAEMON_O): src/core/daemon.c src/core/daemon.h src/core/config.h $(RELOAD_O): src/core/reload.c src/core/reload.h src/core/bootstrap.h src/core/config.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/reload.c +$(CONFIG_PATCH_O): src/core/config_patch.c src/core/config_patch.h src/core/config.h vendor/cJSON/cJSON.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/config_patch.c + $(BOOTSTRAP_O): src/core/bootstrap.c src/core/bootstrap.h src/asap/manifest.h src/core/agent.h src/core/config.h src/core/memory.h src/core/skill.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h src/tools/cron.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/bootstrap.c @@ -364,7 +368,7 @@ $(HTTP_O): src/gateway/http.c src/gateway/http.h src/gateway/http_lws.h src/gate $(HTTP_LWS_O): src/gateway/http_lws.c src/gateway/http_lws.h src/gateway/asap_http_body.h src/gateway/routes.h src/gateway/auth.h src/gateway/static.h src/gateway/ws.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/http_lws.c -$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/bootstrap.h src/core/agent.h src/core/config.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h src/tools/tool.h +$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/bootstrap.h src/core/agent.h src/core/config.h src/core/config_patch.h src/core/reload.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/routes.c $(ROUTES_HARDWARE_O): src/gateway/routes_hardware.c src/gateway/routes_hardware.h src/gateway/routes.h src/gateway/http_lws.h src/gateway/uri_match.h src/hardware/hardware.h src/hardware/hardware_gpio_snapshot.h src/hardware/hardware_tegrastats.h src/hardware/board_detect.h src/core/config.h @@ -468,6 +472,11 @@ test_config: tests/test_config.c $(CONFIG_O) $(TOML_O) $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_config.c $(CONFIG_O) $(TOML_O) $(LDLIBS) $(DSYM_SCRIPT) +test_config_patch: tests/test_config_patch.c $(CONFIG_PATCH_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) + @mkdir -p $(BINDIR) + $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_config_patch.c $(CONFIG_PATCH_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) + $(DSYM_SCRIPT) + test_memory: tests/test_memory.c $(MEMORY_O) $(SQLITE3_O) @mkdir -p $(BINDIR) $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_memory.c $(MEMORY_O) $(SQLITE3_O) $(LDLIBS) @@ -822,8 +831,9 @@ static: --suppress=variableScope:src/vendor/tweetnacl/tweetnacl.c \ -q src/ -test: test_config test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_daemon_smoke test_bootstrap_keys test_update_script test_install_script test_download_model test_web_dashboard test_routes_hardware +test: test_config test_config_patch test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_daemon_smoke test_bootstrap_keys test_update_script test_install_script test_download_model test_web_dashboard test_routes_hardware $(BINDIR)/test_config + $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider @@ -877,7 +887,7 @@ COVERAGE_DIR := build/coverage COVERAGE_MIN := 80 coverage: clean - $(MAKE) BUILD=coverage GATEWAY=0 test_config test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_auth + $(MAKE) BUILD=coverage GATEWAY=0 test_config test_config_patch test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_auth @if [ "$(GATEWAY)" = "1" ]; then $(MAKE) BUILD=coverage GATEWAY=1 shellclaw test_gateway_http test_static; fi @chmod +x scripts/coverage.sh @@ -892,6 +902,6 @@ clean: clean-root-dsym rm -f $(OBJS) $(PROVIDER_COMMON_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(ROUTER_O) $(CJSON_O) $(TWEETNACL_O) $(ANTHROPIC_TEST_O) $(OPENAI_TEST_O) $(LOCAL_TEST_O) $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(CHANNEL_TG_TEST_O) $(CHANNEL_COMMON_O) $(CHANNEL_STUB_O) $(CHANNEL_CLI_O) $(CHANNEL_TG_O) $(CHANNEL_DISCORD_O) $(DISCORD_HELPERS_O) $(CHANNEL_HEARTBEAT_O) $(CHANNEL_WEBCHAT_O) $(AUTH_O) $(STATIC_O) $(HTTP_O) $(HTTP_LWS_O) $(ASAP_HTTP_BODY_O) $(ROUTES_O) $(ROUTES_HARDWARE_O) $(WS_O) $(MANIFEST_O) $(MANIFEST_PROFILES_O) $(MANIFEST_BUILD_O) $(MANIFEST_SIGN_O) $(MANIFEST_KEYS_O) $(ENVELOPE_O) $(ULID_O) $(CLIENT_O) $(ASAP_REGISTRY_O) $(SERVER_O) $(ASAP_LOG_O) $(RATE_LIMIT_O) $(SHELL_O) $(WEBSEARCH_O) $(FILE_O) $(REGISTRY_O) $(CONTEXT_O) $(CONTEXT_CACHE_O) $(CONTEXT_HTTP_O) $(CONTEXT_GEO_O) $(CRYPTO_O) $(JCS_O) $(HARDWARE_STUB_O) $(HARDWARE_INIT_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_TOOLS_O) $(BOARD_DETECT_O) src/hardware/hardware_libgpiod.o $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(CRON_O) $(ASAP_INVOKE_O) $(SANDBOX_O) $(ALLOWLIST_O) rm -f src/gateway/ui_assets.h find . -name '*.gcno' -o -name '*.gcda' -o -name '*.gcov' | xargs rm -f 2>/dev/null || true - rm -f $(WS_TEST_O) $(BINDIR)/asap_registry_test.o $(BINDIR)/asap_invoke_test.o $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(BINDIR)/shellclaw $(BINDIR)/test_tweetnacl_smoke $(BINDIR)/test_config $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider $(BINDIR)/test_anthropic $(BINDIR)/test_openai $(BINDIR)/test_local_provider $(BINDIR)/test_router $(BINDIR)/test_heartbeat $(BINDIR)/test_crypto $(BINDIR)/test_hardware_stub $(BINDIR)/test_board_detect $(BINDIR)/test_hardware_libgpiod $(BINDIR)/test_hardware_i2c $(BINDIR)/test_hardware_camera $(BINDIR)/test_pin_tables $(BINDIR)/test_hardware_init $(BINDIR)/test_hardware_tools $(BINDIR)/test_registry $(BINDIR)/test_ws $(BINDIR)/test_agent $(BINDIR)/test_channel $(BINDIR)/test_cli $(BINDIR)/test_shell $(BINDIR)/test_file $(BINDIR)/test_telegram $(BINDIR)/test_discord_helpers $(BINDIR)/test_web_search $(BINDIR)/test_cron $(BINDIR)/test_context $(BINDIR)/test_manifest_build $(BINDIR)/test_manifest_keys $(BINDIR)/test_jcs $(BINDIR)/test_asap_envelope $(BINDIR)/test_asap_ulid $(BINDIR)/test_asap_client $(BINDIR)/test_asap_registry $(BINDIR)/test_asap_server $(BINDIR)/test_asap_invoke $(BINDIR)/test_asap_log $(BINDIR)/test_auth $(BINDIR)/test_gateway_http $(BINDIR)/test_static $(BINDIR)/test_sandbox $(BINDIR)/test_allowlist $(BINDIR)/test_rate_limit + rm -f $(WS_TEST_O) $(BINDIR)/asap_registry_test.o $(BINDIR)/asap_invoke_test.o $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(BINDIR)/shellclaw $(BINDIR)/test_tweetnacl_smoke $(BINDIR)/test_config $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider $(BINDIR)/test_anthropic $(BINDIR)/test_openai $(BINDIR)/test_local_provider $(BINDIR)/test_router $(BINDIR)/test_heartbeat $(BINDIR)/test_crypto $(BINDIR)/test_hardware_stub $(BINDIR)/test_board_detect $(BINDIR)/test_hardware_libgpiod $(BINDIR)/test_hardware_i2c $(BINDIR)/test_hardware_camera $(BINDIR)/test_pin_tables $(BINDIR)/test_hardware_init $(BINDIR)/test_hardware_tools $(BINDIR)/test_registry $(BINDIR)/test_ws $(BINDIR)/test_agent $(BINDIR)/test_channel $(BINDIR)/test_cli $(BINDIR)/test_shell $(BINDIR)/test_file $(BINDIR)/test_telegram $(BINDIR)/test_discord_helpers $(BINDIR)/test_web_search $(BINDIR)/test_cron $(BINDIR)/test_context $(BINDIR)/test_manifest_build $(BINDIR)/test_manifest_keys $(BINDIR)/test_jcs $(BINDIR)/test_asap_envelope $(BINDIR)/test_asap_ulid $(BINDIR)/test_asap_client $(BINDIR)/test_asap_registry $(BINDIR)/test_asap_server $(BINDIR)/test_asap_invoke $(BINDIR)/test_asap_log $(BINDIR)/test_auth $(BINDIR)/test_gateway_http $(BINDIR)/test_static $(BINDIR)/test_sandbox $(BINDIR)/test_allowlist $(BINDIR)/test_rate_limit rm -rf $(BINDIR)/*.dSYM $(DSYMDIR) rm -f $(BOOTSTRAP_DISPATCH_STUB_O) $(TOOL_RELOAD_STUB_O) $(RELOAD_CHANNEL_STUB_O) $(HTTP_RELOAD_STUB_O) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index d2b5487..816dfae 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -13,7 +13,7 @@ LCOV_RC="lcov_branch_coverage=0" mkdir -p "$COVERAGE_DIR" rm -f "$COVERAGE_DIR"/*.info -TESTS="test_config test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs test_asap_envelope test_asap_ulid test_asap_client test_asap_registry test_asap_server test_asap_invoke test_asap_log test_sandbox test_allowlist test_rate_limit test_auth test_static" +TESTS="test_config test_config_patch test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs test_asap_envelope test_asap_ulid test_asap_client test_asap_registry test_asap_server test_asap_invoke test_asap_log test_sandbox test_allowlist test_rate_limit test_auth test_static" # ASAP tests must stay aligned with ASAP_UNIT_TESTS in the top-level Makefile. if [ "${GATEWAY:-}" = "1" ]; then TESTS="$TESTS test_gateway_http" diff --git a/src/core/config_patch.c b/src/core/config_patch.c new file mode 100644 index 0000000..dc0943a --- /dev/null +++ b/src/core/config_patch.c @@ -0,0 +1,418 @@ +/** + * @file config_patch.c + * @brief Patch on-disk TOML from dashboard JSON updates. + */ +#define _POSIX_C_SOURCE 200809L + +#include "core/config_patch.h" +#include "core/config.h" +#include "cJSON.h" +#include +#include +#include +#include +#include + +#define PATCH_ERR(errbuf, errbufsz, msg) \ + do { \ + if ((errbuf) && (errbufsz) > 0) \ + snprintf((errbuf), (errbufsz), "%s", (msg)); \ + } while (0) + +static char *read_file(const char *path, size_t *out_len, char *errbuf, size_t errbufsz) +{ + FILE *f; + char *buf; + long n; + size_t got; + if (!path || !out_len) { + PATCH_ERR(errbuf, errbufsz, "invalid arguments"); + return NULL; + } + f = fopen(path, "r"); + if (!f) { + PATCH_ERR(errbuf, errbufsz, "cannot open config file"); + return NULL; + } + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + n = ftell(f); + if (n < 0) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + if (fseek(f, 0, SEEK_SET) != 0) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + buf = malloc((size_t)n + 1); + if (!buf) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "out of memory"); + return NULL; + } + got = fread(buf, 1, (size_t)n, f); + fclose(f); + if (got != (size_t)n) { + free(buf); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + buf[got] = '\0'; + *out_len = got; + return buf; +} + +static int buf_reserve(char **buf, size_t *len, size_t *cap, size_t extra) +{ + size_t need; + size_t new_cap; + char *grown; + if (!buf || !*buf || !len || !cap) + return -1; + need = *len + extra + 1; + if (need <= *cap) + return 0; + new_cap = (*cap == 0) ? need : *cap; + while (new_cap < need) { + if (new_cap > ((size_t)-1) / 2) + return -1; + new_cap *= 2; + } + grown = realloc(*buf, new_cap); + if (!grown) + return -1; + *buf = grown; + *cap = new_cap; + return 0; +} + +static int append_section_key(char **buf, size_t *len, size_t *cap, const char *section, + const char *key, const char *line_value) +{ + char extra[768]; + int n; + if (!buf || !*buf || !len || !cap || !section || !key || !line_value) + return -1; + n = snprintf(extra, sizeof(extra), "\n[%s]\n%s = %s\n", section, key, line_value); + if (n < 0 || (size_t)n >= sizeof(extra)) + return -1; + if (buf_reserve(buf, len, cap, (size_t)n) != 0) + return -1; + memcpy(*buf + *len, extra, (size_t)n); + *len += (size_t)n; + (*buf)[*len] = '\0'; + return 0; +} + +static int escape_toml_string(const char *in, char **out) +{ + size_t cap; + size_t len; + size_t i; + if (!in || !out) + return -1; + cap = strlen(in) * 2 + 3; + *out = malloc(cap); + if (!*out) + return -1; + (*out)[0] = '"'; + len = 1; + for (i = 0; in[i]; i++) { + if (in[i] == '"' || in[i] == '\\') { + if (len + 2 >= cap) { + char *grown; + cap *= 2; + grown = realloc(*out, cap); + if (!grown) { + free(*out); + *out = NULL; + return -1; + } + *out = grown; + } + (*out)[len++] = '\\'; + } + if (len + 1 >= cap) { + char *grown; + cap *= 2; + grown = realloc(*out, cap); + if (!grown) { + free(*out); + *out = NULL; + return -1; + } + *out = grown; + } + (*out)[len++] = in[i]; + } + (*out)[len++] = '"'; + (*out)[len] = '\0'; + return 0; +} + +static const char *find_section(const char *content, const char *section) +{ + char marker[128]; + size_t marker_len; + const char *p; + if (!content || !section) + return NULL; + snprintf(marker, sizeof(marker), "[%s]", section); + marker_len = strlen(marker); + for (p = content; *p; p++) { + if (strncmp(p, marker, marker_len) != 0) + continue; + if (p != content && p[-1] != '\n') + continue; + if (p[marker_len] != '\0' && p[marker_len] != '\r' && p[marker_len] != '\n') + continue; + return p; + } + return NULL; +} + +static const char *section_end(const char *section_start) +{ + const char *p; + if (!section_start) + return NULL; + p = strchr(section_start + 1, '\n'); + if (!p) + return section_start + strlen(section_start); + for (; *p; p++) { + if (*p == '[' && (p == section_start || p[-1] == '\n')) + return p; + } + return section_start + strlen(section_start); +} + +static const char *find_key_line(const char *sec_start, const char *sec_end, + const char *key, size_t *line_len) +{ + size_t key_len; + const char *p; + if (!sec_start || !sec_end || !key || !line_len) + return NULL; + key_len = strlen(key); + for (p = sec_start; p < sec_end; p++) { + const char *line_end = strchr(p, '\n'); + size_t span; + if (!line_end || line_end > sec_end) + line_end = sec_end; + span = (size_t)(line_end - p); + while (span > 0 && isspace((unsigned char)p[span - 1])) + span--; + if (span > key_len) { + const char *after_key = p + key_len; + while (after_key < line_end && + (*after_key == ' ' || *after_key == '\t')) + after_key++; + if (strncmp(p, key, key_len) == 0 && after_key < line_end && + *after_key == '=') { + *line_len = (size_t)(line_end - p); + if (*line_end == '\n') + (*line_len)++; + return p; + } + } + if (!*line_end) + break; + p = line_end; + } + return NULL; +} + +static int splice_text(char **content, size_t *len, size_t *cap, size_t off, + size_t old_len, const char *insert, size_t insert_len) +{ + size_t suffix_len; + char *next; + if (!content || !*content || !len || !cap || !insert) + return -1; + if (off > *len || old_len > *len - off) + return -1; + suffix_len = *len - off - old_len; + next = malloc(off + insert_len + suffix_len + 1); + if (!next) + return -1; + memcpy(next, *content, off); + memcpy(next + off, insert, insert_len); + memcpy(next + off + insert_len, *content + off + old_len, suffix_len + 1); + free(*content); + *content = next; + *len = off + insert_len + suffix_len; + if (*len + 1 > *cap) + *cap = *len + 1; + return 0; +} + +static int patch_key_line(char **content, size_t *len, size_t *cap, const char *section, + const char *key, const char *line_value) +{ + const char *sec; + const char *sec_end; + const char *line; + size_t line_len; + char insert_line[512]; + int n; + if (!content || !*content || !len || !cap || !section || !key || !line_value) + return -1; + n = snprintf(insert_line, sizeof(insert_line), "%s = %s\n", key, line_value); + if (n < 0 || (size_t)n >= sizeof(insert_line)) + return -1; + sec = find_section(*content, section); + if (!sec) + return append_section_key(content, len, cap, section, key, line_value); + sec_end = section_end(sec); + line = find_key_line(sec, sec_end, key, &line_len); + if (!line) { + return splice_text(content, len, cap, (size_t)(sec_end - *content), 0, + insert_line, (size_t)n); + } + return splice_text(content, len, cap, (size_t)(line - *content), line_len, + insert_line, (size_t)n); +} + +static int patch_string_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, const char *value) +{ + char *escaped; + int rc; + if (!value) + return 0; + if (escape_toml_string(value, &escaped) != 0) + return -1; + rc = patch_key_line(content, len, cap, section, key, escaped); + free(escaped); + return rc; +} + +static int patch_int_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, int value) +{ + char buf[32]; + snprintf(buf, sizeof(buf), "%d", value); + return patch_key_line(content, len, cap, section, key, buf); +} + +static int patch_double_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, double value) +{ + char buf[32]; + snprintf(buf, sizeof(buf), "%g", value); + return patch_key_line(content, len, cap, section, key, buf); +} + +static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size_t *cap) +{ + cJSON *model = cJSON_GetObjectItem(root, "model"); + cJSON *max_tokens = cJSON_GetObjectItem(root, "max_tokens"); + cJSON *temperature = cJSON_GetObjectItem(root, "temperature"); + cJSON *gateway_host = cJSON_GetObjectItem(root, "gateway_host"); + cJSON *gateway_port = cJSON_GetObjectItem(root, "gateway_port"); + if (model && cJSON_IsString(model) && + patch_string_field(content, len, cap, "agent", "model", model->valuestring) != 0) + return -1; + if (max_tokens && cJSON_IsNumber(max_tokens) && + patch_int_field(content, len, cap, "agent", "max_tokens", max_tokens->valueint) != 0) + return -1; + if (temperature && cJSON_IsNumber(temperature) && + patch_double_field(content, len, cap, "agent", "temperature", + temperature->valuedouble) != 0) + return -1; + if (gateway_host && cJSON_IsString(gateway_host) && + patch_string_field(content, len, cap, "gateway", "host", + gateway_host->valuestring) != 0) + return -1; + if (gateway_port && cJSON_IsNumber(gateway_port) && + patch_int_field(content, len, cap, "gateway", "port", gateway_port->valueint) != 0) + return -1; + return 0; +} + +static int validate_patched_toml(const char *config_path, const char *content, size_t len, + char *errbuf, size_t errbufsz) +{ + size_t path_len; + char *tmp_path; + FILE *f; + config_t *cfg = NULL; + path_len = strlen(config_path); + tmp_path = malloc(path_len + 16); + if (!tmp_path) { + PATCH_ERR(errbuf, errbufsz, "out of memory"); + return -1; + } + snprintf(tmp_path, path_len + 16, "%s.patch-test", config_path); + f = fopen(tmp_path, "w"); + if (!f) { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + free(tmp_path); + return -1; + } + if (fwrite(content, 1, len, f) != len) { + fclose(f); + unlink(tmp_path); + free(tmp_path); + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + fclose(f); + if (config_load(tmp_path, &cfg, errbuf, errbufsz) != 0) { + unlink(tmp_path); + free(tmp_path); + return -1; + } + config_free(cfg); + unlink(tmp_path); + free(tmp_path); + return 0; +} + +int config_patch_dashboard_json(const char *config_path, const char *json_body, char **out_toml, + size_t *out_len, char *errbuf, size_t errbufsz) +{ + cJSON *root; + size_t cap; + char *content; + size_t len; + if (!config_path || !json_body || !out_toml || !out_len) { + PATCH_ERR(errbuf, errbufsz, "invalid arguments"); + return -1; + } + *out_toml = NULL; + *out_len = 0; + root = cJSON_Parse(json_body); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + PATCH_ERR(errbuf, errbufsz, "invalid JSON body"); + return -1; + } + content = read_file(config_path, &len, errbuf, errbufsz); + if (!content) { + cJSON_Delete(root); + return -1; + } + cap = len + 1; + if (apply_dashboard_fields(root, &content, &len, &cap) != 0) { + PATCH_ERR(errbuf, errbufsz, "failed to patch config fields"); + free(content); + cJSON_Delete(root); + return -1; + } + if (validate_patched_toml(config_path, content, len, errbuf, errbufsz) != 0) { + free(content); + cJSON_Delete(root); + return -1; + } + *out_toml = content; + *out_len = len; + cJSON_Delete(root); + return 0; +} diff --git a/src/core/config_patch.h b/src/core/config_patch.h new file mode 100644 index 0000000..5529580 --- /dev/null +++ b/src/core/config_patch.h @@ -0,0 +1,39 @@ +/** + * @file config_patch.h + * @brief Patch on-disk TOML from dashboard JSON updates. + * + * Example: config_patch_dashboard_json(path, "{\"model\":\"x\"}", &toml, &len, err, sizeof(err)); + */ + +#ifndef SHELLCLAW_CONFIG_PATCH_H +#define SHELLCLAW_CONFIG_PATCH_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Merge dashboard JSON fields into an existing config.toml. + * + * Accepts JSON objects with optional keys: model, max_tokens, temperature, + * gateway_host, gateway_port. Unmentioned keys are left unchanged on disk. + * + * @param config_path Path to config.toml. + * @param json_body NUL-terminated JSON object body. + * @param out_toml On success, allocated patched TOML (caller frees). + * @param out_len Length of patched TOML. + * @param errbuf Optional error buffer. + * @param errbufsz Size of errbuf. + * @return 0 on success, non-zero on error. + */ +int config_patch_dashboard_json(const char *config_path, const char *json_body, + char **out_toml, size_t *out_len, char *errbuf, + size_t errbufsz); + +#ifdef __cplusplus +} +#endif + +#endif /* SHELLCLAW_CONFIG_PATCH_H */ diff --git a/src/core/main.c b/src/core/main.c index 5d7fa2c..a9ac570 100644 --- a/src/core/main.c +++ b/src/core/main.c @@ -197,10 +197,13 @@ int main(int argc, char **argv) return 1; } main_loop(g_cli_one_shot != NULL, &cfg); - cleanup_subsystems(); - curl_global_cleanup(); - daemon_pid_cleanup(); - stale_free_all(); - config_free(cfg); + { + config_t *live = bootstrap_get_cfg(); + cleanup_subsystems(); + curl_global_cleanup(); + daemon_pid_cleanup(); + stale_free_all(); + config_free(live); + } return 0; } diff --git a/src/core/reload.c b/src/core/reload.c index 0eb6e14..1520a2a 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -61,18 +61,27 @@ void stale_free_all(void) void try_config_reload(config_t **pcfg) { + config_t *old; config_t *new_cfg; char errbuf[256]; const char *config_path = bootstrap_get_config_path(); if (!pcfg || !*pcfg || !config_path) return; + /* Dashboard PUT may reload from the HTTP thread while main still holds the + * previous pointer. Always enqueue the live bootstrap cfg, not the caller's + * possibly-stale copy, so a later SIGHUP does not double-free. */ + old = bootstrap_get_cfg(); + if (!old) + old = *pcfg; + if (!old) + return; new_cfg = NULL; if (config_load(config_path, &new_cfg, errbuf, sizeof(errbuf)) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: %s\n", errbuf[0] ? errbuf : "unknown error"); return; } - if (stale_enqueue(*pcfg) != 0) { + if (stale_enqueue(old) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: out of memory\n"); config_free(new_cfg); return; diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 100d066..a59936f 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -6,6 +6,7 @@ #include "gateway/routes.h" #include "gateway/routes_hardware.h" +#include "gateway/http.h" #include "gateway/auth.h" #include "gateway/rate_limit.h" #include "channels/channel.h" @@ -16,7 +17,9 @@ #include "asap/log.h" #include "core/bootstrap.h" #include "core/config.h" +#include "core/config_patch.h" #include "core/memory.h" +#include "core/reload.h" #include "core/skill.h" #include "providers/provider.h" #include "tools/context.h" @@ -187,9 +190,54 @@ static void handle_config_get(const config_t *cfg, char *buf, size_t size, int * } } +static int body_is_json_object(const char *body, size_t body_len) +{ + size_t i; + for (i = 0; i < body_len; i++) { + unsigned char c = (unsigned char)body[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') + continue; + return c == '{'; + } + return 0; +} + +static int config_put_patch_json(http_server_ctx_t *ctx, const char *body, size_t body_len, + char **out_toml, size_t *out_len, char *buf, size_t size, + int *status) +{ + char *json_nul; + char errbuf[256] = {0}; + json_nul = malloc(body_len + 1); + if (!json_nul) { + json_error(buf, size, status, 500, "Out of memory"); + return -1; + } + memcpy(json_nul, body, body_len); + json_nul[body_len] = '\0'; + if (config_patch_dashboard_json(ctx->config_path, json_nul, out_toml, out_len, errbuf, + sizeof(errbuf)) != 0) { + free(json_nul); + json_error(buf, size, status, 400, errbuf[0] ? errbuf : "Invalid config patch"); + return -1; + } + free(json_nul); + return 0; +} + static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t body_len, char *buf, size_t size, int *status) { + char *patched_body = NULL; + size_t patched_len = 0; + char errbuf[256] = {0}; + const char *write_body = body; + size_t write_len = body_len; + size_t path_len; + char *tmp_path; + FILE *f; + size_t written; + config_t *cfg = NULL; if (!ctx->config_path || !body || body_len == 0) { json_error(buf, size, status, 400, "Bad request"); return; @@ -198,29 +246,41 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b json_error(buf, size, status, 400, "Config too large"); return; } - size_t path_len = strlen(ctx->config_path); - char *tmp_path = malloc(path_len + 8); - if (!tmp_path) { json_error(buf, size, status, 500, "Out of memory"); return; } + if (body_is_json_object(body, body_len)) { + if (config_put_patch_json(ctx, body, body_len, &patched_body, &patched_len, buf, size, + status) != 0) + return; + write_body = patched_body; + write_len = patched_len; + } + path_len = strlen(ctx->config_path); + tmp_path = malloc(path_len + 8); + if (!tmp_path) { + free(patched_body); + json_error(buf, size, status, 500, "Out of memory"); + return; + } snprintf(tmp_path, path_len + 8, "%s.tmp", ctx->config_path); - FILE *f = fopen(tmp_path, "w"); + f = fopen(tmp_path, "w"); if (!f) { free(tmp_path); + free(patched_body); json_error(buf, size, status, 500, "Failed to write config"); return; } - size_t written = fwrite(body, 1, body_len, f); + written = fwrite(write_body, 1, write_len, f); fclose(f); - if (written != body_len) { + if (written != write_len) { unlink(tmp_path); free(tmp_path); + free(patched_body); json_error(buf, size, status, 500, "Failed to write config"); return; } - config_t *cfg = NULL; - char errbuf[256] = {0}; if (config_load(tmp_path, &cfg, errbuf, sizeof(errbuf)) != 0) { unlink(tmp_path); free(tmp_path); + free(patched_body); json_error(buf, size, status, 400, errbuf[0] ? errbuf : "Invalid TOML"); return; } @@ -228,10 +288,21 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b if (rename(tmp_path, ctx->config_path) != 0) { unlink(tmp_path); free(tmp_path); + free(patched_body); json_error(buf, size, status, 500, "Failed to save config"); return; } free(tmp_path); + free(patched_body); + /* Dashboard/TOML save: swap live cfg now instead of waiting for SIGHUP. + * Call http_set_live_config here: test_reload rebuilds reload.o with + * GATEWAY=0, so try_config_reload may omit the gateway pointer swap. */ + { + config_t *live_cfg = bootstrap_get_cfg(); + if (live_cfg) + try_config_reload(&live_cfg); + http_set_live_config(bootstrap_get_cfg()); + } *status = 200; json_response(buf, size, status, "{\"ok\":true}"); } diff --git a/tests/test_config_patch.c b/tests/test_config_patch.c new file mode 100644 index 0000000..95e2778 --- /dev/null +++ b/tests/test_config_patch.c @@ -0,0 +1,136 @@ +/** + * @file test_config_patch.c + * @brief Unit tests for dashboard JSON config patching. + */ + +#include "test_runner.h" +#include "src/core/config.h" +#include "src/core/config_patch.h" +#include +#include +#include + +static int write_toml(const char *path, const char *toml) +{ + FILE *f = fopen(path, "w"); + if (!f) + return -1; + fputs(toml, f); + fclose(f); + return 0; +} + +static int test_patch_model_and_temperature(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + config_t *cfg = NULL; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, + "[agent]\nmodel = \"old-model\"\nmax_tokens = 1024\ntemperature = 0.2\n" + "[gateway]\nhost = \"127.0.0.1\"\nport = 18789\n") == 0); + ASSERT(config_patch_dashboard_json( + path, "{\"model\":\"new-model\",\"temperature\":0.9}", &patched, &patched_len, errbuf, + sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "model = \"new-model\"") != NULL); + ASSERT(strstr(patched, "temperature = 0.9") != NULL); + ASSERT(strstr(patched, "max_tokens = 1024") != NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(strcmp(config_agent_model(cfg), "new-model") == 0); + ASSERT(config_agent_temperature(cfg) == 0.9); + ASSERT(config_agent_max_tokens(cfg) == 1024); + config_free(cfg); + remove(path); + return 0; +} + +static int test_patch_inserts_missing_key(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + config_t *cfg = NULL; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old-model\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"max_tokens\":2048}", &patched, &patched_len, + errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "max_tokens = 2048") != NULL); + ASSERT(strstr(patched, "model = \"old-model\"") != NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(config_agent_max_tokens(cfg) == 2048); + config_free(cfg); + remove(path); + return 0; +} + +static int test_patch_rejects_invalid_json(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old-model\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "not-json", &patched, &patched_len, errbuf, + sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + remove(path); + return 0; +} + +static int test_patch_creates_missing_section(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + config_t *cfg = NULL; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old-model\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"gateway_host\":\"10.0.0.1\",\"gateway_port\":19000}", + &patched, &patched_len, errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "[gateway]") != NULL); + ASSERT(strstr(patched, "host = \"10.0.0.1\"") != NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(strcmp(config_gateway_host(cfg), "10.0.0.1") == 0); + ASSERT(config_gateway_port(cfg) == 19000); + config_free(cfg); + remove(path); + return 0; +} + +int main(void) +{ + int failed = 0; + if (test_patch_model_and_temperature() != 0) { + fprintf(stderr, "test_patch_model_and_temperature failed\n"); + failed++; + } + if (test_patch_inserts_missing_key() != 0) { + fprintf(stderr, "test_patch_inserts_missing_key failed\n"); + failed++; + } + if (test_patch_rejects_invalid_json() != 0) { + fprintf(stderr, "test_patch_rejects_invalid_json failed\n"); + failed++; + } + if (test_patch_creates_missing_section() != 0) { + fprintf(stderr, "test_patch_creates_missing_section failed\n"); + failed++; + } + if (failed == 0) + printf("test_config_patch: all tests passed\n"); + return failed; +} diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 2bb9456..08d5617 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1058,6 +1058,34 @@ static int test_api_config_put_valid(const char *token, int port, const char *co return 0; } +static int test_api_config_put_json(const char *token, int port) +{ + long code; + char *body = NULL; + char json[256]; + int r; + snprintf(json, sizeof(json), + "{\"model\":\"patched-model\",\"max_tokens\":2048,\"temperature\":0.5," + "\"gateway_host\":\"127.0.0.1\",\"gateway_port\":%d}", + port); + r = http_put_auth(gw_url("/api/config"), token, json, &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "\"ok\":true") != NULL || strstr(body, "\"ok\": true") != NULL); + free(body); + body = NULL; + r = http_get_auth(gw_url("/api/config"), token, &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "\"patched-model\"") != NULL); + ASSERT(strstr(body, "\"max_tokens\":2048") != NULL || + strstr(body, "\"max_tokens\": 2048") != NULL); + free(body); + return 0; +} + static int test_api_skills_list(const char *token) { long code; @@ -1608,6 +1636,10 @@ int main(int argc, char **argv) fprintf(stderr, "test_api_config_put_valid failed\n"); failed++; } + if (test_api_config_put_json(token, port) != 0) { + fprintf(stderr, "test_api_config_put_json failed\n"); + failed++; + } if (test_api_status_get(token) != 0) { fprintf(stderr, "test_api_status_get failed\n"); failed++; } if (test_api_context_snapshot_get(token) != 0) { fprintf(stderr, "test_api_context_snapshot_get failed\n"); failed++; } if (test_api_skills_list(token) != 0) { fprintf(stderr, "test_api_skills_list failed\n"); failed++; } diff --git a/tests/test_reload.c b/tests/test_reload.c index d3ff4f3..6ced210 100644 --- a/tests/test_reload.c +++ b/tests/test_reload.c @@ -174,6 +174,44 @@ static int test_try_config_reload_null_args_noop(void) return 0; } +static int test_try_config_reload_ignores_stale_caller_pointer(void) +{ + char path[128]; + config_t *main_ptr = NULL; + config_t *http_ptr = NULL; + ASSERT(test_runner_mkstemp_path("shellclaw_test_reload", path, sizeof(path)) == 0); + main_ptr = load_minimal_config(path, "gen0"); + ASSERT(main_ptr != NULL); + bootstrap_set_config_path(path); + bootstrap_set_cfg(main_ptr); + http_ptr = bootstrap_get_cfg(); + { + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"gen1\"\n"); + fclose(f); + } + try_config_reload(&http_ptr); + ASSERT(http_ptr != NULL); + ASSERT(strcmp(config_agent_model(http_ptr), "gen1") == 0); + ASSERT(main_ptr != http_ptr); + { + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"gen2\"\n"); + fclose(f); + } + try_config_reload(&main_ptr); + ASSERT(main_ptr != NULL); + ASSERT(strcmp(config_agent_model(main_ptr), "gen2") == 0); + ASSERT(main_ptr == bootstrap_get_cfg()); + stale_free_all(); + config_free(main_ptr); + bootstrap_set_cfg(NULL); + remove(path); + return 0; +} + int main(void) { RUN(test_on_hup_sets_reload_flag()); @@ -183,6 +221,7 @@ int main(void) RUN(test_try_config_reload_swaps_live_config()); RUN(test_try_config_reload_keeps_old_on_invalid_file()); RUN(test_try_config_reload_null_args_noop()); + RUN(test_try_config_reload_ignores_stale_caller_pointer()); printf("test_reload: all tests passed\n"); return 0; } From b8f50eb7c1ab30ae91c4366eee71a7d28514c0f2 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 13:33:19 -0300 Subject: [PATCH 74/92] test(gateway): cover oversized PUT /api/config 413 Transport already rejects bodies over 64 KiB before routes run; keep that boundary under test so dashboard saves cannot truncate config. Refs: #56 --- tests/test_gateway_http.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 08d5617..09c34fc 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1029,6 +1029,27 @@ static int test_api_config_put_invalid_toml(const char *token) return 0; } +static int test_api_config_put_rejects_oversized_body(const char *token) +{ + long code; + char *body = NULL; + char *huge; + size_t n = 70000; + int r; + huge = malloc(n + 1); + ASSERT(huge != NULL); + memset(huge, 'a', n); + huge[n] = '\0'; + r = http_put_auth(gw_url("/api/config"), token, huge, &code, &body); + free(huge); + ASSERT(r == 0); + ASSERT(code == 413); + ASSERT(body != NULL); + ASSERT(strstr(body, "large") != NULL || strstr(body, "error") != NULL); + free(body); + return 0; +} + static int test_api_config_put_valid(const char *token, int port, const char *config_path) { long code; @@ -1632,6 +1653,10 @@ int main(int argc, char **argv) fprintf(stderr, "test_api_config_put_invalid_toml failed\n"); failed++; } + if (test_api_config_put_rejects_oversized_body(token) != 0) { + fprintf(stderr, "test_api_config_put_rejects_oversized_body failed\n"); + failed++; + } if (test_api_config_put_valid(token, port, config_path) != 0) { fprintf(stderr, "test_api_config_put_valid failed\n"); failed++; From c261e4571040c2c8f63ade0477ba954a0c189366 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 18:16:57 -0300 Subject: [PATCH 75/92] refactor(asap): drop owned_payload alias in fill_response_envelope Assign payload directly to out->payload. The extra cJSON_Delete is already gone; the local alias was only for cppcheck. --- src/asap/server.c | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/asap/server.c b/src/asap/server.c index 3aea332..63bebdc 100644 --- a/src/asap/server.c +++ b/src/asap/server.c @@ -84,7 +84,6 @@ static int fill_response_envelope(asap_envelope_t *out, const asap_envelope_t *i const char *payload_type, cJSON *payload) { char ulid_buf[ULID_STRING_LEN + 1]; - cJSON *owned_payload; if (!out || !in || !payload_type || !payload) { if (payload) cJSON_Delete(payload); return -32603; @@ -101,8 +100,7 @@ static int fill_response_envelope(asap_envelope_t *out, const asap_envelope_t *i out->recipient = in->sender ? strdup(in->sender) : NULL; out->payload_type = strdup(payload_type); /* Ownership of payload moves to out; asap_envelope_clear frees it once. */ - owned_payload = payload; - out->payload = owned_payload; + out->payload = payload; if (in->correlation_id) out->correlation_id = strdup(in->correlation_id); if (in->trace_id) From bfd96851e8f73a4c1387dee67e455df4dc909a94 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 18:18:17 -0300 Subject: [PATCH 76/92] fix(gateway): treat NULL body as exceeding static cap Fail closed if a later caller forgets to pass &conn->body instead of skipping the 64 KiB check. --- src/gateway/asap_http_body.c | 4 +++- src/gateway/asap_http_body.h | 3 ++- tests/test_asap_http_body.c | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/gateway/asap_http_body.c b/src/gateway/asap_http_body.c index e62f562..46f7475 100644 --- a/src/gateway/asap_http_body.c +++ b/src/gateway/asap_http_body.c @@ -46,7 +46,9 @@ int asap_http_body_parse_content_length(const char *cl_buf, long *cl_out) int asap_http_body_exceeds_static_cap(const asap_http_body_t *body, long content_length) { - if (!body || body->use_dyn_body) + if (!body) + return 1; + if (body->use_dyn_body) return 0; return content_length > (long)BODY_BUF_SIZE; } diff --git a/src/gateway/asap_http_body.h b/src/gateway/asap_http_body.h index 8ad59aa..7728866 100644 --- a/src/gateway/asap_http_body.h +++ b/src/gateway/asap_http_body.h @@ -34,11 +34,12 @@ int asap_http_body_parse_content_length(const char *cl_buf, long *cl_out); * True when Content-Length exceeds the static POST/PUT cap (BODY_BUF_SIZE). * POST /asap uses a 1 MiB dynamic buffer (use_dyn_body); skip the static cap * so envelopes between 64 KiB and ASAP_BODY_MAX are not 413'd. + * A NULL body pointer is fail-closed (returns 1). * * Example: asap_http_body_exceeds_static_cap(&body, 70000) is 0 when * body.use_dyn_body is set, and 1 for the default static buffer. * - * @return 1 if the static cap applies and is exceeded; 0 otherwise. + * @return 1 if the static cap applies and is exceeded, or body is NULL; 0 otherwise. */ int asap_http_body_exceeds_static_cap(const asap_http_body_t *body, long content_length); diff --git a/tests/test_asap_http_body.c b/tests/test_asap_http_body.c index 9af8c37..b7bb98c 100644 --- a/tests/test_asap_http_body.c +++ b/tests/test_asap_http_body.c @@ -73,7 +73,8 @@ static int test_exceeds_static_cap(void) asap_http_body_t body; memset(&body, 0, sizeof(body)); - ASSERT(asap_http_body_exceeds_static_cap(NULL, (long)BODY_BUF_SIZE + 1) == 0); + ASSERT(asap_http_body_exceeds_static_cap(NULL, (long)BODY_BUF_SIZE + 1) == 1); + ASSERT(asap_http_body_exceeds_static_cap(NULL, 0) == 1); ASSERT(asap_http_body_exceeds_static_cap(&body, (long)BODY_BUF_SIZE) == 0); ASSERT(asap_http_body_exceeds_static_cap(&body, (long)BODY_BUF_SIZE + 1) == 1); body.use_dyn_body = 1; From 7c3d1e27237c7455127d5d2f041467fdc7519fbc Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 18:18:43 -0300 Subject: [PATCH 77/92] docs(asap): document inbound 1 MiB POST /asap body cap Keep the leftover 64 KiB static BODY_BUF_SIZE check off inbound /asap. --- docs/ASAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ASAP.md b/docs/ASAP.md index 6a1d344..8426463 100644 --- a/docs/ASAP.md +++ b/docs/ASAP.md @@ -16,7 +16,7 @@ When the gateway is enabled and signing keys load successfully, ShellClaw serves |-------|------|----------| | `GET /.well-known/asap/manifest.json` | Public | **SignedManifest** JSON (inner manifest + Ed25519 signature + public_key) | | `GET /.well-known/asap/health` | Public | Minimal health stub (`{"status":"ok"}` in v1.0) | -| `POST /asap` | Rate-limited | JSON-RPC ASAP ingress: `task.request` / `mcp.tool_call` dispatch with the process provider and tool table. Serialized responses larger than the 64 KiB gateway HTTP buffer (`RESP_BUF_SIZE`) are rejected with HTTP 500 / JSON-RPC `-32603` rather than truncated. Compliance harness shape is still partial (see Known gaps). | +| `POST /asap` | Rate-limited | JSON-RPC ASAP ingress: `task.request` / `mcp.tool_call` dispatch with the process provider and tool table. Inbound bodies use a 1 MiB dynamic buffer (`ASAP_BODY_MAX`); do not reintroduce the 64 KiB static `BODY_BUF_SIZE` check used by `PUT /api/config`. Serialized responses larger than the 64 KiB gateway HTTP buffer (`RESP_BUF_SIZE`) are rejected with HTTP 500 / JSON-RPC `-32603` rather than truncated. Compliance harness shape is still partial (see Known gaps). | | `GET /api/asap/log` | Bearer | Inbound ASAP message log | Implementation: `src/asap/manifest.c`, `src/gateway/routes.c`. If keys cannot load, manifest route returns **500** and agent startup fails fast (`init_subsystems()`). From 5c214113970609cff96e3cf82d43e41bccbd9fc1 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 18:19:03 -0300 Subject: [PATCH 78/92] test(gateway): record POST /asap rate-limit headroom Seven counted POSTs in the 60s window leave 3 of 10 slots; the 1 MiB 413 path is LWS init and is not counted. --- tests/test_gateway_http.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 6d087b5..a471390 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1609,6 +1609,13 @@ int main(int argc, char **argv) failed++; } if (test_health_wellknown() != 0) { fprintf(stderr, "test_health_wellknown failed\n"); failed++; } + /* + * Per-IP /asap is ASAP_RATE_LIMIT_RPM (10) per 60s, counted in + * handle_asap after dyn malloc. CL > 1 MiB 413s at LWS init and is + * not counted. Seven POSTs reach handle_asap (invalid_body, + * missing_fields, task_request, mcp_tool_call, mcp_unknown_tool, + * oversized_response, body_over_static_cap_accepted). Headroom is 3. + */ if (test_asap_body_over_max() != 0) { fprintf(stderr, "test_asap_body_over_max failed\n"); failed++; From c0c7d82bc165c7754d1c14bb477fecb257933f00 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 13:17:20 -0300 Subject: [PATCH 79/92] fix(config): patch indented TOML and reject mistyped dashboard JSON Formatter-indented keys and commented section headers were missed, so a dashboard save inserted a duplicate key and tomlc99 returned 400. A present field with the wrong JSON type now fails instead of a 200 no-op, and string values escape quotes and newlines. --- src/core/config_patch.c | 82 +++++++++++++++++++++++++++++++-------- tests/test_config_patch.c | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 16 deletions(-) diff --git a/src/core/config_patch.c b/src/core/config_patch.c index dc0943a..38b9684 100644 --- a/src/core/config_patch.c +++ b/src/core/config_patch.c @@ -124,7 +124,16 @@ static int escape_toml_string(const char *in, char **out) (*out)[0] = '"'; len = 1; for (i = 0; in[i]; i++) { - if (in[i] == '"' || in[i] == '\\') { + char extra = 0; + if (in[i] == '"' || in[i] == '\\') + extra = in[i]; + else if (in[i] == '\n') + extra = 'n'; + else if (in[i] == '\r') + extra = 'r'; + else if (in[i] == '\t') + extra = 't'; + if (extra != 0) { if (len + 2 >= cap) { char *grown; cap *= 2; @@ -137,6 +146,8 @@ static int escape_toml_string(const char *in, char **out) *out = grown; } (*out)[len++] = '\\'; + (*out)[len++] = extra; + continue; } if (len + 1 >= cap) { char *grown; @@ -156,6 +167,17 @@ static int escape_toml_string(const char *in, char **out) return 0; } +static int section_header_closed(const char *after) +{ + if (!after) + return 0; + while (*after == ' ' || *after == '\t') + after++; + if (*after == '#' || *after == '\0' || *after == '\r' || *after == '\n') + return 1; + return 0; +} + static const char *find_section(const char *content, const char *section) { char marker[128]; @@ -170,7 +192,7 @@ static const char *find_section(const char *content, const char *section) continue; if (p != content && p[-1] != '\n') continue; - if (p[marker_len] != '\0' && p[marker_len] != '\r' && p[marker_len] != '\n') + if (!section_header_closed(p + marker_len)) continue; return p; } @@ -202,18 +224,22 @@ static const char *find_key_line(const char *sec_start, const char *sec_end, key_len = strlen(key); for (p = sec_start; p < sec_end; p++) { const char *line_end = strchr(p, '\n'); - size_t span; if (!line_end || line_end > sec_end) line_end = sec_end; - span = (size_t)(line_end - p); - while (span > 0 && isspace((unsigned char)p[span - 1])) - span--; + { + const char *key_at = p; + size_t span; + while (key_at < line_end && (*key_at == ' ' || *key_at == '\t')) + key_at++; + span = (size_t)(line_end - key_at); + while (span > 0 && isspace((unsigned char)key_at[span - 1])) + span--; if (span > key_len) { - const char *after_key = p + key_len; + const char *after_key = key_at + key_len; while (after_key < line_end && (*after_key == ' ' || *after_key == '\t')) after_key++; - if (strncmp(p, key, key_len) == 0 && after_key < line_end && + if (strncmp(key_at, key, key_len) == 0 && after_key < line_end && *after_key == '=') { *line_len = (size_t)(line_end - p); if (*line_end == '\n') @@ -221,6 +247,7 @@ static const char *find_key_line(const char *sec_start, const char *sec_end, return p; } } + } if (!*line_end) break; p = line_end; @@ -309,28 +336,50 @@ static int patch_double_field(char **content, size_t *len, size_t *cap, const ch return patch_key_line(content, len, cap, section, key, buf); } -static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size_t *cap) +static int reject_wrong_type(const cJSON *item, int expect_string, const char *field, + char *errbuf, size_t errbufsz) +{ + int ok; + if (!item) + return 0; + ok = expect_string ? cJSON_IsString(item) : cJSON_IsNumber(item); + if (ok) + return 0; + if (errbuf && errbufsz > 0) + snprintf(errbuf, errbufsz, "field \"%s\" must be a JSON %s", field, + expect_string ? "string" : "number"); + return -1; +} + +static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size_t *cap, + char *errbuf, size_t errbufsz) { cJSON *model = cJSON_GetObjectItem(root, "model"); cJSON *max_tokens = cJSON_GetObjectItem(root, "max_tokens"); cJSON *temperature = cJSON_GetObjectItem(root, "temperature"); cJSON *gateway_host = cJSON_GetObjectItem(root, "gateway_host"); cJSON *gateway_port = cJSON_GetObjectItem(root, "gateway_port"); - if (model && cJSON_IsString(model) && + if (reject_wrong_type(model, 1, "model", errbuf, errbufsz) != 0 || + reject_wrong_type(max_tokens, 0, "max_tokens", errbuf, errbufsz) != 0 || + reject_wrong_type(temperature, 0, "temperature", errbuf, errbufsz) != 0 || + reject_wrong_type(gateway_host, 1, "gateway_host", errbuf, errbufsz) != 0 || + reject_wrong_type(gateway_port, 0, "gateway_port", errbuf, errbufsz) != 0) + return -1; + if (model && patch_string_field(content, len, cap, "agent", "model", model->valuestring) != 0) return -1; - if (max_tokens && cJSON_IsNumber(max_tokens) && + if (max_tokens && patch_int_field(content, len, cap, "agent", "max_tokens", max_tokens->valueint) != 0) return -1; - if (temperature && cJSON_IsNumber(temperature) && + if (temperature && patch_double_field(content, len, cap, "agent", "temperature", temperature->valuedouble) != 0) return -1; - if (gateway_host && cJSON_IsString(gateway_host) && + if (gateway_host && patch_string_field(content, len, cap, "gateway", "host", gateway_host->valuestring) != 0) return -1; - if (gateway_port && cJSON_IsNumber(gateway_port) && + if (gateway_port && patch_int_field(content, len, cap, "gateway", "port", gateway_port->valueint) != 0) return -1; return 0; @@ -400,8 +449,9 @@ int config_patch_dashboard_json(const char *config_path, const char *json_body, return -1; } cap = len + 1; - if (apply_dashboard_fields(root, &content, &len, &cap) != 0) { - PATCH_ERR(errbuf, errbufsz, "failed to patch config fields"); + if (apply_dashboard_fields(root, &content, &len, &cap, errbuf, errbufsz) != 0) { + if (!errbuf || errbufsz == 0 || errbuf[0] == '\0') + PATCH_ERR(errbuf, errbufsz, "failed to patch config fields"); free(content); cJSON_Delete(root); return -1; diff --git a/tests/test_config_patch.c b/tests/test_config_patch.c index 95e2778..315202e 100644 --- a/tests/test_config_patch.c +++ b/tests/test_config_patch.c @@ -87,6 +87,74 @@ static int test_patch_rejects_invalid_json(void) return 0; } +static int test_patch_indented_key_and_commented_section(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + config_t *cfg = NULL; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, + "[agent] # live\n model = \"old\"\n" + "[gateway]\n host = \"127.0.0.1\"\n port = 18789\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":\"new\",\"gateway_port\":19000}", + &patched, &patched_len, errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "model = \"new\"") != NULL); + ASSERT(strstr(patched, "model = \"old\"") == NULL); + ASSERT(strstr(patched, "port = 19000") != NULL); + ASSERT(strstr(patched, "[agent]") != NULL); + /* One [agent] header, not a duplicate appended after a missed comment. */ + ASSERT(strstr(strstr(patched, "[agent]") + 1, "[agent]") == NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(strcmp(config_agent_model(cfg), "new") == 0); + ASSERT(config_gateway_port(cfg) == 19000); + config_free(cfg); + remove(path); + return 0; +} + +static int test_patch_escapes_quotes_and_newlines(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":\"a\\\"b\\nc\"}", &patched, &patched_len, + errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "model = \"a\\\"b\\nc\"") != NULL); + free(patched); + remove(path); + return 0; +} + +static int test_patch_rejects_wrong_json_types(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old\"\nmax_tokens = 1024\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":123,\"max_tokens\":\"nope\"}", + &patched, &patched_len, errbuf, sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + ASSERT(strstr(errbuf, "model") != NULL); + memset(errbuf, 0, sizeof(errbuf)); + ASSERT(config_patch_dashboard_json(path, "{\"model\":null}", &patched, &patched_len, errbuf, + sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + ASSERT(strstr(errbuf, "model") != NULL); + remove(path); + return 0; +} + static int test_patch_creates_missing_section(void) { char path[128]; @@ -130,6 +198,18 @@ int main(void) fprintf(stderr, "test_patch_creates_missing_section failed\n"); failed++; } + if (test_patch_indented_key_and_commented_section() != 0) { + fprintf(stderr, "test_patch_indented_key_and_commented_section failed\n"); + failed++; + } + if (test_patch_escapes_quotes_and_newlines() != 0) { + fprintf(stderr, "test_patch_escapes_quotes_and_newlines failed\n"); + failed++; + } + if (test_patch_rejects_wrong_json_types() != 0) { + fprintf(stderr, "test_patch_rejects_wrong_json_types failed\n"); + failed++; + } if (failed == 0) printf("test_config_patch: all tests passed\n"); return failed; From eedd31ca3825a369c9afd667231ed19da0e4c999 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 13:17:20 -0300 Subject: [PATCH 80/92] fix(gateway): lock config reload and fail PUT when it does not apply Dashboard save and SIGHUP both swap the live config. Hold agent_lock on both paths so the same pointer cannot be queued twice, and return HTTP 500 when the file is saved but the live reload does not. --- CHANGELOG.md | 1 + Makefile | 2 +- src/core/main.c | 3 +++ src/core/reload.c | 11 ++++++----- src/core/reload.h | 4 +++- src/gateway/routes.c | 20 ++++++++++++++++---- tests/test_gateway_http.c | 4 ++++ tests/test_reload.c | 2 +- web/js/app.js | 1 + 9 files changed, 36 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831d9ac..f0b6c6d 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 +- Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Leaf workspace symlinks (dangling or an in-workspace alias) are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). - `write_file` persists via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe an existing workspace file and a sibling `path.tmp` is not truncated (#78). diff --git a/Makefile b/Makefile index afb29ba..83d0c22 100644 --- a/Makefile +++ b/Makefile @@ -189,7 +189,7 @@ shellclaw: $(SHELLCLAW_OBJS) $(CONFIG_O): src/core/config.c src/core/config.h $(CC) $(CFLAGS) $(INC) -c -o $@ $< -$(MAIN_O): src/core/main.c src/asap/manifest.h src/core/config.h src/core/bootstrap.h src/core/daemon.h src/core/dispatch.h src/core/reload.h src/channels/channel.h src/hardware/board_detect.h src/providers/provider.h +$(MAIN_O): src/core/main.c src/asap/manifest.h src/core/agent.h src/core/config.h src/core/bootstrap.h src/core/daemon.h src/core/dispatch.h src/core/reload.h src/channels/channel.h src/hardware/board_detect.h src/providers/provider.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/main.c $(DAEMON_O): src/core/daemon.c src/core/daemon.h src/core/config.h diff --git a/src/core/main.c b/src/core/main.c index a9ac570..30ae75e 100644 --- a/src/core/main.c +++ b/src/core/main.c @@ -20,6 +20,7 @@ #include "core/bootstrap.h" #include "core/config.h" #include "core/daemon.h" +#include "core/agent.h" #include "core/dispatch.h" #include "core/reload.h" #include "core/version.h" @@ -67,7 +68,9 @@ static void main_loop(int one_shot, config_t **pcfg) while (!g_shutdown) { if (g_reload_requested) { g_reload_requested = 0; + agent_lock(); try_config_reload(pcfg); + agent_unlock(); } provider_router_periodic_recovery_tick(time(NULL)); channel_incoming_msg_t msg; diff --git a/src/core/reload.c b/src/core/reload.c index 1520a2a..17c64e5 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -59,14 +59,14 @@ void stale_free_all(void) g_stale_cfg_head = NULL; } -void try_config_reload(config_t **pcfg) +int try_config_reload(config_t **pcfg) { config_t *old; config_t *new_cfg; char errbuf[256]; const char *config_path = bootstrap_get_config_path(); if (!pcfg || !*pcfg || !config_path) - return; + return -1; /* Dashboard PUT may reload from the HTTP thread while main still holds the * previous pointer. Always enqueue the live bootstrap cfg, not the caller's * possibly-stale copy, so a later SIGHUP does not double-free. */ @@ -74,17 +74,17 @@ void try_config_reload(config_t **pcfg) if (!old) old = *pcfg; if (!old) - return; + return -1; new_cfg = NULL; if (config_load(config_path, &new_cfg, errbuf, sizeof(errbuf)) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: %s\n", errbuf[0] ? errbuf : "unknown error"); - return; + return -1; } if (stale_enqueue(old) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: out of memory\n"); config_free(new_cfg); - return; + return -1; } provider_router_set_live_config(new_cfg); provider_openai_set_live_config(new_cfg); @@ -99,4 +99,5 @@ void try_config_reload(config_t **pcfg) *pcfg = new_cfg; bootstrap_set_cfg(new_cfg); fprintf(stderr, "shellclaw: config reloaded from %s\n", config_path); + return 0; } diff --git a/src/core/reload.h b/src/core/reload.h index 4f324db..29caf9c 100644 --- a/src/core/reload.h +++ b/src/core/reload.h @@ -27,9 +27,11 @@ void stale_free_all(void); /** * Re-parse config and swap live pointers. Old config is queued via stale_enqueue(). + * Callers on different threads must hold agent_lock() around this call. * @param pcfg In/out active config pointer (updated on success). + * @return 0 on success, -1 if reload did not swap the live config. */ -void try_config_reload(config_t **pcfg); +int try_config_reload(config_t **pcfg); #ifdef __cplusplus } diff --git a/src/gateway/routes.c b/src/gateway/routes.c index a59936f..e917c22 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -15,6 +15,7 @@ #include "asap/envelope.h" #include "asap/server.h" #include "asap/log.h" +#include "core/agent.h" #include "core/bootstrap.h" #include "core/config.h" #include "core/config_patch.h" @@ -295,12 +296,23 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b free(tmp_path); free(patched_body); /* Dashboard/TOML save: swap live cfg now instead of waiting for SIGHUP. - * Call http_set_live_config here: test_reload rebuilds reload.o with - * GATEWAY=0, so try_config_reload may omit the gateway pointer swap. */ + * agent_lock matches the SIGHUP path in main_loop so the two threads cannot + * enqueue the same pointer. http_set_live_config stays here because + * test_reload rebuilds reload.o with GATEWAY=0. */ { config_t *live_cfg = bootstrap_get_cfg(); - if (live_cfg) - try_config_reload(&live_cfg); + int reload_rc; + if (!live_cfg) { + json_error(buf, size, status, 500, "Config saved but live reload failed"); + return; + } + agent_lock(); + reload_rc = try_config_reload(&live_cfg); + agent_unlock(); + if (reload_rc != 0) { + json_error(buf, size, status, 500, "Config saved but live reload failed"); + return; + } http_set_live_config(bootstrap_get_cfg()); } *status = 200; diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 10bb37a..7061e71 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1144,6 +1144,10 @@ static int test_api_config_put_json(const char *token, int port) ASSERT(strstr(body, "\"patched-model\"") != NULL); ASSERT(strstr(body, "\"max_tokens\":2048") != NULL || strstr(body, "\"max_tokens\": 2048") != NULL); + ASSERT(strstr(body, "\"temperature\":0.5") != NULL || + strstr(body, "\"temperature\": 0.5") != NULL); + ASSERT(strstr(body, "\"gateway_host\":\"127.0.0.1\"") != NULL || + strstr(body, "\"gateway_host\": \"127.0.0.1\"") != NULL); free(body); return 0; } diff --git a/tests/test_reload.c b/tests/test_reload.c index 6ced210..37e7a75 100644 --- a/tests/test_reload.c +++ b/tests/test_reload.c @@ -157,7 +157,7 @@ static int test_try_config_reload_keeps_old_on_invalid_file(void) fprintf(f, "[memory]\ndb_path = \"/tmp/db\"\n"); fclose(f); } - try_config_reload(&cfg); + ASSERT(try_config_reload(&cfg) != 0); ASSERT(cfg != NULL); ASSERT(strcmp(config_agent_model(cfg), "still-valid") == 0); stale_free_all(); diff --git a/web/js/app.js b/web/js/app.js index a225248..03aea9c 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -160,6 +160,7 @@ '
' + '
' + '
' + + '

Gateway host and port are saved now. The process keeps the current listen address until restart.

' + ''); document.getElementById('config-form').onsubmit = function (e) { e.preventDefault(); From 41fdaf85464c8a55abce994b7b4b108d3e4ef674 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 13:53:26 -0300 Subject: [PATCH 81/92] fix(test): size allowlist state paths for format truncation GCC -Werror=format-truncation rejected snprintf of a 256-byte directory plus /config.toml into another 256-byte buffer. --- tests/test_allowlist.c | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index 346e8c5..e45a8fe 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -12,6 +12,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/allowlist.h" +#include #include #include #include @@ -122,13 +123,13 @@ static int test_block_auth_tokens_json(void) static int test_block_state_dir_config_and_memory(void) { char dir[] = "/tmp/sc_al_state_XXXXXX"; - char state[256]; - char cfg_path[256]; - char db_path[256]; + char state[PATH_MAX - 32]; + char cfg_path[PATH_MAX]; + char db_path[PATH_MAX]; char *tmp; FILE *f; allowlist_config_t acfg; - char cmd[512]; + char cmd[PATH_MAX + 16]; tmp = mkdtemp(dir); if (!tmp) { From e63a7ecae9e8e5e8c0ef34bebb7194a30d217fab Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 14:01:55 -0300 Subject: [PATCH 82/92] fix(sandbox): block state sidecars and workspace-relative shell names WAL sidecars next to memory.db were readable, and a bare cat config.toml ran after the sandbox chdir. Also refuse a symlink workspace and apply the same state-file check on the unsandboxed shell path. --- CHANGELOG.md | 2 +- src/core/bootstrap.c | 29 +++++++++-- src/sandbox/allowlist.c | 103 ++++++++++++++++++++++++++++++++++------ src/sandbox/allowlist.h | 3 +- src/tools/shell.c | 17 +++++-- tests/test_allowlist.c | 17 +++++++ tests/test_config.c | 3 +- tests/test_file.c | 7 +-- tests/test_shell.c | 35 +++++++++++++- 9 files changed, 184 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a3c69..cfb4f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed -- Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` when a custom workspace contains them. +- Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` (including `memory.db-*` sidecars) when a custom workspace contains them. A bare `cat config.toml` after the sandbox `chdir` is blocked, and a symlink at the workspace path is not accepted. - Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Leaf workspace symlinks (dangling or an in-workspace alias) are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index 58de247..92ac554 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -237,11 +237,30 @@ static void channels_cleanup(void) g_cfg = NULL; } -static void ensure_workspace_directory(const char *workspace) +static int workspace_is_real_dir(const char *workspace) +{ + struct stat st; + + if (lstat(workspace, &st) != 0) { + fprintf(stderr, "shellclaw: workspace %s: %s\n", workspace, strerror(errno)); + return 0; + } + if (S_ISLNK(st.st_mode)) { + fprintf(stderr, "shellclaw: workspace %s is a symlink\n", workspace); + return 0; + } + if (!S_ISDIR(st.st_mode)) { + fprintf(stderr, "shellclaw: workspace %s is not a directory\n", workspace); + return 0; + } + return 1; +} + +static int ensure_workspace_directory(const char *workspace) { const char *slash; - if (!workspace || !workspace[0]) return; + if (!workspace || !workspace[0]) return 0; slash = strrchr(workspace, '/'); if (slash && slash != workspace) { char parent[PATH_MAX]; @@ -257,11 +276,15 @@ static void ensure_workspace_directory(const char *workspace) if (mkdir(workspace, 0700) != 0 && errno != EEXIST) fprintf(stderr, "shellclaw: mkdir workspace %s: %s\n", workspace, strerror(errno)); + if (!workspace_is_real_dir(workspace)) + return -1; + return 0; } int tools_init(const config_t *cfg) { - ensure_workspace_directory(config_workspace_path(cfg)); + if (ensure_workspace_directory(config_workspace_path(cfg)) != 0) + return -1; tool_set_config(cfg); g_tool_count = tool_get_all(g_tools, SHELLCLAW_MAX_TOOLS); return 0; diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index 6f1e62e..f3a67f2 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -141,7 +141,8 @@ int allowlist_path_is_runtime_state_file(const char *path) strcmp(base, "shellclaw.pid") == 0 || strcmp(base, "shellclaw.log") == 0) return 1; - if (strcmp(base, "config.toml") != 0 && strcmp(base, "memory.db") != 0) + if (strcmp(base, "config.toml") != 0 && strcmp(base, "memory.db") != 0 && + strncmp(base, "memory.db-", 10) != 0) return 0; slash = strrchr(use, '/'); if (!slash || slash == use) @@ -156,6 +157,66 @@ int allowlist_path_is_runtime_state_file(const char *path) return strcmp(slash, ".shellclaw") == 0; } +/** Copy @p rel under @p root, collapsing "." and "..". Absolute @p rel is copied as-is. */ +static int join_under_root(const char *root, const char *rel, char *out, size_t cap) +{ + char tmp[PATH_MAX]; + char *dup; + char *save = NULL; + char *tok; + char *stack[48] = {0}; + int nstack = 0; + int i; + size_t used; + + if (!rel || !rel[0] || !out || cap == 0) + return -1; + if (rel[0] == '/') { + if (strlen(rel) + 1 > cap) + return -1; + memcpy(out, rel, strlen(rel) + 1); + return 0; + } + if (!root || !root[0]) + return -1; + if (snprintf(tmp, sizeof(tmp), "%s/%s", root, rel) >= (int)sizeof(tmp)) + return -1; + dup = strdup(tmp); + if (!dup) + return -1; + for (tok = strtok_r(dup, "/", &save); tok; tok = strtok_r(NULL, "/", &save)) { + if (strcmp(tok, ".") == 0) + continue; + if (strcmp(tok, "..") == 0) { + if (nstack > 0) + nstack--; + continue; + } + if (nstack >= (int)(sizeof(stack) / sizeof(stack[0]))) { + free(dup); + return -1; + } + stack[nstack++] = tok; + } + used = 0; + out[0] = '\0'; + for (i = 0; i < nstack; i++) { + size_t part = strlen(stack[i]); + if (used + 1 + part + 1 > cap) { + free(dup); + return -1; + } + out[used++] = '/'; + memcpy(out + used, stack[i], part); + used += part; + out[used] = '\0'; + } + free(dup); + if (used == 0) + return -1; + return 0; +} + /* ------------------------------------------------------------------ */ /* Public: combined check */ /* ------------------------------------------------------------------ */ @@ -190,20 +251,20 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg return 1; } } - /* Phase 2: workspace path containment */ - if (!cfg || !cfg->workspace_only || !cfg->workspace_path || !cfg->workspace_path[0]) - return 0; - workspace_only = cfg->workspace_only; - (void)workspace_only; - /* Resolve workspace root once */ - if (!realpath(cfg->workspace_path, ws_resolved)) { - /* Workspace path does not exist; use as-is. */ - size_t n = strlen(cfg->workspace_path); - if (n >= PATH_MAX) n = PATH_MAX - 1; - memcpy(ws_resolved, cfg->workspace_path, n); - ws_resolved[n] = '\0'; + /* Phase 2: runtime-state paths, then optional workspace containment. + * State files are rejected even when workspace_only is off, so an + * unsandboxed `cat ~/.shellclaw/config.toml` cannot skip the check. */ + workspace_only = cfg && cfg->workspace_only && cfg->workspace_path && + cfg->workspace_path[0]; + if (cfg && cfg->workspace_path && cfg->workspace_path[0]) { + if (!realpath(cfg->workspace_path, ws_resolved)) { + size_t n = strlen(cfg->workspace_path); + if (n >= PATH_MAX) n = PATH_MAX - 1; + memcpy(ws_resolved, cfg->workspace_path, n); + ws_resolved[n] = '\0'; + } + workspace_root = ws_resolved; } - workspace_root = ws_resolved; /* Tokenize the command and check each path-like token. */ cmd_copy = strdup(cmd); if (!cmd_copy) return 0; /* fail-open on OOM */ @@ -226,7 +287,19 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg free(cmd_copy); return 1; } - if (has_path_chars(tok)) { + /* sandbox_exec chdirs into the workspace, so a bare name is that file. */ + if (workspace_root && check[0] != '/') { + char joined[PATH_MAX]; + if (join_under_root(workspace_root, check, joined, sizeof(joined)) == 0 && + allowlist_path_is_runtime_state_file(joined)) { + set_reason(reason_buf, reason_cap, + "command blocked: runtime state file: ", joined); + fprintf(stderr, "allowlist: blocked runtime state file: %s\n", joined); + free(cmd_copy); + return 1; + } + } + if (workspace_only && has_path_chars(tok)) { if (!allowlist_path_is_under_workspace(check, workspace_root)) { set_reason(reason_buf, reason_cap, "command blocked: path escapes workspace: ", check); diff --git a/src/sandbox/allowlist.h b/src/sandbox/allowlist.h index 5f64c2e..b2d8ea1 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -70,7 +70,8 @@ int allowlist_path_is_under_workspace(const char *path, const char *workspace_ro * Return 1 if @p path is a ShellClaw runtime state file that tools must not touch. * * Always reserved by basename: auth_tokens.json, shellclaw.pid, shellclaw.log. - * Also reserved when the parent directory is named `.shellclaw`: config.toml, memory.db. + * Also reserved when the parent directory is named `.shellclaw`: config.toml, + * memory.db, and memory.db-* sidecars (WAL, shm, journal). * * @param path Absolute, relative, or unresolved path (realpath used when the file exists). * @return 1 if reserved, 0 otherwise. diff --git a/src/tools/shell.c b/src/tools/shell.c index fd5fe28..d2c02fa 100644 --- a/src/tools/shell.c +++ b/src/tools/shell.c @@ -233,13 +233,20 @@ static int shell_execute(const char *args_json, char *result_buf, size_t max_len free(command); return rc; } - /* Fallback path: best-effort blocklist + plain fork */ + /* Fallback path: same runtime-state predicate as the allowlist, then + * the substring blocklist. workspace_only stays off so a bare filename + * in the process cwd is not treated as ~/.shellclaw. */ fprintf(stderr, "shell: sandbox disabled — running command with reduced isolation\n"); - if (fallback_is_blocked(command)) { - free(command); - snprintf(result_buf, max_len, "{\"error\":\"command blocked for safety\"}"); - return -1; + { + char reason[256]; + reason[0] = '\0'; + if (allowlist_check_shell_command(command, NULL, reason, sizeof(reason)) || + fallback_is_blocked(command)) { + free(command); + snprintf(result_buf, max_len, "{\"error\":\"command blocked for safety\"}"); + return -1; + } } { int rc = run_unsandboxed(command, timeout_sec, result_buf, max_len); diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index e45a8fe..223b3d9 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -175,6 +175,22 @@ static int test_block_state_dir_config_and_memory(void) return 0; } +static int test_block_memory_sidecars_and_bare_names(void) +{ + allowlist_config_t acfg; + + ASSERT(allowlist_path_is_runtime_state_file("/tmp/x/.shellclaw/memory.db-wal") == 1); + ASSERT(allowlist_path_is_runtime_state_file("/tmp/x/.shellclaw/memory.db-shm") == 1); + ASSERT(allowlist_path_is_runtime_state_file("/tmp/proj/memory.db-wal") == 0); + acfg.workspace_path = "/tmp/x/.shellclaw"; + acfg.workspace_only = 1; + ASSERT(allowlist_check_shell_command("cat config.toml", &acfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat memory.db", &acfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat memory.db-wal", &acfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat notes.txt", &acfg, NULL, 0) == 0); + return 0; +} + static int test_allow_project_config_toml(void) { ASSERT(allowlist_path_is_runtime_state_file("/tmp/project/config.toml") == 0); @@ -297,6 +313,7 @@ int main(void) RUN(test_null_command_blocked()); RUN(test_block_auth_tokens_json()); RUN(test_block_state_dir_config_and_memory()); + RUN(test_block_memory_sidecars_and_bare_names()); RUN(test_allow_project_config_toml()); RUN(test_path_inside_workspace()); RUN(test_path_outside_workspace()); diff --git a/tests/test_config.c b/tests/test_config.c index 5c1e963..c32b633 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -112,7 +112,8 @@ static int test_defaults(void) ASSERT(ws != NULL); n = strlen(ws); ASSERT(n >= 10); - ASSERT(strcmp(ws + n - 10, "/workspace") == 0); + ASSERT(n >= 21); + ASSERT(strcmp(ws + n - 21, "/.shellclaw/workspace") == 0); } config_free(cfg); remove(path); diff --git a/tests/test_file.c b/tests/test_file.c index 9af7050..0907d27 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -224,12 +224,9 @@ static void test_runtime_state_files_rejected_inside_workspace(void) int r; snprintf(tmpdir, sizeof(tmpdir), "/tmp/sc_test_state_%d", (int)getpid()); - if (mkdir(tmpdir, 0755) != 0 && errno != EEXIST) return; + MU_ASSERT(mkdir(tmpdir, 0755) == 0 || errno == EEXIST, "mkdir state tmpdir"); snprintf(state_dir, sizeof(state_dir), "%s/.shellclaw", tmpdir); - if (mkdir(state_dir, 0755) != 0 && errno != EEXIST) { - rmdir(tmpdir); - return; - } + MU_ASSERT(mkdir(state_dir, 0755) == 0 || errno == EEXIST, "mkdir .shellclaw"); snprintf(token_path, sizeof(token_path), "%s/auth_tokens.json", state_dir); snprintf(config_toml, sizeof(config_toml), "%s/config.toml", state_dir); snprintf(memory_db, sizeof(memory_db), "%s/memory.db", state_dir); diff --git a/tests/test_shell.c b/tests/test_shell.c index b310af4..a37341b 100644 --- a/tests/test_shell.c +++ b/tests/test_shell.c @@ -9,8 +9,10 @@ #include "core/config.h" #include #include +#include #include #include +#include #include #include #include @@ -71,11 +73,42 @@ static void test_shell_invalid_json(void) static void test_shell_blocked_auth_tokens(void) { const tool_t *t = tool_shell_get(); - char buf[256]; + char buf[512]; + char home[64]; + char state[128]; + char cfg_path[160]; + char cmd[256]; + char *old_home; + FILE *f; buf[0] = '\0'; tool_shell_set_config(NULL); (void)t->execute("{\"command\":\"cat ~/.shellclaw/auth_tokens.json\"}", buf, sizeof(buf)); MU_ASSERT(strstr(buf, "blocked") != NULL, "cat auth_tokens.json blocked"); + + snprintf(home, sizeof(home), "/tmp/sc_shell_home_%d", (int)getpid()); + snprintf(state, sizeof(state), "%s/.shellclaw", home); + snprintf(cfg_path, sizeof(cfg_path), "%s/config.toml", state); + MU_ASSERT(mkdir(home, 0755) == 0 || errno == EEXIST, "mkdir shell home"); + MU_ASSERT(mkdir(state, 0755) == 0 || errno == EEXIST, "mkdir shell state"); + f = fopen(cfg_path, "w"); + MU_ASSERT(f != NULL, "write state config.toml"); + fputs("secret-config\n", f); + fclose(f); + old_home = getenv("HOME"); + setenv("HOME", home, 1); + snprintf(cmd, sizeof(cmd), + "{\"command\":\"cat %s/.shellclaw/config.toml\"}", home); + buf[0] = '\0'; + (void)t->execute(cmd, buf, sizeof(buf)); + MU_ASSERT(strstr(buf, "blocked") != NULL, "unsandboxed cat state config.toml blocked"); + MU_ASSERT(strstr(buf, "secret-config") == NULL, "state config.toml not returned"); + if (old_home) + setenv("HOME", old_home, 1); + else + unsetenv("HOME"); + unlink(cfg_path); + rmdir(state); + rmdir(home); } static void test_shell_missing_command(void) From 5ebb5a97c29ba02c98681766c0d77bcacf3c164b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:51:29 -0300 Subject: [PATCH 83/92] fix(allowlist): fail closed on workspace escapes and reserved names workspace_only tokenized on whitespace, so a quoted path, a file: URL, or $HOME/$PWD could leave the workspace, and strdup failure fell open. Record the unresolved-path reason before freeing the command copy. Substring-block auth_tokens.json, shellclaw.pid, and shellclaw.log on the whole command so python -c open() cannot skip the basename check. --- CHANGELOG.md | 1 + Makefile | 9 +- src/sandbox/allowlist.c | 782 ++++++++++++++++++++++++++++------- src/sandbox/allowlist.h | 46 ++- src/sandbox/allowlist_path.c | 208 ++++++++++ tests/test_allowlist.c | 541 ++++++++++++++++++++++++ 6 files changed, 1408 insertions(+), 179 deletions(-) create mode 100644 src/sandbox/allowlist_path.c diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb4f95..710582b 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 +- Shell `workspace_only` walks the first existing ancestor, collapses `..` lexically (without cancelling across a symlink), and scans quoted/embedded paths, `file:` URLs, `$HOME`/`$PWD` (including glued `$IFS`), and bare relative names such as `cat leak`. `strdup` OOM is fail-closed. Encoded-slash cat-and-mouse is frozen; Landlock is the kernel host-FS bound. - Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` (including `memory.db-*` sidecars) when a custom workspace contains them. A bare `cat config.toml` after the sandbox `chdir` is blocked, and a symlink at the workspace path is not accepted. - Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). diff --git a/Makefile b/Makefile index 05f4519..ae2a06d 100644 --- a/Makefile +++ b/Makefile @@ -126,7 +126,9 @@ ASAP_INVOKE_O := src/tools/asap_invoke.o ASAP_INVOKE_TEST_O := $(BINDIR)/asap_invoke_test.o # Sandbox (Phase 3 §5) SANDBOX_O := src/sandbox/sandbox.o -ALLOWLIST_O := src/sandbox/allowlist.o +ALLOWLIST_SCAN_O := src/sandbox/allowlist.o +ALLOWLIST_PATH_O := src/sandbox/allowlist_path.o +ALLOWLIST_O := $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) MANIFEST_O := src/asap/manifest.o MANIFEST_PROFILES_O := src/asap/manifest_profiles.o MANIFEST_BUILD_O := src/asap/manifest_build.o @@ -384,9 +386,12 @@ $(SHELL_O): src/tools/shell.c src/tools/tool.h src/tools/shell.h src/core/config $(SANDBOX_O): src/sandbox/sandbox.c src/sandbox/sandbox.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox.c -$(ALLOWLIST_O): src/sandbox/allowlist.c src/sandbox/allowlist.h +$(ALLOWLIST_SCAN_O): src/sandbox/allowlist.c src/sandbox/allowlist.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/allowlist.c +$(ALLOWLIST_PATH_O): src/sandbox/allowlist_path.c src/sandbox/allowlist.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/allowlist_path.c + $(WEBSEARCH_O): src/tools/web_search.c src/tools/tool.h src/tools/web_search.h src/core/config.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/tools/web_search.c diff --git a/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index f3a67f2..7d06dd9 100644 --- a/src/sandbox/allowlist.c +++ b/src/sandbox/allowlist.c @@ -1,23 +1,22 @@ /** * @file allowlist.c - * @brief Shell-command allowlist: built-in blocklist + workspace realpath checks. + * @brief Shell-command allowlist: blocklist plus workspace path scans. + * + * sandbox_exec() does not pivot_root. Landlock is the kernel host-FS bound. + * This scanner is defense-in-depth for quoted/embedded paths, $HOME/$PWD, + * file: URLs, and relative symlink tokens the old strtok gate missed. */ #define _DEFAULT_SOURCE #define _POSIX_C_SOURCE 200809L #include "sandbox/allowlist.h" -#include #include #include #include #include - -/* ------------------------------------------------------------------ */ -/* Built-in blocklist patterns */ -/* ------------------------------------------------------------------ */ +#include static const char *const BLOCK_SUBSTRINGS[] = { - /* Filesystem destroyers */ "rm -rf /", "rm -rf /*", "rm -rf / ", @@ -28,7 +27,6 @@ static const char *const BLOCK_SUBSTRINGS[] = { "> /dev/sd", "dd if=", "dd of=/dev", - /* System lifecycle */ "shutdown", "reboot", "halt", @@ -38,40 +36,35 @@ static const char *const BLOCK_SUBSTRINGS[] = { "systemctl poweroff", "systemctl reboot", "systemctl halt", - /* Fork bombs */ ":(){ :|:& };:", "fork()", ":(){:|:&};:", - /* Privilege escalation */ "chmod 777 /", "chmod -R 777 /", "chown root", "sudo rm -rf", - /* Credential / secret file access */ "/etc/shadow", "/etc/gshadow", "~/.ssh/id_", "id_rsa", "id_ed25519", - "auth_tokens.json", - "shellclaw.pid", - "shellclaw.log", /* Jetson Tegra GPU device nodes (audit 7.1 — not bind-mounted, block direct open) */ "/dev/nvhost", "/dev/nvgpu", "/dev/nvmap", - /* Jetson Argus camera daemon socket (audit 7.3 — agent-only, not shell sandbox) */ "/tmp/argus_socket", + /* Not only token basenames: python -c open('auth_tokens.json') is one + * argument. Sandbox-off already substring-matches these three. */ + "auth_tokens.json", + "shellclaw.pid", + "shellclaw.log", NULL }; -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - static void set_reason(char *buf, size_t cap, const char *prefix, const char *detail) { - if (!buf || cap == 0) return; + if (!buf || cap == 0) + return; if (detail && detail[0]) snprintf(buf, cap, "%s%s", prefix, detail); else @@ -79,45 +72,554 @@ static void set_reason(char *buf, size_t cap, const char *prefix, const char *de buf[cap - 1] = '\0'; } -/** Return 1 if @p s begins with prefix after any leading whitespace. */ static int has_path_chars(const char *tok) { - if (!tok) return 0; - return tok[0] == '/' || tok[0] == '~' || tok[0] == '.'; + if (!tok || !tok[0]) + return 0; + return tok[0] == '/' || tok[0] == '~' || tok[0] == '.' || tok[0] == '$'; +} + +static int is_option_token(const char *tok) +{ + if (!tok || tok[0] != '-' || tok[1] == '\0') + return 0; + if (tok[1] >= '0' && tok[1] <= '9') + return 0; + return 1; } -/* ------------------------------------------------------------------ */ -/* Public: path-under-workspace check (5.4) */ -/* ------------------------------------------------------------------ */ +static int is_path_body_char(unsigned char c) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '/' || c == '.' || c == '_' || + c == '-' || c == '+' || c == '%' || c == '@'; +} -int allowlist_path_is_under_workspace(const char *path, const char *workspace_root) +static int is_ident_cont(unsigned char c) { - char resolved_path[PATH_MAX]; - char resolved_ws[PATH_MAX]; - const char *actual_ws; - size_t wlen; - if (!path || !workspace_root || !workspace_root[0]) return 0; - /* Resolve the workspace root (handles symlinks like macOS /tmp -> /private/tmp). */ - if (realpath(workspace_root, resolved_ws)) - actual_ws = resolved_ws; - else - actual_ws = workspace_root; - wlen = strlen(actual_ws); - if (realpath(path, resolved_path)) { - /* Exact match or resolved path starts with resolved workspace + '/' */ - if (strncmp(resolved_path, actual_ws, wlen) == 0) { - if (resolved_path[wlen] == '\0' || resolved_path[wlen] == '/') return 1; + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_'; +} + +static int join_under_workspace(const char *workspace_root, const char *rel, + char *out, size_t cap) +{ + if (!workspace_root || !rel || !out || cap == 0) + return -1; + { + int n = snprintf(out, cap, "%s/%s", workspace_root, rel); + if (n < 0 || (size_t)n >= cap) + return -1; + } + return 0; +} + +static int deny_escaped(char *reason_buf, size_t reason_cap, const char *shown) +{ + set_reason(reason_buf, reason_cap, "command blocked: path escapes workspace: ", shown); + fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", shown); + return 1; +} + +static int deny_unresolved(char *reason_buf, size_t reason_cap, const char *shown) +{ + set_reason(reason_buf, reason_cap, + "command blocked: unresolved shell path expansion: ", shown); + fprintf(stderr, "allowlist: blocked unresolved shell path: %s\n", shown); + return 1; +} + +static int check_joined_or_abs(const char *candidate, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char joined[PATH_MAX]; + const char *check = candidate; + + if (!candidate || candidate[0] == '\0') + return 0; + if (candidate[0] != '/') { + if (join_under_workspace(workspace_root, candidate, joined, sizeof(joined)) != 0) + return deny_escaped(reason_buf, reason_cap, candidate); + check = joined; + } + if (!allowlist_path_is_under_workspace(check, workspace_root)) + return deny_escaped(reason_buf, reason_cap, check); + return 0; +} + +static int block_if_relative_token_escapes(const char *tok, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char joined[PATH_MAX]; + + if (!tok || !tok[0] || !workspace_root || !workspace_root[0]) + return 0; + if (has_path_chars(tok) || is_option_token(tok)) + return 0; + if (join_under_workspace(workspace_root, tok, joined, sizeof(joined)) != 0) + return deny_escaped(reason_buf, reason_cap, tok); + if (access(joined, F_OK) != 0) + return 0; + if (!allowlist_path_is_under_workspace(joined, workspace_root)) + return deny_escaped(reason_buf, reason_cap, joined); + return 0; +} + +static char *strip_surrounding_quotes(char *tok) +{ + size_t n; + + if (!tok || !tok[0]) + return tok; + n = strlen(tok); + if (n >= 2 && ((tok[0] == '\'' && tok[n - 1] == '\'') || + (tok[0] == '"' && tok[n - 1] == '"'))) { + tok[n - 1] = '\0'; + return tok + 1; + } + return tok; +} + +static int expand_env_prefix(const char *tok, const char *prefix, size_t prefix_len, + int require_slash_or_end, const char *value, + char *expanded, size_t expanded_cap) +{ + const char *suffix; + + if (strncmp(tok, prefix, prefix_len) != 0) + return 1; + suffix = tok + prefix_len; + if (require_slash_or_end && suffix[0] != '\0' && suffix[0] != '/') + return 1; + if (!value) + return -1; + { + int n = snprintf(expanded, expanded_cap, "%s%s", value, suffix); + if (n < 0 || (size_t)n >= expanded_cap) + return -1; + } + return 0; +} + +static int expand_shell_path_token(const char *tok, char *expanded, size_t expanded_cap) +{ + const char *home; + const char *cwd; + int rc; + + if (!tok || !expanded || expanded_cap == 0) + return -1; + if (tok[0] == '~') { + int n; + home = getenv("HOME"); + if (!home || home[0] == '\0') + return -1; + n = snprintf(expanded, expanded_cap, "%s%s", home, tok + 1); + return (n < 0 || (size_t)n >= expanded_cap) ? -1 : 0; + } + if (tok[0] != '$') { + if (strlen(tok) >= expanded_cap) + return -1; + memcpy(expanded, tok, strlen(tok) + 1); + return 0; + } + if (tok[1] == '\'' || tok[1] == '"' || tok[1] == '(') + return -1; + home = getenv("HOME"); + cwd = getenv("PWD"); + rc = expand_env_prefix(tok, "${HOME}", 7, 0, home, expanded, expanded_cap); + if (rc != 1) + return rc; + rc = expand_env_prefix(tok, "$HOME", 5, 1, home, expanded, expanded_cap); + if (rc != 1) + return rc; + rc = expand_env_prefix(tok, "${PWD}", 6, 0, cwd, expanded, expanded_cap); + if (rc != 1) + return rc; + rc = expand_env_prefix(tok, "$PWD", 4, 1, cwd, expanded, expanded_cap); + if (rc != 1) + return rc; + return -1; +} + +static int slash_follows_home_or_pwd_brace(const char *text, const char *slash) +{ + if (!text || !slash || slash <= text) + return 0; + if (slash >= text + 7 && strncmp(slash - 7, "${HOME}", 7) == 0) + return 1; + if (slash >= text + 6 && strncmp(slash - 6, "${PWD}", 6) == 0) + return 1; + return 0; +} + +static int is_inside_url(const char *text, const char *p) +{ + const char *q; + + if (!text || !p || p < text) + return 0; + for (q = p; q > text; q--) { + unsigned char c = (unsigned char)q[-1]; + if (c == ' ' || c == '\t' || c == '\n' || c == ';' || c == '|' || + c == '&' || c == '<' || c == '>' || c == '"' || c == '\'') + return 0; + if (q >= text + 3 && q[-3] == ':' && q[-2] == '/' && q[-1] == '/') + return 1; + } + return 0; +} + +static int is_fs_absolute_path_start(const char *text, const char *p) +{ + unsigned char prev; + + if (!text || !p) + return 0; + if (*p == '.') { + if (!(p[1] == '/' || + (p[1] == '.' && (p[2] == '/' || p[2] == '\0' || + p[2] == '\'' || p[2] == '"' || + !is_path_body_char((unsigned char)p[2]))))) + return 0; + if (is_inside_url(text, p) && p[1] != '.') + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + if (is_path_body_char(prev) && prev != '/') + return 0; + return 1; + } + if (*p != '/' && *p != '~') + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + if (*p == '/' && prev == ':') + return 0; + if (*p == '/' && p >= text + 2 && p[-1] == '/' && p[-2] == ':') + return 0; + if (is_path_body_char(prev) && prev != '/') + return 0; + if (prev == '}') + return !slash_follows_home_or_pwd_brace(text, p); + return 1; +} + +static int expand_tilde_fragment(const char *fragment, char *dest, size_t dest_cap) +{ + const char *home; + + if (!fragment || !dest || dest_cap == 0) + return -1; + if (fragment[0] != '~') { + if (strlen(fragment) >= dest_cap) + return -1; + memcpy(dest, fragment, strlen(fragment) + 1); + return 0; + } + home = getenv("HOME"); + if (!home || home[0] == '\0') + return -1; + { + int n = snprintf(dest, dest_cap, "%s%s", home, fragment + 1); + if (n < 0 || (size_t)n >= dest_cap) + return -1; + } + return 0; +} + +static int block_if_embedded_paths_escape(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + + if (!text || !workspace_root) + return 0; + for (p = text; *p; p++) { + char fragment[PATH_MAX]; + char expanded[PATH_MAX]; + size_t n = 0; + const char *start; + + if (!is_fs_absolute_path_start(text, p)) + continue; + start = p; + fragment[n++] = *p++; + while (*p && is_path_body_char((unsigned char)*p) && n + 1 < sizeof(fragment)) + fragment[n++] = *p++; + fragment[n] = '\0'; + if (expand_tilde_fragment(fragment, expanded, sizeof(expanded)) != 0) + return deny_escaped(reason_buf, reason_cap, fragment); + if (check_joined_or_abs(expanded, workspace_root, reason_buf, reason_cap)) + return 1; + if (p > start) + p--; + } + return 0; +} + +static size_t path_suffix_len(const char *p) +{ + size_t n = 0; + + if (!p || p[0] != '/') + return 0; + while (p[n] && is_path_body_char((unsigned char)p[n])) + n++; + return n; +} + +static int block_expanded_env_path(const char *value, const char *suffix, size_t suffix_len, + const char *workspace_root, const char *raw, + char *reason_buf, size_t reason_cap) +{ + char expanded[PATH_MAX]; + + if (!value) + return deny_unresolved(reason_buf, reason_cap, raw); + { + int n = snprintf(expanded, sizeof(expanded), "%s%.*s", value, (int)suffix_len, suffix); + if (n < 0 || (size_t)n >= sizeof(expanded)) + return deny_unresolved(reason_buf, reason_cap, raw); + } + if (!allowlist_path_is_under_workspace(expanded, workspace_root)) + return deny_escaped(reason_buf, reason_cap, expanded); + return 0; +} + +static int block_if_dollar_expansions_escape(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + const char *home; + const char *cwd; + + if (!text || !workspace_root) + return 0; + home = getenv("HOME"); + cwd = getenv("PWD"); + for (p = text; *p; ) { + size_t suffix_n; + + if (*p != '$') { + p++; + continue; + } + if (p[1] == '\'' || p[1] == '"' || p[1] == '(') + return deny_unresolved(reason_buf, reason_cap, p); + if (p[1] == '{') { + if (strncmp(p, "${HOME}", 7) == 0) { + suffix_n = path_suffix_len(p + 7); + if (block_expanded_env_path(home, p + 7, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 7 + suffix_n; + continue; + } + if (strncmp(p, "${PWD}", 6) == 0) { + suffix_n = path_suffix_len(p + 6); + if (block_expanded_env_path(cwd, p + 6, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 6 + suffix_n; + continue; + } + return deny_unresolved(reason_buf, reason_cap, p); + } + if (strncmp(p, "$HOME", 5) == 0 && !is_ident_cont((unsigned char)p[5])) { + suffix_n = path_suffix_len(p + 5); + if (block_expanded_env_path(home, p + 5, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 5 + suffix_n; + continue; } + if (strncmp(p, "$PWD", 4) == 0 && !is_ident_cont((unsigned char)p[4])) { + suffix_n = path_suffix_len(p + 4); + if (block_expanded_env_path(cwd, p + 4, suffix_n, workspace_root, p, + reason_buf, reason_cap)) + return 1; + p += 4 + suffix_n; + continue; + } + return deny_unresolved(reason_buf, reason_cap, p); + } + return 0; +} + +static int prefix_ci_eq(const char *p, const char *prefix) +{ + size_t i; + + if (!p || !prefix) return 0; + for (i = 0; prefix[i]; i++) { + unsigned char a = (unsigned char)p[i]; + unsigned char b = (unsigned char)prefix[i]; + if (a >= 'A' && a <= 'Z') + a = (unsigned char)(a - 'A' + 'a'); + if (b >= 'A' && b <= 'Z') + b = (unsigned char)(b - 'A' + 'a'); + if (a != b) + return 0; } - /* Path does not exist on disk: check the lexical prefix against resolved workspace. */ - if (strncmp(path, actual_ws, wlen) == 0) { - if (path[wlen] == '\0' || path[wlen] == '/') return 1; + return 1; +} + +static int hex_nibble(unsigned char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static int percent_decode_inplace(char *s) +{ + char *r = s; + char *w = s; + + while (*r) { + if (*r == '%') { + int hi; + int lo; + + if (!r[1] || !r[2]) + return -1; + hi = hex_nibble((unsigned char)r[1]); + lo = hex_nibble((unsigned char)r[2]); + if (hi < 0 || lo < 0) + return -1; + *w++ = (char)((hi << 4) | lo); + r += 3; + continue; + } + *w++ = *r++; } - /* Also try against the original (unresolved) workspace root. */ - wlen = strlen(workspace_root); - if (strncmp(path, workspace_root, wlen) == 0) { - if (path[wlen] == '\0' || path[wlen] == '/') return 1; + *w = '\0'; + return 0; +} + +static int block_if_file_url_escapes(const char *text, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + const char *p; + + if (!text || !workspace_root) + return 0; + for (p = text; *p; p++) { + const char *s; + char path[PATH_MAX]; + size_t n = 0; + + if (!prefix_ci_eq(p, "file:")) + continue; + if (p > text) { + unsigned char prev = (unsigned char)p[-1]; + + if (is_path_body_char(prev) && prev != '/') + continue; + } + s = p + 5; + while (*s == '/') + s++; + if (prefix_ci_eq(s, "localhost") && + (s[9] == '/' || s[9] == '\0' || s[9] == '\'' || s[9] == '"')) + s += 9; + else if (strncmp(s, "127.0.0.1", 9) == 0 && + (s[9] == '/' || s[9] == '\0' || s[9] == '\'' || s[9] == '"')) + s += 9; + while (*s == '/') + s++; + if (*s == '\0' || *s == '\'' || *s == '"') + continue; + path[n++] = '/'; + while (*s && is_path_body_char((unsigned char)*s) && n + 1 < sizeof(path)) + path[n++] = *s++; + path[n] = '\0'; + if (percent_decode_inplace(path) != 0) + return deny_escaped(reason_buf, reason_cap, path); + if (!allowlist_path_is_under_workspace(path, workspace_root)) + return deny_escaped(reason_buf, reason_cap, path); + } + return 0; +} + +static int at_word_start(const char *text, const char *p) +{ + unsigned char prev; + + if (!text || !p) + return 0; + if (p == text) + return 1; + prev = (unsigned char)p[-1]; + return !is_ident_cont(prev); +} + +static int name_is_home_or_pwd(const char *q) +{ + if (strncmp(q, "HOME", 4) == 0 && !is_ident_cont((unsigned char)q[4])) + return 1; + if (strncmp(q, "PWD", 3) == 0 && !is_ident_cont((unsigned char)q[3])) + return 1; + return 0; +} + +static int command_mutates_home_or_pwd(const char *text) +{ + const char *p; + + if (!text) + return 0; + for (p = text; *p; p++) { + const char *q; + + if (!at_word_start(text, p)) + continue; + if (strncmp(p, "HOME=", 5) == 0 || strncmp(p, "PWD=", 4) == 0) + return 1; + if (strncmp(p, "unset", 5) == 0 && !is_ident_cont((unsigned char)p[5])) { + q = p + 5; + while (*q == ' ' || *q == '\t') + q++; + if (name_is_home_or_pwd(q)) + return 1; + } + if (strncmp(p, "export", 6) == 0 && !is_ident_cont((unsigned char)p[6])) { + q = p + 6; + while (*q == ' ' || *q == '\t') + q++; + if (name_is_home_or_pwd(q)) + return 1; + } + } + return 0; +} + +static int blocklist_hit(const char *cmd, char *reason_buf, size_t reason_cap) +{ + const char *const *p; + + for (p = BLOCK_SUBSTRINGS; *p; p++) { + if (strstr(cmd, *p) != NULL) { + set_reason(reason_buf, reason_cap, + "command blocked: contains forbidden pattern '", *p); + if (reason_buf && reason_cap > 0) { + size_t used = strlen(reason_buf); + if (used + 2 < reason_cap) { + reason_buf[used] = '\''; + reason_buf[used + 1] = '\0'; + } + } + fprintf(stderr, "allowlist: blocked command containing '%s'\n", *p); + return 1; + } } return 0; } @@ -157,156 +659,116 @@ int allowlist_path_is_runtime_state_file(const char *path) return strcmp(slash, ".shellclaw") == 0; } -/** Copy @p rel under @p root, collapsing "." and "..". Absolute @p rel is copied as-is. */ -static int join_under_root(const char *root, const char *rel, char *out, size_t cap) +static int deny_runtime_state(char *reason_buf, size_t reason_cap, const char *shown) { - char tmp[PATH_MAX]; - char *dup; - char *save = NULL; - char *tok; - char *stack[48] = {0}; - int nstack = 0; - int i; - size_t used; + set_reason(reason_buf, reason_cap, "command blocked: runtime state file: ", shown); + fprintf(stderr, "allowlist: blocked runtime state file: %s\n", shown); + return 1; +} - if (!rel || !rel[0] || !out || cap == 0) - return -1; - if (rel[0] == '/') { - if (strlen(rel) + 1 > cap) - return -1; - memcpy(out, rel, strlen(rel) + 1); +/* State files are rejected even when workspace_only is off. Bare names are + * joined only when a workspace path is configured, so a sandbox-off fallback + * does not treat the process cwd as ~/.shellclaw. */ +static int block_if_runtime_state_token(const char *tok, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + char expanded[PATH_MAX]; + char joined[PATH_MAX]; + const char *check = tok; + + if (!tok || !tok[0]) return 0; - } - if (!root || !root[0]) - return -1; - if (snprintf(tmp, sizeof(tmp), "%s/%s", root, rel) >= (int)sizeof(tmp)) - return -1; - dup = strdup(tmp); - if (!dup) - return -1; - for (tok = strtok_r(dup, "/", &save); tok; tok = strtok_r(NULL, "/", &save)) { - if (strcmp(tok, ".") == 0) - continue; - if (strcmp(tok, "..") == 0) { - if (nstack > 0) - nstack--; - continue; - } - if (nstack >= (int)(sizeof(stack) / sizeof(stack[0]))) { - free(dup); - return -1; - } - stack[nstack++] = tok; - } - used = 0; - out[0] = '\0'; - for (i = 0; i < nstack; i++) { - size_t part = strlen(stack[i]); - if (used + 1 + part + 1 > cap) { - free(dup); - return -1; - } - out[used++] = '/'; - memcpy(out + used, stack[i], part); - used += part; - out[used] = '\0'; - } - free(dup); - if (used == 0) - return -1; + if ((tok[0] == '~' || tok[0] == '$') && + expand_shell_path_token(tok, expanded, sizeof(expanded)) == 0) + check = expanded; + if (allowlist_path_is_runtime_state_file(check)) + return deny_runtime_state(reason_buf, reason_cap, check); + if (!workspace_root || !workspace_root[0] || check[0] == '/') + return 0; + if (join_under_workspace(workspace_root, check, joined, sizeof(joined)) != 0) + return 0; + if (allowlist_path_is_runtime_state_file(joined)) + return deny_runtime_state(reason_buf, reason_cap, joined); return 0; } -/* ------------------------------------------------------------------ */ -/* Public: combined check */ -/* ------------------------------------------------------------------ */ +static int block_workspace_scanners(const char *cmd, const char *workspace_root, + char *reason_buf, size_t reason_cap) +{ + if (command_mutates_home_or_pwd(cmd)) { + set_reason(reason_buf, reason_cap, + "command blocked: HOME/PWD assignment in command", ""); + fprintf(stderr, "allowlist: blocked HOME/PWD assignment in command\n"); + return 1; + } + if (block_if_file_url_escapes(cmd, workspace_root, reason_buf, reason_cap)) + return 1; + if (block_if_dollar_expansions_escape(cmd, workspace_root, reason_buf, reason_cap)) + return 1; + return block_if_embedded_paths_escape(cmd, workspace_root, reason_buf, reason_cap); +} int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg, char *reason_buf, size_t reason_cap) { - const char *const *p; char ws_resolved[PATH_MAX]; const char *workspace_root = NULL; - int workspace_only = 0; - char *cmd_copy = NULL; + int workspace_only; + char *cmd_copy; char *tok; char *saveptr; + if (!cmd) { set_reason(reason_buf, reason_cap, "null command", ""); return 1; } - /* Phase 1: built-in substring blocklist */ - for (p = BLOCK_SUBSTRINGS; *p; p++) { - if (strstr(cmd, *p) != NULL) { - set_reason(reason_buf, reason_cap, - "command blocked: contains forbidden pattern '", *p); - if (reason_buf && reason_cap > 0) { - size_t used = strlen(reason_buf); - if (used + 2 < reason_cap) { - reason_buf[used] = '\''; - reason_buf[used + 1] = '\0'; - } - } - fprintf(stderr, "allowlist: blocked command containing '%s'\n", *p); - return 1; - } - } - /* Phase 2: runtime-state paths, then optional workspace containment. - * State files are rejected even when workspace_only is off, so an - * unsandboxed `cat ~/.shellclaw/config.toml` cannot skip the check. */ + if (blocklist_hit(cmd, reason_buf, reason_cap)) + return 1; workspace_only = cfg && cfg->workspace_only && cfg->workspace_path && cfg->workspace_path[0]; if (cfg && cfg->workspace_path && cfg->workspace_path[0]) { if (!realpath(cfg->workspace_path, ws_resolved)) { size_t n = strlen(cfg->workspace_path); - if (n >= PATH_MAX) n = PATH_MAX - 1; + if (n >= PATH_MAX) + n = PATH_MAX - 1; memcpy(ws_resolved, cfg->workspace_path, n); ws_resolved[n] = '\0'; } workspace_root = ws_resolved; } - /* Tokenize the command and check each path-like token. */ + if (workspace_only && + block_workspace_scanners(cmd, workspace_root, reason_buf, reason_cap)) + return 1; cmd_copy = strdup(cmd); - if (!cmd_copy) return 0; /* fail-open on OOM */ + if (!cmd_copy) { + set_reason(reason_buf, reason_cap, "command blocked: out of memory", ""); + return 1; + } tok = strtok_r(cmd_copy, " \t\n;|&><", &saveptr); while (tok) { - char expanded[PATH_MAX]; - const char *check = tok; - if (tok[0] == '~') { - const char *home = getenv("HOME"); - if (home) - snprintf(expanded, sizeof(expanded), "%s%s", home, tok + 1); - else - snprintf(expanded, sizeof(expanded), "%s", tok); - check = expanded; - } - if (allowlist_path_is_runtime_state_file(check)) { - set_reason(reason_buf, reason_cap, - "command blocked: runtime state file: ", check); - fprintf(stderr, "allowlist: blocked runtime state file: %s\n", check); + tok = strip_surrounding_quotes(tok); + if (block_if_runtime_state_token(tok, workspace_root, reason_buf, reason_cap)) { free(cmd_copy); return 1; } - /* sandbox_exec chdirs into the workspace, so a bare name is that file. */ - if (workspace_root && check[0] != '/') { - char joined[PATH_MAX]; - if (join_under_root(workspace_root, check, joined, sizeof(joined)) == 0 && - allowlist_path_is_runtime_state_file(joined)) { - set_reason(reason_buf, reason_cap, - "command blocked: runtime state file: ", joined); - fprintf(stderr, "allowlist: blocked runtime state file: %s\n", joined); + if (workspace_only && has_path_chars(tok)) { + char expanded[PATH_MAX]; + + if (expand_shell_path_token(tok, expanded, sizeof(expanded)) != 0) { + /* tok points into cmd_copy; copy the reason before free. */ + int blocked = deny_unresolved(reason_buf, reason_cap, tok); free(cmd_copy); - return 1; + return blocked; } - } - if (workspace_only && has_path_chars(tok)) { - if (!allowlist_path_is_under_workspace(check, workspace_root)) { - set_reason(reason_buf, reason_cap, - "command blocked: path escapes workspace: ", check); - fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", check); + if (check_joined_or_abs(expanded, workspace_root, reason_buf, reason_cap)) { free(cmd_copy); return 1; } + } else if (workspace_only && + block_if_relative_token_escapes(tok, workspace_root, reason_buf, + reason_cap)) { + free(cmd_copy); + return 1; } tok = strtok_r(NULL, " \t\n;|&><", &saveptr); } diff --git a/src/sandbox/allowlist.h b/src/sandbox/allowlist.h index b2d8ea1..9c89988 100644 --- a/src/sandbox/allowlist.h +++ b/src/sandbox/allowlist.h @@ -1,19 +1,22 @@ /** * @file allowlist.h - * @brief Shell-command allowlist: conservative block rules and workspace path checks. + * @brief Shell-command allowlist: blocklist plus workspace path defense-in-depth. * - * allowlist_check_shell_command() should be called before executing any shell command - * when the sandbox is enabled. It combines two independent layers of defence: + * allowlist_check_shell_command() runs before sandbox_exec() when the sandbox + * is enabled. It is not the host-FS bound: Linux Landlock in sandbox_exec() + * is, when a workspace path is set. This scanner still closes the holes that + * whitespace tokenization missed: * - * 1. A built-in substring blocklist (dangerous patterns like "rm -rf /", - * "mkfs", "dd of=/dev/", fork bombs, etc.). - * 2. An optional workspace-containment check: if enabled via allowlist_config_t, - * path-like tokens in the command are resolved with realpath(3) and rejected - * when they escape the declared workspace root. + * 1. Built-in substring blocklist (rm -rf /, mkfs, Jetson GPU /dev, Argus). + * 2. workspace_only containment: existing-ancestor walk, lexical `..` + * collapse (no cancel across a symlink), quoted/embedded `/` `~` `../`, + * `file:` URLs (including percent-encoding), `$HOME`/`$PWD` on the full + * command (including glued `$IFS`), fail-closed other `$` forms, in-command + * HOME/PWD assignment, and bare relative names (`cat leak`). * - * Both checks are intentionally conservative and may produce false positives. - * They are a best-effort defence-in-depth layer; real isolation is provided by - * sandbox_exec() via kernel namespaces. + * Conservative false positives (`awk '/foo/'`, `echo HOME=foo`) are accepted. + * Interpreter concat with no path character (`chr(47)+`) is residual here; + * Landlock denies the host inode. Encoded-slash cat-and-mouse is frozen. */ #ifndef SHELLCLAW_ALLOWLIST_H #define SHELLCLAW_ALLOWLIST_H @@ -28,13 +31,13 @@ extern "C" { typedef struct allowlist_config { /** * Absolute (or ~-prefixed) path to the workspace root. - * When non-NULL and workspace_only is non-zero, any path argument that - * resolves outside this prefix is rejected. + * When non-NULL and workspace_only is non-zero, path arguments that + * resolve outside this prefix are rejected. */ const char *workspace_path; /** - * When non-zero, any token that looks like a path is subjected to - * realpath()-based containment checks against workspace_path. + * When non-zero, path-like tokens and embedded path fragments are + * subjected to containment checks against workspace_path. */ int workspace_only; } allowlist_config_t; @@ -50,6 +53,9 @@ typedef struct allowlist_config { * @param reason_buf Optional buffer for a blocking reason message. * @param reason_cap Capacity of @p reason_buf. * @return 0 if the command is allowed, 1 if blocked. + * + * Example: allowlist_check_shell_command("cat '/etc/passwd'", &cfg, reason, sizeof reason) + * returns 1 when cfg.workspace_only is set to a workspace other than `/etc`. */ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg, char *reason_buf, size_t reason_cap); @@ -57,8 +63,14 @@ int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg /** * Check whether @p path is contained inside @p workspace_root after resolving symlinks. * - * Uses realpath(3); if the path does not exist on disk, checks the string prefix - * against the canonicalised workspace root. + * Uses realpath(3) when the path exists (kernel symlink walk, including `..` + * after a symlink). If that fails, `..` / `.` are collapsed lexically without + * cancelling `..` across a symlink, so a missing directory before `..` cannot + * pin the walk at the workspace. If the collapsed path still does not exist, + * walks to the first existing ancestor and checks that resolved directory. + * + * Example: allowlist_path_is_under_workspace("/ws/../../tmp/x", "/ws") is 0 + * even when /tmp/x does not exist. * * @param path Absolute or relative path to test. * @param workspace_root Absolute path to the workspace root (already resolved). diff --git a/src/sandbox/allowlist_path.c b/src/sandbox/allowlist_path.c new file mode 100644 index 0000000..d85c3d8 --- /dev/null +++ b/src/sandbox/allowlist_path.c @@ -0,0 +1,208 @@ +/** + * @file allowlist_path.c + * @brief Workspace containment for shell-command paths (defense-in-depth). + * + * realpath(3) cannot canonicalize a missing destination. Walking the first + * existing ancestor (same idea as tools/file.c) still collapses `..` through + * directories that exist. Lexical collapse runs first so a missing component + * before `..` cannot pin the walk at the workspace. `..` is not cancelled + * across a symlink: the kernel walks the link first. + */ +#define _DEFAULT_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include "sandbox/allowlist.h" +#include +#include +#include +#include +#include +#include +#include + +static int resolved_is_under_workspace(const char *resolved, const char *actual_ws, + size_t wlen) +{ + if (!resolved || !actual_ws || wlen == 0) + return 0; + if (strncmp(resolved, actual_ws, wlen) != 0) + return 0; + return resolved[wlen] == '\0' || resolved[wlen] == '/'; +} + +static int existing_ancestor_is_under_workspace(const char *path, const char *actual_ws, + size_t wlen) +{ + char path_copy[PATH_MAX]; + char parent[PATH_MAX]; + char resolved[PATH_MAX]; + int hops; + + if (!path || path[0] == '\0' || strlen(path) >= PATH_MAX) + return 0; + snprintf(path_copy, sizeof(path_copy), "%s", path); + for (hops = 0; hops < PATH_MAX; hops++) { + char *dir; + size_t n; + + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') + return 0; + n = strlen(dir); + if (n >= sizeof(parent)) + return 0; + memcpy(parent, dir, n + 1); + if (realpath(parent, resolved) != NULL) + return resolved_is_under_workspace(resolved, actual_ws, wlen); + if (strcmp(parent, ".") == 0 || strcmp(parent, "/") == 0) + return 0; + memcpy(path_copy, parent, n + 1); + } + return 0; +} + +static int append_path_seg(char *buf, size_t cap, size_t *len, const char *seg, + int add_slash) +{ + size_t sl; + + if (!buf || !len || !seg) + return -1; + sl = strlen(seg); + if (add_slash) { + if (*len + 1 >= cap) + return -1; + buf[(*len)++] = '/'; + } + if (*len + sl + 1 > cap) + return -1; + memcpy(buf + *len, seg, sl); + *len += sl; + buf[*len] = '\0'; + return 0; +} + +/** True when the stacked path is a symlink, so `..` must not cancel it. */ +static int stacked_path_is_symlink(const char **parts, int nparts, int absolute) +{ + char probe[PATH_MAX]; + struct stat st; + size_t probe_len; + int pi; + + memset(&st, 0, sizeof(st)); + if (absolute) { + probe[0] = '/'; + probe_len = 1; + probe[1] = '\0'; + } else { + probe_len = 0; + probe[0] = '\0'; + } + for (pi = 0; pi < nparts; pi++) { + if (!parts[pi]) + return 1; + if (append_path_seg(probe, sizeof(probe), &probe_len, parts[pi], + (absolute && pi == 0) ? 0 : (probe_len > 0)) != 0) + return 1; + } + return lstat(probe, &st) == 0 && S_ISLNK(st.st_mode); +} + +/** + * Collapse `.` / `..` without requiring directories to exist, so + * `/ws/nope/../../../tmp/x` becomes `/tmp/x` instead of walking back to `/ws`. + */ +static int lexical_collapse_path(const char *path, char *out, size_t out_cap) +{ + char tmp[PATH_MAX]; + const char *parts[PATH_MAX / 2]; + int nparts = 0; + int absolute; + size_t len; + char *cur; + int i; + size_t out_len; + + memset(parts, 0, sizeof(parts)); + if (!path || !out || out_cap == 0) + return -1; + len = strlen(path); + if (len == 0 || len >= sizeof(tmp)) + return -1; + memcpy(tmp, path, len + 1); + absolute = (tmp[0] == '/'); + cur = absolute ? tmp + 1 : tmp; + while (*cur) { + char *seg = cur; + + while (*cur && *cur != '/') + cur++; + if (*cur == '/') { + *cur = '\0'; + cur++; + } + if (seg[0] == '\0' || strcmp(seg, ".") == 0) + continue; + if (strcmp(seg, "..") == 0) { + if (nparts > 0) { + if (stacked_path_is_symlink(parts, nparts, absolute)) + return -1; + nparts--; + } + continue; + } + if (nparts >= (int)(sizeof(parts) / sizeof(parts[0]))) + return -1; + parts[nparts++] = seg; + } + if (absolute) { + if (out_cap < 2) + return -1; + out[0] = '/'; + out_len = 1; + out[1] = '\0'; + } else { + out_len = 0; + out[0] = '\0'; + } + if (!absolute && nparts == 0) { + if (out_cap < 2) + return -1; + out[0] = '.'; + out[1] = '\0'; + return 0; + } + for (i = 0; i < nparts; i++) { + if (!parts[i]) + return -1; + if (append_path_seg(out, out_cap, &out_len, parts[i], + (!absolute && i == 0) ? 0 : (out_len > (absolute ? 1 : 0))) != 0) + return -1; + } + return 0; +} + +int allowlist_path_is_under_workspace(const char *path, const char *workspace_root) +{ + char resolved_path[PATH_MAX]; + char resolved_ws[PATH_MAX]; + char collapsed[PATH_MAX]; + const char *actual_ws; + size_t wlen; + + if (!path || !workspace_root || !workspace_root[0]) + return 0; + if (realpath(workspace_root, resolved_ws)) + actual_ws = resolved_ws; + else + actual_ws = workspace_root; + wlen = strlen(actual_ws); + if (realpath(path, resolved_path)) + return resolved_is_under_workspace(resolved_path, actual_ws, wlen); + if (lexical_collapse_path(path, collapsed, sizeof(collapsed)) != 0) + return 0; + if (realpath(collapsed, resolved_path)) + return resolved_is_under_workspace(resolved_path, actual_ws, wlen); + return existing_ancestor_is_under_workspace(collapsed, actual_ws, wlen); +} diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index 223b3d9..6ff4dc8 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -293,6 +293,530 @@ static int test_symlink_escape(void) #endif } +static int test_workspace_only_blocks_quoted_path(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat '/etc/passwd'", &cfg, reason, sizeof(reason)) == 1); + ASSERT(allowlist_check_shell_command("cat \"/etc/passwd\"", &cfg, reason, sizeof(reason)) == 1); + return 0; +} + +static int test_workspace_only_blocks_embedded_path_in_python(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('/etc/passwd').read()\"", &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "passwd") != NULL || strstr(reason, "workspace") != NULL); + return 0; +} + +static int test_workspace_only_allows_relative_and_url_slashes(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo 3/4", &cfg, reason, sizeof(reason)) == 0); + ASSERT(allowlist_check_shell_command("ls src/foo", &cfg, reason, sizeof(reason)) == 0); + ASSERT(allowlist_check_shell_command( + "curl https://example.com/api", &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +static int test_workspace_only_blocks_file_url(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat file:///etc/passwd", &cfg, reason, sizeof(reason)) == 1); + return 0; +} + +static int test_workspace_only_blocks_home_env_expansion(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_home_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_home_env_expansion: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $HOME/.shellclaw/auth_tokens.json", + &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "escapes workspace") != NULL || + strstr(reason, "unresolved") != NULL || + strstr(reason, "forbidden") != NULL); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat ${HOME}/.shellclaw/auth_tokens.json", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $PWD/../outside.txt", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $'\\x2fetc\\x2fpasswd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat \"$HOME/.shellclaw/auth_tokens.json\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat notes.txt", &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +static int test_workspace_only_blocks_glued_shell_expansions(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_glue_XXXXXX"; + char *dir; + char *old_pwd; + char pwd_copy[256]; + char outside[512]; + int rc; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat$IFS/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat${IFS}/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat$'\\x20/etc/passwd'", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('$HOME/.shellclaw/auth_tokens.json')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat\"$HOME/.bashrc\"", + &cfg, reason, sizeof(reason)) == 1); + old_pwd = getenv("PWD"); + pwd_copy[0] = '\0'; + if (old_pwd) { + if (strlen(old_pwd) >= sizeof(pwd_copy)) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: PWD too long\n"); + return 1; + } + memcpy(pwd_copy, old_pwd, strlen(old_pwd) + 1); + } + if (setenv("PWD", dir, 1) != 0) { + rmdir(dir); + fprintf(stderr, "test_workspace_only_blocks_glued_shell_expansions: setenv PWD failed\n"); + return 1; + } + snprintf(outside, sizeof(outside), "%s/../sc_al_pwd_stolen_%d.txt", dir, (int)getpid()); + reason[0] = '\0'; + rc = allowlist_check_shell_command("cat $PWD/../sc_al_pwd_stolen.txt", + &cfg, reason, sizeof(reason)); + if (pwd_copy[0]) + (void)setenv("PWD", pwd_copy, 1); + else + (void)unsetenv("PWD"); + ASSERT(rc == 1); + ASSERT(allowlist_path_is_under_workspace(outside, dir) == 0); + rmdir(dir); + return 0; +} + +static int test_dotdot_escape_nonexistent_destination(void) +{ + char workspace[] = "/tmp/sc_al_ws_XXXXXX"; + char *ws; + char new_file[256]; + char escape_path[256]; + char cmd[640]; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_dotdot_escape_nonexistent_destination: mkdtemp failed\n"); + return 1; + } + snprintf(new_file, sizeof(new_file), "%s/brand_new.txt", ws); + ASSERT(allowlist_path_is_under_workspace(new_file, ws) == 1); + snprintf(escape_path, sizeof(escape_path), + "%s/../../tmp/sc_al_stolen_%d", ws, (int)getpid()); + ASSERT(allowlist_path_is_under_workspace(escape_path, ws) == 0); + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), "cp %s/memory.db %s", ws, escape_path); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + rmdir(ws); + return 0; +} + +static int test_dotdot_escape_missing_component_before_dotdot(void) +{ + char workspace[] = "/tmp/sc_al_ws_XXXXXX"; + char *ws; + char escape_path[256]; + char cmd[640]; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_dotdot_escape_missing_component_before_dotdot: mkdtemp failed\n"); + return 1; + } + snprintf(escape_path, sizeof(escape_path), + "%s/nope/../../../tmp/sc_al_stolen2_%d", ws, (int)getpid()); + ASSERT(allowlist_path_is_under_workspace(escape_path, ws) == 0); + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), "cp %s/memory.db %s", ws, escape_path); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + rmdir(ws); + return 0; +} + +static int test_workspace_only_blocks_file_url_variants(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://localhost/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +static int test_workspace_only_blocks_embedded_relative_dotdot(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_rel_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_embedded_relative_dotdot: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('../secret')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('foo/../../etc/passwd')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('notes.txt')\"", + &cfg, reason, sizeof(reason)) == 0); + rmdir(dir); + return 0; +} + +static int test_workspace_only_blocks_home_pwd_assignment(void) +{ + allowlist_config_t cfg; + char reason[256]; + char ws[] = "/tmp/sc_al_asgn_XXXXXX"; + char *dir; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_workspace_only_blocks_home_pwd_assignment: mkdtemp failed\n"); + return 1; + } + cfg.workspace_path = dir; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("PWD=; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("HOME=; cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("export PWD=; cat $PWD/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("unset HOME; cat $HOME/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + rmdir(dir); + return 0; +} + +static int test_workspace_only_blocks_symlink_dotdot(void) +{ +#ifdef __linux__ + char workspace[] = "/tmp/sc_al_sydd_XXXXXX"; + char link_path[256]; + char escape_path[512]; + char cmd[640]; + char *ws; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_only_blocks_symlink_dotdot: mkdtemp failed\n"); + return 1; + } + snprintf(link_path, sizeof(link_path), "%s/out", ws); + if (symlink("/", link_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_workspace_only_blocks_symlink_dotdot: symlink failed\n"); + return 1; + } + snprintf(escape_path, sizeof(escape_path), "%s/../etc/passwd", link_path); + ASSERT(allowlist_path_is_under_workspace(escape_path, ws) == 0); + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + snprintf(cmd, sizeof(cmd), "cat %s/../etc/passwd", link_path); + ASSERT(allowlist_check_shell_command(cmd, &cfg, reason, sizeof(reason)) == 1); + unlink(link_path); + rmdir(ws); + return 0; +#else + fprintf(stderr, "test_workspace_only_blocks_symlink_dotdot: skipped (Linux-specific)\n"); + return 0; +#endif +} + +static int test_workspace_only_blocks_percent_encoded_file_url(void) +{ + allowlist_config_t cfg; + char reason[256]; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///etc/%2e%2e/%2e%2e/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl https://example.com/api", + &cfg, reason, sizeof(reason)) == 0); + return 0; +} + +static int test_relative_symlink_indirection(void) +{ +#ifdef __linux__ + char workspace[] = "/tmp/sc_al_reltok_XXXXXX"; + char leak_path[256]; + char *ws; + allowlist_config_t cfg; + char reason[256]; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_relative_symlink_indirection: mkdtemp failed\n"); + return 1; + } + snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); + if (symlink("/etc/passwd", leak_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_relative_symlink_indirection: symlink failed\n"); + return 1; + } + cfg.workspace_path = ws; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat leak", &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "escapes") != NULL || strstr(reason, "workspace") != NULL); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo hello", &cfg, reason, sizeof(reason)) == 0); + unlink(leak_path); + rmdir(ws); + return 0; +#else + fprintf(stderr, "test_relative_symlink_indirection: skipped (Linux-specific)\n"); + return 0; +#endif +} + +static int test_runtime_state_edges_and_url_hosts(void) +{ + allowlist_config_t cfg; + char reason[256]; + + ASSERT(allowlist_path_is_runtime_state_file(NULL) == 0); + ASSERT(allowlist_path_is_runtime_state_file("") == 0); + ASSERT(allowlist_path_is_runtime_state_file("shellclaw.pid") == 1); + ASSERT(allowlist_path_is_runtime_state_file("shellclaw.log") == 1); + ASSERT(allowlist_path_is_runtime_state_file("config.toml") == 0); + ASSERT(allowlist_path_is_runtime_state_file("/config.toml") == 0); + ASSERT(allowlist_path_is_runtime_state_file("/.shellclaw/config.toml") == 1); + ASSERT(allowlist_path_is_runtime_state_file("/.shellclaw/memory.db") == 1); + ASSERT(allowlist_check_shell_command("cat shellclaw.pid", NULL, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat shellclaw.log", NULL, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command("cat config.toml", NULL, NULL, 0) == 0); + ASSERT(allowlist_path_is_under_workspace(NULL, "/tmp") == 0); + ASSERT(allowlist_path_is_under_workspace("/tmp/a", NULL) == 0); + ASSERT(allowlist_path_is_under_workspace("/tmp/a", "") == 0); + ASSERT(allowlist_path_is_under_workspace("foo/../../etc/passwd", "/tmp") == 0); + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file://127.0.0.1/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///etc/%2", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///etc/%GG", + &cfg, reason, sizeof(reason)) == 1); + return 0; +} + +/* Sandbox-off substring-matches these names. Sandbox-on must too: a + * python -c open() is not a shell token whose basename is the file. */ +static int test_runtime_state_names_block_embedded(void) +{ + allowlist_config_t cfg; + + cfg.workspace_path = "/tmp"; + cfg.workspace_only = 1; + ASSERT(allowlist_check_shell_command( + "python3 -c \"print(open('auth_tokens.json').read())\"", + &cfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('shellclaw.pid')\"", + &cfg, NULL, 0) == 1); + ASSERT(allowlist_check_shell_command( + "python3 -c \"open('shellclaw.log')\"", + &cfg, NULL, 0) == 1); + return 0; +} + +/* $HOME/$PWD that stay inside the workspace take the scanner's allow path. + * A temp workspace makes those expansions fail closed, so these lines were + * never hit. Also cover token-walker and file-URL edges around that path. */ +static int test_in_workspace_env_and_scanner_edges(void) +{ + allowlist_config_t cfg; + char reason[256]; + char cwd[PATH_MAX]; + char ws[] = "/tmp/sc_al_cov_XXXXXX"; + const char *home; + const char *pwd; + char *dir; + + home = getenv("HOME"); + pwd = getenv("PWD"); + if (!home || !home[0] || !getcwd(cwd, sizeof(cwd))) { + fprintf(stderr, "test_in_workspace_env_and_scanner_edges: HOME or cwd unavailable\n"); + return 1; + } + if (!pwd || !pwd[0]) + pwd = cwd; + cfg.workspace_only = 1; + cfg.workspace_path = home; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo $HOME/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ${HOME}/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ~/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo $HOME.extra", + &cfg, reason, sizeof(reason)) == 1); + ASSERT(strstr(reason, "unresolved") != NULL); + cfg.workspace_path = pwd; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo $PWD/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ${PWD}/notes.txt", + &cfg, reason, sizeof(reason)) == 0); + cfg.workspace_path = "/tmp"; + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("/bin/echo hi", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("curl file:///%2E%2E/etc/passwd", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo xfile:///tmp/inside", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo file://localhost", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("unset FOO; echo notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("export BAR; echo notes.txt", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo -1", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("python3 -c \"open('..')\"", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo ..;", + &cfg, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("echo https://example.com/.git", + &cfg, reason, sizeof(reason)) == 0); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat ${HOME}/auth_tokens.json", + NULL, reason, sizeof(reason)) == 1); + reason[0] = '\0'; + ASSERT(allowlist_check_shell_command("cat $PWD/shellclaw.pid", + NULL, reason, sizeof(reason)) == 1); + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_in_workspace_env_and_scanner_edges: mkdtemp failed\n"); + return 1; + } + ASSERT(allowlist_path_is_under_workspace("foo/..", dir) == 0); + ASSERT(allowlist_path_is_under_workspace("foo/./bar", dir) == 0); + rmdir(dir); + return 0; +} + /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ @@ -315,12 +839,29 @@ int main(void) RUN(test_block_state_dir_config_and_memory()); RUN(test_block_memory_sidecars_and_bare_names()); RUN(test_allow_project_config_toml()); + RUN(test_runtime_state_names_block_embedded()); + RUN(test_runtime_state_edges_and_url_hosts()); + RUN(test_in_workspace_env_and_scanner_edges()); RUN(test_path_inside_workspace()); RUN(test_path_outside_workspace()); RUN(test_path_prefix_no_slash()); RUN(test_workspace_only_blocks_outside_path()); RUN(test_workspace_only_allows_inside_path()); + RUN(test_workspace_only_blocks_quoted_path()); + RUN(test_workspace_only_blocks_embedded_path_in_python()); + RUN(test_workspace_only_allows_relative_and_url_slashes()); + RUN(test_workspace_only_blocks_file_url()); + RUN(test_workspace_only_blocks_home_env_expansion()); + RUN(test_workspace_only_blocks_glued_shell_expansions()); RUN(test_symlink_escape()); + RUN(test_dotdot_escape_nonexistent_destination()); + RUN(test_dotdot_escape_missing_component_before_dotdot()); + RUN(test_workspace_only_blocks_file_url_variants()); + RUN(test_workspace_only_blocks_embedded_relative_dotdot()); + RUN(test_workspace_only_blocks_home_pwd_assignment()); + RUN(test_workspace_only_blocks_symlink_dotdot()); + RUN(test_workspace_only_blocks_percent_encoded_file_url()); + RUN(test_relative_symlink_indirection()); printf("test_allowlist: all tests passed\n"); return 0; } From 554fcf55921f7517ae2394cf10f95524da12f95e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:52:26 -0300 Subject: [PATCH 84/92] fix(sandbox): fail closed when namespace setup cannot be applied unshare does not move the caller into the new PID namespace, and a shared mount tree would let a later umount of /proc hit the host. Fork so the command is PID 1, require MS_PRIVATE, remount proc, and report setup failure on a control pipe: the child writes the byte, and EOF is success. Join the cgroup before that fork, reap on timeout, and close inherited fds. --- src/sandbox/sandbox.c | 339 +++++++++++++++++++++++++++------ src/sandbox/sandbox.h | 29 ++- tests/test_sandbox.c | 432 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 696 insertions(+), 104 deletions(-) diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 927ef3d..192a5b0 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -1,14 +1,17 @@ /** * @file sandbox.c - * @brief Process sandbox: Linux namespace isolation, cgroups v2, timeout/kill. + * @brief Process sandbox: Linux namespaces, cgroups v2, timeout. * - * Linux path: fork() + unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID) in the - * child, giving the shell and its children mount, network, and PID namespace - * isolation respectively. Does not mount(2), bind-mount, or pivot_root(2); Jetson - * GPU nodes (/dev/nvhost-*, /dev/nvgpu, /dev/nvmap) are never injected into the - * namespace — see docs/SECURITY.md. cgroups v2 memory.max and cpu.max limits are applied - * via the host cgroup hierarchy when available; the function degrades gracefully - * if the kernel does not expose writable cgroup controllers. + * Linux path: fork the isolator, then unshare mount/network/PID (user ns + * first when unprivileged). Isolation failure is reported on a control pipe, + * not via sh-compatible exit codes. dup2, chdir, PID-1 fork, and workspace + * setup failures write that byte before exiting; a 0-byte read is success. + * After CLONE_NEWPID, fork so the command + * is PID 1 (unshare does not move the caller). That child fchdir's the + * workspace, remounts procfs, sets PR_SET_PDEATHSIG, and closes fds >= 3 + * so a timeout SIGKILL of the isolator cannot leave the command under + * host init. The isolator joins its cgroup before the command fork so + * memory.max and cpu.max apply to sh -c. Limits degrade if unavailable. * * Non-Linux path: plain fork() + execl(); a warning is emitted to stderr. */ @@ -31,18 +34,18 @@ #ifdef __linux__ #include +#include #include +#include #endif #define DEFAULT_TIMEOUT_MS 10000 -#define DEFAULT_MEMORY_MAX (64UL * 1024UL * 1024UL) /* 64 MiB */ +#define DEFAULT_MEMORY_MAX (64UL * 1024UL * 1024UL) #define DEFAULT_CGROUP_BASE "/sys/fs/cgroup" #define CGROUP_NAME_PREFIX "shellclaw_sb_" #define PIPE_POLL_SLICE_MS 500 - -/* ------------------------------------------------------------------ */ -/* cgroups v2 helpers (Linux only) */ -/* ------------------------------------------------------------------ */ +#define SANDBOX_ISO_NS 1 +#define SANDBOX_ISO_LL 2 #ifdef __linux__ @@ -66,23 +69,31 @@ static int cgroup_controllers_available(const char *base) return access(path, F_OK) == 0; } -/** - * Create a cgroup at /, write resource limits, assign @p pid. - * Returns 0 on success; the caller must call cgroup_remove() when done. - */ -static int cgroup_create(const char *base, const char *name, pid_t pid, - size_t memory_max, const char *cpu_max_str) +static int cgroup_prepare(const char *base, const char *name, + size_t memory_max, const char *cpu_max_str) { char cpath[1024]; char val[64]; - snprintf(cpath, sizeof(cpath), "%s/%s", base, name); - if (mkdir(cpath, 0755) != 0 && errno != EEXIST) return -1; + int n; + + n = snprintf(cpath, sizeof(cpath), "%s/%s", base, name); + if (n < 0 || (size_t)n >= sizeof(cpath)) + return -1; + if (mkdir(cpath, 0755) != 0 && errno != EEXIST) + return -1; snprintf(val, sizeof(val), "%zu", memory_max > 0 ? memory_max : DEFAULT_MEMORY_MAX); cgroup_write_file(cpath, "memory.max", val); if (cpu_max_str && cpu_max_str[0]) cgroup_write_file(cpath, "cpu.max", cpu_max_str); - snprintf(val, sizeof(val), "%d", (int)pid); - return cgroup_write_file(cpath, "cgroup.procs", val); + return 0; +} + +/* cgroup v2 does not move descendants that already exist. Write 0 before fork. */ +static int cgroup_join_self(const char *cpath) +{ + if (!cpath || !cpath[0]) + return -1; + return cgroup_write_file(cpath, "cgroup.procs", "0"); } static void cgroup_remove(const char *base, const char *name) @@ -94,10 +105,6 @@ static void cgroup_remove(const char *base, const char *name) #endif /* __linux__ */ -/* ------------------------------------------------------------------ */ -/* Pipe drain with timeout */ -/* ------------------------------------------------------------------ */ - static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) { size_t total = 0; @@ -136,29 +143,158 @@ static size_t drain_pipe(int fd, char *buf, size_t cap, int timeout_ms) return total; } -/* ------------------------------------------------------------------ */ -/* Child setup before exec */ -/* ------------------------------------------------------------------ */ +#ifdef __linux__ + +static int write_proc_str(const char *path, const char *s) +{ + int fd; + ssize_t n; + size_t len; + + fd = open(path, O_WRONLY | O_CLOEXEC); + if (fd < 0) return -1; + len = strlen(s); + n = write(fd, s, len); + close(fd); + return (n == (ssize_t)len) ? 0 : -1; +} + +static int enter_user_namespace(void) +{ + char map[64]; + uid_t uid = getuid(); + gid_t gid = getgid(); + + if (unshare(CLONE_NEWUSER) != 0) return -1; + if (write_proc_str("/proc/self/setgroups", "deny\n") != 0) return -1; + snprintf(map, sizeof map, "0 %u 1\n", (unsigned)uid); + if (write_proc_str("/proc/self/uid_map", map) != 0) return -1; + snprintf(map, sizeof map, "0 %u 1\n", (unsigned)gid); + if (write_proc_str("/proc/self/gid_map", map) != 0) return -1; + return 0; +} + +static int unshare_isolation_namespaces(void) +{ + if (unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID) != 0) + return -1; + /* Shared mounts would let the later umount2("/proc") hit the host. */ + if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) != 0) + return -1; + return 0; +} + +static int isolate_namespaces(void) +{ + if (unshare_isolation_namespaces() == 0) + return 0; + if (enter_user_namespace() != 0) + return -1; + return unshare_isolation_namespaces(); +} + +static int remount_procfs(void) +{ + (void)umount2("/proc", MNT_DETACH); + if (mount("proc", "/proc", "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) + return -1; + return 0; +} + +static int enter_workspace_cwd(const char *workspace) +{ + int ws_fd; + ws_fd = open(workspace, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (ws_fd < 0) + return -1; + if (fchdir(ws_fd) != 0) { + close(ws_fd); + return -1; + } + close(ws_fd); + return 0; +} + +static unsigned char setup_command_process(const char *workspace) +{ + if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0) + return SANDBOX_ISO_NS; + if (workspace && workspace[0] && enter_workspace_cwd(workspace) != 0) + return SANDBOX_ISO_NS; + if (remount_procfs() != 0) + return SANDBOX_ISO_NS; + return 0; +} + +#endif /* __linux__ */ + +static void close_inherited_fds(void) +{ +#if defined(__linux__) && defined(__NR_close_range) + if (syscall(__NR_close_range, 3, ~0U, 0) == 0) + return; +#endif + { + int fd; + for (fd = 3; fd < 1024; fd++) + (void)close(fd); + } +} + +static ssize_t read_isolation_byte(int fd, unsigned char *iso, int timeout_ms) +{ + struct pollfd pfd; + int r; + pfd.fd = fd; + pfd.events = POLLIN; + pfd.revents = 0; + r = poll(&pfd, 1, timeout_ms); + if (r <= 0) + return -1; + { + ssize_t n = read(fd, iso, 1); + return n; + } +} -static void setup_child_process(int pipe_wr, const char *workspace) +static unsigned char setup_child_process(int pipe_wr, const char *workspace) { close(STDIN_FILENO); - if (dup2(pipe_wr, STDOUT_FILENO) < 0) _exit(125); - if (dup2(pipe_wr, STDERR_FILENO) < 0) _exit(125); + if (dup2(pipe_wr, STDOUT_FILENO) < 0) + return SANDBOX_ISO_NS; + if (dup2(pipe_wr, STDERR_FILENO) < 0) + return SANDBOX_ISO_NS; close(pipe_wr); #ifdef __linux__ - setsid(); - /* Namespace isolation: mount + network + PID (children of this process). */ - unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); - prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)workspace; + (void)setsid(); + if (isolate_namespaces() != 0) + return SANDBOX_ISO_NS; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + return SANDBOX_ISO_NS; + if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0) + return SANDBOX_ISO_NS; +#else + if (workspace && workspace[0] && chdir(workspace) != 0) + return SANDBOX_ISO_NS; #endif - if (workspace && workspace[0]) - if (chdir(workspace) != 0) _exit(124); + return 0; } -/* ------------------------------------------------------------------ */ -/* Post-drain wait: kill child if still alive */ -/* ------------------------------------------------------------------ */ +#ifdef __linux__ +static void wait_and_exit_with_child(pid_t cmd_pid) +{ + int st = 0; + + if (waitpid(cmd_pid, &st, 0) < 0) + _exit(125); + if (WIFEXITED(st)) + _exit(WEXITSTATUS(st)); + if (WIFSIGNALED(st)) + _exit(128 + WTERMSIG(st)); + _exit(1); +} +#endif static int reap_child(pid_t pid, int *status_out) { @@ -168,29 +304,43 @@ static int reap_child(pid_t pid, int *status_out) struct timespec ts; int retries; kill(pid, SIGKILL); + (void)kill(-pid, SIGKILL); for (retries = 0; retries < 40; retries++) { wr = waitpid(pid, &st, WNOHANG); if (wr != 0) break; ts.tv_sec = 0; - ts.tv_nsec = 50 * 1000 * 1000; /* 50 ms */ + ts.tv_nsec = 50 * 1000 * 1000; nanosleep(&ts, NULL); } if (wr == 0) waitpid(pid, &st, 0); if (status_out) *status_out = st; - return 1; /* did time out */ + return 1; } if (status_out) *status_out = st; return 0; } -/* ------------------------------------------------------------------ */ -/* Public API */ -/* ------------------------------------------------------------------ */ +static int report_isolation_failure(char *out, size_t out_cap, unsigned char iso) +{ + if (iso == SANDBOX_ISO_LL) + snprintf(out, out_cap, "sandbox: Landlock filesystem bound failed"); + else + snprintf(out, out_cap, "sandbox: namespace isolation failed"); + return -1; +} + +static int close_pipes_pair(int a, int b) +{ + close(a); + close(b); + return -1; +} int sandbox_exec(const char *cmd, char *out, size_t out_cap, int timeout_ms, const sandbox_config_t *cfg) { int pipefd[2]; + int errpipe[2]; pid_t pid; size_t total; int timed_out = 0; @@ -198,10 +348,13 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, int used_cgroup = 0; #ifdef __linux__ char cgroup_name[80]; + char cgroup_dir[1024]; const char *cgroup_base = (cfg && cfg->cgroup_base && cfg->cgroup_base[0]) ? cfg->cgroup_base : DEFAULT_CGROUP_BASE; size_t memory_max = cfg ? cfg->memory_max_bytes : 0; const char *cpu_max_str = cfg ? cfg->cpu_max : NULL; + static unsigned cgroup_seq; + cgroup_dir[0] = '\0'; #endif if (!cmd || !out || out_cap == 0) return -1; out[0] = '\0'; @@ -217,36 +370,98 @@ int sandbox_exec(const char *cmd, char *out, size_t out_cap, } #endif if (pipe(pipefd) != 0) return -1; - if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) != 0) { + if (pipe(errpipe) != 0) { close(pipefd[0]); close(pipefd[1]); return -1; } - /* Write end must NOT have CLOEXEC so child inherits it. */ + if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) != 0 || + fcntl(errpipe[0], F_SETFD, FD_CLOEXEC) != 0 || + fcntl(errpipe[1], F_SETFD, FD_CLOEXEC) != 0) { + close(errpipe[0]); + close(errpipe[1]); + return close_pipes_pair(pipefd[0], pipefd[1]); + } +#ifdef __linux__ + if (cgroup_controllers_available(cgroup_base)) { + cgroup_seq++; + snprintf(cgroup_name, sizeof(cgroup_name), "%s%d_%u", + CGROUP_NAME_PREFIX, (int)getpid(), cgroup_seq); + if (cgroup_prepare(cgroup_base, cgroup_name, memory_max, cpu_max_str) == 0) { + int n = snprintf(cgroup_dir, sizeof(cgroup_dir), "%s/%s", + cgroup_base, cgroup_name); + if (n > 0 && (size_t)n < sizeof(cgroup_dir)) + used_cgroup = 1; + } else { + fprintf(stderr, "sandbox: cgroup setup failed (non-fatal)\n"); + } + } +#endif pid = fork(); if (pid < 0) { - close(pipefd[0]); - close(pipefd[1]); - return -1; + close(errpipe[0]); + close(errpipe[1]); +#ifdef __linux__ + if (used_cgroup) + cgroup_remove(cgroup_base, cgroup_name); +#endif + return close_pipes_pair(pipefd[0], pipefd[1]); } if (pid == 0) { - /* Child */ + unsigned char iso; close(pipefd[0]); - setup_child_process(pipefd[1], workspace); + close(errpipe[0]); +#ifdef __linux__ + if (used_cgroup && cgroup_join_self(cgroup_dir) != 0) + fprintf(stderr, "sandbox: cgroup join failed (non-fatal)\n"); +#endif + iso = setup_child_process(pipefd[1], workspace); + if (iso != 0) { + if (write(errpipe[1], &iso, 1) < 0) { /* parent fail-closes on EOF */ } + _exit(1); + } +#ifdef __linux__ + { + pid_t cmd_pid = fork(); + if (cmd_pid < 0) { + unsigned char fork_iso = SANDBOX_ISO_NS; + if (write(errpipe[1], &fork_iso, 1) < 0) { /* parent fail-closes on EOF */ } + _exit(1); + } + if (cmd_pid > 0) { + close(errpipe[1]); + wait_and_exit_with_child(cmd_pid); + } + iso = setup_command_process(workspace); + if (iso != 0) { + if (write(errpipe[1], &iso, 1) < 0) { /* parent fail-closes on EOF */ } + _exit(1); + } + } +#endif + close(errpipe[1]); + close_inherited_fds(); execl("/bin/sh", "sh", "-c", cmd, (char *)NULL); _exit(127); } - /* Parent */ close(pipefd[1]); + close(errpipe[1]); + { + unsigned char iso = 0; + ssize_t n = read_isolation_byte(errpipe[0], &iso, timeout_ms); + close(errpipe[0]); + if (n != 0) { + (void)reap_child(pid, NULL); #ifdef __linux__ - if (cgroup_controllers_available(cgroup_base)) { - snprintf(cgroup_name, sizeof(cgroup_name), "%s%d", CGROUP_NAME_PREFIX, (int)pid); - if (cgroup_create(cgroup_base, cgroup_name, pid, memory_max, cpu_max_str) == 0) - used_cgroup = 1; - else - fprintf(stderr, "sandbox: cgroup setup failed for pid %d (non-fatal)\n", (int)pid); - } + if (used_cgroup) + cgroup_remove(cgroup_base, cgroup_name); #endif + close(pipefd[0]); + if (n != 1) + iso = SANDBOX_ISO_NS; + return report_isolation_failure(out, out_cap, iso); + } + } total = drain_pipe(pipefd[0], out, out_cap, timeout_ms); close(pipefd[0]); timed_out = reap_child(pid, NULL); diff --git a/src/sandbox/sandbox.h b/src/sandbox/sandbox.h index 5d255d7..280a7dc 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -1,10 +1,17 @@ /** * @file sandbox.h - * @brief Process sandbox API: isolated execution with namespaces, timeout, and cgroups v2. + * @brief Process sandbox API: namespaces, timeout, cgroups v2. * - * On Linux, sandbox_exec uses clone(2) with PID/mount/network namespace isolation, - * optional cgroups v2 resource limits, and a hard timeout with SIGKILL. - * On other platforms (macOS, BSDs) it falls back to a plain fork+exec and logs a warning. + * On Linux, sandbox_exec forks an isolator, unshares PID/mount/network + * namespaces (entering a user namespace when unprivileged), then forks again + * so the command is PID 1. Isolation failure is fail-closed via a control + * pipe (not sh exit 122/123). The copied mount tree must become + * MS_REC|MS_PRIVATE, and the command child remounts proc. Optional cgroups + * v2 resource limits are joined before the command fork. A hard timeout + * SIGKILLs the isolator; the command child sets PR_SET_PDEATHSIG so the + * PID-1 process cannot outlive it. + * On other platforms (macOS, BSDs) it falls back to a plain fork+exec + * and logs a warning. */ #ifndef SHELLCLAW_SANDBOX_H #define SHELLCLAW_SANDBOX_H @@ -42,9 +49,13 @@ typedef struct sandbox_config { /** * Execute @p cmd inside an isolated child process and capture output. * - * On Linux, clones with CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET. - * Applies cgroups v2 limits when available; degrades gracefully if not. - * Kills the child with SIGKILL if @p timeout_ms elapses before exit. + * On Linux, enters a user namespace when needed, then unshares + * CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET and forks so the command is + * PID 1. If those namespaces or MS_PRIVATE cannot be applied, returns -1 + * (fail-closed). Setup failure is a byte on the control pipe; EOF is + * success. Applies cgroups v2 limits when available, joining before the + * command fork. On timeout the parent SIGKILLs the isolator; the command + * is PID 1 and dies via PR_SET_PDEATHSIG and process-group kill. * * On non-Linux platforms the function executes the command via fork()+exec() * without namespace isolation and emits a warning to stderr. @@ -55,7 +66,9 @@ typedef struct sandbox_config { * @param timeout_ms Maximum wall-clock milliseconds before SIGKILL. 0 = default (10 000 ms). * @param cfg Optional sandbox configuration. NULL = use built-in defaults. * @return 0 on success (command ran; check output for exit status text), - * -1 on system error (pipe/fork/clone failure). + * -1 on system error (pipe/fork/clone/isolation failure). + * + * Example: sandbox_exec("echo hi", out, sizeof out, 5000, &cfg); */ int sandbox_exec(const char *cmd, char *out, size_t out_cap, int timeout_ms, const sandbox_config_t *cfg); diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 4c653c3..6febd6f 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -2,22 +2,35 @@ * @file test_sandbox.c * @brief Unit tests for sandbox_exec. * - * Linux-specific namespace and cgroup tests are guarded by #ifdef __linux__ - * and runtime checks for userns / cgroup availability. - * On macOS and other platforms, the tests fall back to verifying basic - * fork+exec behaviour: output capture, timeout, and null-safety. + * Linux-specific namespace and cgroup tests are guarded by + * #ifdef __linux__. GitHub-hosted runners often cannot apply user namespaces + * (sandbox_exec fail-closes; success-path tests skip). Do not weaken + * sandbox.c for CI. * * 5.7 Benchmark: run sandbox_exec("true") 200 times and report median. * The benchmark is informational only — it does not gate the test suite. */ +#define _DEFAULT_SOURCE +#define _DARWIN_C_SOURCE #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include +#include #include #include #include #include #include +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#endif #define ASSERT(c) do { \ if (!(c)) { \ @@ -28,14 +41,94 @@ #define RUN(t) do { int r_ = (t); if (r_) return r_; } while (0) -/* ------------------------------------------------------------------ */ -/* Basic functionality */ -/* ------------------------------------------------------------------ */ +static int isolation_was_denied(int rc, const char *out) +{ + if (rc != -1) + return 0; + if (out == NULL) + return 0; + if (strstr(out, "sandbox: namespace isolation failed") != NULL) + return 1; + if (strstr(out, "sandbox: Landlock filesystem bound failed") != NULL) + return 1; + return 0; +} + +static int skip_if_isolation_denied(int rc, const char *out, const char *name) +{ + if (!isolation_was_denied(rc, out)) + return 0; + fprintf(stderr, "test_sandbox: skip %s (%s)\n", name, out); + return 1; +} + +#ifdef __linux__ +static int proc_cmdline_has(const char *needle) +{ + DIR *d; + struct dirent *e; + d = opendir("/proc"); + if (!d) + return 0; + while ((e = readdir(d)) != NULL) { + char path[288]; + char buf[256]; + FILE *f; + size_t n; + size_t i; + if (e->d_name[0] < '1' || e->d_name[0] > '9') + continue; + if (strlen(e->d_name) > 16) + continue; + snprintf(path, sizeof path, "/proc/%s/cmdline", e->d_name); + f = fopen(path, "r"); + if (!f) + continue; + n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + for (i = 0; i < n; i++) { + if (buf[i] == '\0') + buf[i] = ' '; + } + buf[n] = '\0'; + if (strstr(buf, needle) != NULL) { + closedir(d); + return 1; + } + } + closedir(d); + return 0; +} +#endif + +static int test_isolation_denied_helper(void) +{ + ASSERT(isolation_was_denied(0, "sandbox: namespace isolation failed") == 0); + ASSERT(isolation_was_denied(-1, "hello") == 0); + ASSERT(isolation_was_denied(-1, NULL) == 0); + ASSERT(isolation_was_denied(-1, "sandbox: namespace isolation failed") == 1); + ASSERT(isolation_was_denied(-1, "sandbox: Landlock filesystem bound failed") == 1); + return 0; +} + +static int test_fail_closed_reports_isolation_error(void) +{ + char out[4096]; + int rc; + + rc = sandbox_exec("true", out, sizeof(out), 5000, NULL); + if (rc == 0) + return 0; + ASSERT(isolation_was_denied(rc, out)); + return 0; +} static int test_output_capture(void) { char out[4096]; int rc = sandbox_exec("echo hello_sandbox", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_output_capture")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "hello_sandbox") != NULL); return 0; @@ -45,6 +138,8 @@ static int test_stderr_captured(void) { char out[4096]; int rc = sandbox_exec("echo err >&2", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_stderr_captured")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "err") != NULL); return 0; @@ -67,9 +162,84 @@ static int test_zero_cap_returns_error(void) static int test_exit_nonzero_runs(void) { char out[4096]; - /* Command exits non-zero; sandbox_exec should still return 0 (ran ok). */ int rc = sandbox_exec("exit 1", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_exit_nonzero_runs")) + return 0; + ASSERT(rc == 0); + return 0; +} + +static int test_exit_122_is_not_isolation_failure(void) +{ + char out[4096]; + int rc = sandbox_exec("exit 122", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_exit_122_is_not_isolation_failure")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "namespace isolation failed") == NULL); + ASSERT(strstr(out, "Landlock filesystem bound failed") == NULL); + return 0; +} + +static int test_exit_123_with_workspace_is_not_isolation_failure(void) +{ + char out[4096]; + sandbox_config_t cfg; + char ws[] = "/tmp/sc_sb_ex_XXXXXX"; + char *dir; + int rc; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_exit_123_with_workspace_is_not_isolation_failure: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = dir; + rc = sandbox_exec("exit 123", out, sizeof(out), 5000, &cfg); + rmdir(dir); + if (skip_if_isolation_denied(rc, out, "test_exit_123_with_workspace_is_not_isolation_failure")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "namespace isolation failed") == NULL); + ASSERT(strstr(out, "Landlock filesystem bound failed") == NULL); + return 0; +} + +static int test_pidns_fork_allows_second_command(void) +{ + char out[4096]; + int rc = sandbox_exec("/bin/echo A; /bin/echo B", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_pidns_fork_allows_second_command")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "Cannot fork") == NULL); + ASSERT(strstr(out, "A") != NULL); + ASSERT(strstr(out, "B") != NULL); + return 0; +} + +static int test_dev_null_is_writable(void) +{ + char out[4096]; + sandbox_config_t cfg; + char ws[] = "/tmp/sc_sb_null_XXXXXX"; + char *dir; + int rc; + + dir = mkdtemp(ws); + if (!dir) { + fprintf(stderr, "test_dev_null_is_writable: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = dir; + rc = sandbox_exec("echo hi >/dev/null && echo OK", out, sizeof(out), 5000, &cfg); + rmdir(dir); + if (skip_if_isolation_denied(rc, out, "test_dev_null_is_writable")) + return 0; ASSERT(rc == 0); + ASSERT(strstr(out, "OK") != NULL); return 0; } @@ -77,50 +247,200 @@ static int test_workspace_chdir(void) { char out[4096]; sandbox_config_t cfg; + int rc; + memset(&cfg, 0, sizeof cfg); cfg.workspace_path = "/tmp"; - int rc = sandbox_exec("pwd", out, sizeof(out), 5000, &cfg); + rc = sandbox_exec("pwd", out, sizeof(out), 5000, &cfg); + if (skip_if_isolation_denied(rc, out, "test_workspace_chdir")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "/tmp") != NULL); return 0; } -/* ------------------------------------------------------------------ */ -/* Timeout test */ -/* ------------------------------------------------------------------ */ +static int test_missing_workspace_fail_closed(void) +{ + char out[4096]; + sandbox_config_t cfg; + int rc; + + memset(&cfg, 0, sizeof cfg); + memset(out, 0, sizeof out); + cfg.workspace_path = "/no/such/sc_ws_missing_dir"; + rc = sandbox_exec("echo should_not_run", out, sizeof out, 3000, &cfg); + ASSERT(rc == -1); + ASSERT(strstr(out, "should_not_run") == NULL); + ASSERT(strstr(out, "sandbox:") != NULL); + return 0; +} static int test_timeout_kills_process(void) { char out[4096]; - /* sleep 60 should be killed well before natural completion. */ - int rc = sandbox_exec("sleep 60", out, sizeof(out), 300, NULL); + int rc = sandbox_exec("sleep 86401", out, sizeof(out), 300, NULL); + if (skip_if_isolation_denied(rc, out, "test_timeout_kills_process")) + return 0; ASSERT(rc == 0); ASSERT(strstr(out, "timed out") != NULL || strlen(out) == 0); +#ifdef __linux__ + { + int tries; + for (tries = 0; tries < 20; tries++) { + if (!proc_cmdline_has("sleep 86401")) + break; + { + struct timespec ts; + ts.tv_sec = 0; + ts.tv_nsec = 50 * 1000 * 1000; + nanosleep(&ts, NULL); + } + } + ASSERT(proc_cmdline_has("sleep 86401") == 0); + } +#endif + return 0; +} + +#ifdef __linux__ +static int test_inherited_fd_is_closed(void) +{ + char dir[] = "/tmp/sc_sb_fd_XXXXXX"; + char path[256]; + char cmd[128]; + char out[4096]; + char *ws; + int fd; + int rc; + ws = mkdtemp(dir); + if (!ws) { + fprintf(stderr, "test_inherited_fd_is_closed: mkdtemp failed\n"); + return 1; + } + snprintf(path, sizeof path, "%s/secret", ws); + fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (fd < 0) { + rmdir(ws); + return 1; + } + if (write(fd, "SECRET_FD_LEAK\n", 15) != 15) { + close(fd); + unlink(path); + rmdir(ws); + return 1; + } + close(fd); + fd = open(path, O_RDONLY); + if (fd < 0) { + unlink(path); + rmdir(ws); + return 1; + } + snprintf(cmd, sizeof cmd, "cat /dev/fd/%d 2>&1; echo EXIT:$?", fd); + rc = sandbox_exec(cmd, out, sizeof out, 5000, NULL); + close(fd); + unlink(path); + rmdir(ws); + if (skip_if_isolation_denied(rc, out, "test_inherited_fd_is_closed")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "SECRET_FD_LEAK") == NULL); return 0; } -/* ------------------------------------------------------------------ */ -/* Linux-only: namespace isolation (shadow should not be readable) */ -/* ------------------------------------------------------------------ */ +static int test_proc_is_namespaced(void) +{ + char out[4096]; + int rc; + rc = sandbox_exec("tr '\\0' ' ' < /proc/1/cmdline; echo", out, sizeof out, 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_proc_is_namespaced")) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "systemd") == NULL); + ASSERT(strstr(out, "sh") != NULL); + return 0; +} + +#endif #ifdef __linux__ static int test_shadow_not_accessible(void) { char out[4096]; int rc; - /* Even if /etc/shadow is not present in CI, the exec should succeed. - * The important check: no actual secret content leaks. */ + rc = sandbox_exec("cat /etc/shadow 2>&1 || echo BLOCKED", out, sizeof(out), 5000, NULL); + if (isolation_was_denied(rc, out)) + return 0; ASSERT(rc == 0); - /* Either permission denied or the echo BLOCKED message appears. */ ASSERT(strlen(out) > 0); + ASSERT(strstr(out, "root:") == NULL); return 0; } -#endif -/* ------------------------------------------------------------------ */ -/* 5.7 Benchmark: sandbox_exec("true") N times */ -/* ------------------------------------------------------------------ */ +static int listen_loopback_ephemeral(int *port_out) +{ + int fd; + int one = 1; + struct sockaddr_in addr; + socklen_t addr_len; + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one) != 0) { + close(fd); + return -1; + } + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0) { + close(fd); + return -1; + } + if (listen(fd, 1) != 0) { + close(fd); + return -1; + } + addr_len = sizeof addr; + if (getsockname(fd, (struct sockaddr *)&addr, &addr_len) != 0) { + close(fd); + return -1; + } + *port_out = (int)ntohs(addr.sin_port); + return fd; +} + +static int test_network_namespace_blocks_host_loopback(void) +{ + int port = 0; + int srv; + int rc; + char cmd[256]; + char out[4096]; + + if (access("/bin/bash", X_OK) != 0) { + fprintf(stderr, "test_sandbox: skip netns loopback test (no bash)\n"); + return 0; + } + srv = listen_loopback_ephemeral(&port); + ASSERT(srv >= 0); + ASSERT(port > 0); + snprintf(cmd, sizeof cmd, + "bash -c 'echo >/dev/tcp/127.0.0.1/%d' >/dev/null 2>&1 " + "&& echo CONNECTED || echo ISOLATED", + port); + rc = sandbox_exec(cmd, out, sizeof out, 5000, NULL); + close(srv); + ASSERT(strstr(out, "CONNECTED") == NULL); + if (isolation_was_denied(rc, out)) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "ISOLATED") != NULL); + return 0; +} +#endif static int benchmark_sandbox_exec(void) { @@ -128,8 +448,13 @@ static int benchmark_sandbox_exec(void) long times_us[BENCH_N]; char out[256]; int i; + int rc; long sum = 0; long median_us; + + rc = sandbox_exec("true", out, sizeof(out), 5000, NULL); + if (skip_if_isolation_denied(rc, out, "benchmark_sandbox_exec")) + return 0; for (i = 0; i < BENCH_N; i++) { struct timespec t0, t1; long diff_us; @@ -141,7 +466,6 @@ static int benchmark_sandbox_exec(void) times_us[i] = diff_us; sum += diff_us; } - /* Simple selection sort for median (small N). */ for (i = 0; i < BENCH_N - 1; i++) { int j, min_idx = i; for (j = i + 1; j < BENCH_N; j++) @@ -155,33 +479,73 @@ static int benchmark_sandbox_exec(void) median_us = times_us[BENCH_N / 2]; printf("sandbox_exec('true') N=%d: median=%ld µs avg=%ld µs\n", BENCH_N, median_us, sum / BENCH_N); - /* - * PRD §4.7.35 target: clone + setup < 1 ms. - * This assertion is informational; we mark slow CI as a skip rather than - * a hard failure. Uncomment the hard assert for local development: - * ASSERT(median_us < 1000); - */ if (median_us >= 2000) fprintf(stderr, "sandbox bench: median %ld µs exceeds 2 ms target (CI may be slow)\n", median_us); return 0; } -/* ------------------------------------------------------------------ */ -/* main */ -/* ------------------------------------------------------------------ */ +#ifdef __linux__ +static int test_command_inherits_cgroup(void) +{ + char probe[160]; + char out[4096]; + char *cg; + char *dir; + char *nl; + int rc; + + snprintf(probe, sizeof probe, "/sys/fs/cgroup/shellclaw_probe_%d", (int)getpid()); + if (mkdir(probe, 0755) != 0) { + fprintf(stderr, + "test_sandbox: skip test_command_inherits_cgroup (mkdir errno %d)\n", + errno); + return 0; + } + rmdir(probe); + rc = sandbox_exec( + "echo CG:$(cat /proc/self/cgroup); echo DIR:$(ls /sys/fs/cgroup 2>/dev/null | grep shellclaw_sb_ || true)", + out, sizeof out, 5000, NULL); + if (skip_if_isolation_denied(rc, out, "test_command_inherits_cgroup")) + return 0; + ASSERT(rc == 0); + dir = strstr(out, "DIR:"); + if (!dir || dir[4] == '\0' || dir[4] == '\n') { + fprintf(stderr, "test_sandbox: skip test_command_inherits_cgroup (no cgroup dir)\n"); + return 0; + } + cg = strstr(out, "CG:"); + ASSERT(cg != NULL); + nl = strchr(cg, '\n'); + if (nl) + *nl = '\0'; + ASSERT(strstr(cg, "shellclaw_sb_") != NULL); + return 0; +} +#endif int main(void) { + RUN(test_isolation_denied_helper()); + RUN(test_fail_closed_reports_isolation_error()); RUN(test_output_capture()); RUN(test_stderr_captured()); RUN(test_null_cmd_returns_error()); RUN(test_zero_cap_returns_error()); RUN(test_exit_nonzero_runs()); + RUN(test_exit_122_is_not_isolation_failure()); + RUN(test_exit_123_with_workspace_is_not_isolation_failure()); + RUN(test_pidns_fork_allows_second_command()); + RUN(test_dev_null_is_writable()); RUN(test_workspace_chdir()); + RUN(test_missing_workspace_fail_closed()); RUN(test_timeout_kills_process()); #ifdef __linux__ + RUN(test_command_inherits_cgroup()); + RUN(test_inherited_fd_is_closed()); + RUN(test_proc_is_namespaced()); RUN(test_shadow_not_accessible()); + RUN(test_network_namespace_blocks_host_loopback()); #else fprintf(stderr, "test_sandbox: Linux-only namespace tests skipped on this platform\n"); #endif From 541195a30a952a0cb2ba15458977052b28c9c901 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:52:39 -0300 Subject: [PATCH 85/92] fix(sandbox): bound host files with Landlock on the workspace The string allowlist cannot see interpreter concatenation such as chr(47)+. Enter the workspace before restrict_self, grant read on /etc/ssl/certs only, and split prepare() from restrict_self so gcov can still write. Landlock setup failure is fail-closed on the isolation pipe. --- CHANGELOG.md | 1 + Makefile | 11 +- src/sandbox/sandbox.c | 9 +- src/sandbox/sandbox.h | 23 +-- src/sandbox/sandbox_landlock.c | 243 ++++++++++++++++++++++++++ src/sandbox/sandbox_landlock.h | 39 +++++ src/tools/shell.c | 5 +- tests/test_sandbox.c | 307 ++++++++++++++++++++++++++++++++- 8 files changed, 617 insertions(+), 21 deletions(-) create mode 100644 src/sandbox/sandbox_landlock.c create mode 100644 src/sandbox/sandbox_landlock.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 710582b..f91e866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - Shell `workspace_only` walks the first existing ancestor, collapses `..` lexically (without cancelling across a symlink), and scans quoted/embedded paths, `file:` URLs, `$HOME`/`$PWD` (including glued `$IFS`), and bare relative names such as `cat leak`. `strdup` OOM is fail-closed. Encoded-slash cat-and-mouse is frozen; Landlock is the kernel host-FS bound. +- Shell `sandbox_exec` applies a Landlock ruleset to the configured workspace (fail-closed) as the kernel host-FS bound, so symlink and `chr(47)+` host reads cannot skip the string scanner. Mount/network/PID namespaces fail closed (user namespace first when unprivileged). After `CLONE_NEWPID` the isolator forks so the command is PID 1. Isolation failure uses a control pipe, not `sh` exit 122/123. `/dev/null` stays writable under Landlock. - Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` (including `memory.db-*` sidecars) when a custom workspace contains them. A bare `cat config.toml` after the sandbox `chdir` is blocked, and a symlink at the workspace path is not accepted. - Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). diff --git a/Makefile b/Makefile index ae2a06d..f40f391 100644 --- a/Makefile +++ b/Makefile @@ -125,7 +125,9 @@ CRON_O := src/tools/cron.o ASAP_INVOKE_O := src/tools/asap_invoke.o ASAP_INVOKE_TEST_O := $(BINDIR)/asap_invoke_test.o # Sandbox (Phase 3 §5) -SANDBOX_O := src/sandbox/sandbox.o +SANDBOX_CORE_O := src/sandbox/sandbox.o +SANDBOX_LANDLOCK_O := src/sandbox/sandbox_landlock.o +SANDBOX_O := $(SANDBOX_CORE_O) $(SANDBOX_LANDLOCK_O) ALLOWLIST_SCAN_O := src/sandbox/allowlist.o ALLOWLIST_PATH_O := src/sandbox/allowlist_path.o ALLOWLIST_O := $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) @@ -383,9 +385,12 @@ $(SHELL_O): src/tools/shell.c src/tools/tool.h src/tools/shell.h src/core/config src/sandbox/sandbox.h src/sandbox/allowlist.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/tools/shell.c -$(SANDBOX_O): src/sandbox/sandbox.c src/sandbox/sandbox.h +$(SANDBOX_CORE_O): src/sandbox/sandbox.c src/sandbox/sandbox.h src/sandbox/sandbox_landlock.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox.c +$(SANDBOX_LANDLOCK_O): src/sandbox/sandbox_landlock.c src/sandbox/sandbox_landlock.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/sandbox_landlock.c + $(ALLOWLIST_SCAN_O): src/sandbox/allowlist.c src/sandbox/allowlist.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/sandbox/allowlist.c @@ -904,7 +909,7 @@ clean-root-dsym: @rm -f shellclaw test_agent test_anthropic test_channel test_cli test_config test_file test_memory test_local_provider test_openai test_provider test_router test_shell test_skill test_telegram test_web_search test_ws clean: clean-root-dsym - rm -f $(OBJS) $(PROVIDER_COMMON_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(ROUTER_O) $(CJSON_O) $(TWEETNACL_O) $(ANTHROPIC_TEST_O) $(OPENAI_TEST_O) $(LOCAL_TEST_O) $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(CHANNEL_TG_TEST_O) $(CHANNEL_COMMON_O) $(CHANNEL_STUB_O) $(CHANNEL_CLI_O) $(CHANNEL_TG_O) $(CHANNEL_DISCORD_O) $(DISCORD_HELPERS_O) $(CHANNEL_HEARTBEAT_O) $(CHANNEL_WEBCHAT_O) $(AUTH_O) $(STATIC_O) $(HTTP_O) $(HTTP_LWS_O) $(ASAP_HTTP_BODY_O) $(ROUTES_O) $(ROUTES_HARDWARE_O) $(WS_O) $(MANIFEST_O) $(MANIFEST_PROFILES_O) $(MANIFEST_BUILD_O) $(MANIFEST_SIGN_O) $(MANIFEST_KEYS_O) $(ENVELOPE_O) $(ULID_O) $(CLIENT_O) $(ASAP_REGISTRY_O) $(SERVER_O) $(ASAP_LOG_O) $(RATE_LIMIT_O) $(SHELL_O) $(WEBSEARCH_O) $(FILE_O) $(REGISTRY_O) $(CONTEXT_O) $(CONTEXT_CACHE_O) $(CONTEXT_HTTP_O) $(CONTEXT_GEO_O) $(CRYPTO_O) $(JCS_O) $(HARDWARE_STUB_O) $(HARDWARE_INIT_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_TOOLS_O) $(BOARD_DETECT_O) src/hardware/hardware_libgpiod.o $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(CRON_O) $(ASAP_INVOKE_O) $(SANDBOX_O) $(ALLOWLIST_O) + rm -f $(OBJS) $(PROVIDER_COMMON_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(ROUTER_O) $(CJSON_O) $(TWEETNACL_O) $(ANTHROPIC_TEST_O) $(OPENAI_TEST_O) $(LOCAL_TEST_O) $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(CHANNEL_TG_TEST_O) $(CHANNEL_COMMON_O) $(CHANNEL_STUB_O) $(CHANNEL_CLI_O) $(CHANNEL_TG_O) $(CHANNEL_DISCORD_O) $(DISCORD_HELPERS_O) $(CHANNEL_HEARTBEAT_O) $(CHANNEL_WEBCHAT_O) $(AUTH_O) $(STATIC_O) $(HTTP_O) $(HTTP_LWS_O) $(ASAP_HTTP_BODY_O) $(ROUTES_O) $(ROUTES_HARDWARE_O) $(WS_O) $(MANIFEST_O) $(MANIFEST_PROFILES_O) $(MANIFEST_BUILD_O) $(MANIFEST_SIGN_O) $(MANIFEST_KEYS_O) $(ENVELOPE_O) $(ULID_O) $(CLIENT_O) $(ASAP_REGISTRY_O) $(SERVER_O) $(ASAP_LOG_O) $(RATE_LIMIT_O) $(SHELL_O) $(WEBSEARCH_O) $(FILE_O) $(REGISTRY_O) $(CONTEXT_O) $(CONTEXT_CACHE_O) $(CONTEXT_HTTP_O) $(CONTEXT_GEO_O) $(CRYPTO_O) $(JCS_O) $(HARDWARE_STUB_O) $(HARDWARE_INIT_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_TOOLS_O) $(BOARD_DETECT_O) src/hardware/hardware_libgpiod.o $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(CRON_O) $(ASAP_INVOKE_O) $(SANDBOX_CORE_O) $(SANDBOX_LANDLOCK_O) $(ALLOWLIST_SCAN_O) $(ALLOWLIST_PATH_O) rm -f src/gateway/ui_assets.h find . -name '*.gcno' -o -name '*.gcda' -o -name '*.gcov' | xargs rm -f 2>/dev/null || true rm -f $(WS_TEST_O) $(BINDIR)/asap_registry_test.o $(BINDIR)/asap_invoke_test.o $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(BINDIR)/shellclaw $(BINDIR)/test_tweetnacl_smoke $(BINDIR)/test_config $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider $(BINDIR)/test_anthropic $(BINDIR)/test_openai $(BINDIR)/test_local_provider $(BINDIR)/test_router $(BINDIR)/test_heartbeat $(BINDIR)/test_crypto $(BINDIR)/test_hardware_stub $(BINDIR)/test_board_detect $(BINDIR)/test_hardware_libgpiod $(BINDIR)/test_hardware_i2c $(BINDIR)/test_hardware_camera $(BINDIR)/test_pin_tables $(BINDIR)/test_hardware_init $(BINDIR)/test_hardware_tools $(BINDIR)/test_registry $(BINDIR)/test_ws $(BINDIR)/test_agent $(BINDIR)/test_channel $(BINDIR)/test_cli $(BINDIR)/test_shell $(BINDIR)/test_file $(BINDIR)/test_telegram $(BINDIR)/test_discord_helpers $(BINDIR)/test_web_search $(BINDIR)/test_cron $(BINDIR)/test_context $(BINDIR)/test_manifest_build $(BINDIR)/test_manifest_keys $(BINDIR)/test_jcs $(BINDIR)/test_asap_envelope $(BINDIR)/test_asap_ulid $(BINDIR)/test_asap_client $(BINDIR)/test_asap_registry $(BINDIR)/test_asap_server $(BINDIR)/test_asap_invoke $(BINDIR)/test_asap_log $(BINDIR)/test_auth $(BINDIR)/test_gateway_http $(BINDIR)/test_static $(BINDIR)/test_sandbox $(BINDIR)/test_allowlist $(BINDIR)/test_rate_limit diff --git a/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 192a5b0..4aac1a7 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -1,6 +1,6 @@ /** * @file sandbox.c - * @brief Process sandbox: Linux namespaces, cgroups v2, timeout. + * @brief Process sandbox: Linux namespaces, Landlock FS bound, cgroups v2. * * Linux path: fork the isolator, then unshare mount/network/PID (user ns * first when unprivileged). Isolation failure is reported on a control pipe, @@ -8,7 +8,8 @@ * setup failures write that byte before exiting; a 0-byte read is success. * After CLONE_NEWPID, fork so the command * is PID 1 (unshare does not move the caller). That child fchdir's the - * workspace, remounts procfs, sets PR_SET_PDEATHSIG, and closes fds >= 3 + * workspace (Landlock cannot walk `/tmp` after restrict_self), remounts + * procfs, applies Landlock, sets PR_SET_PDEATHSIG, and closes fds >= 3 * so a timeout SIGKILL of the isolator cannot leave the command under * host init. The isolator joins its cgroup before the command fork so * memory.max and cpu.max apply to sh -c. Limits degrade if unavailable. @@ -19,6 +20,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_landlock.h" #include #include #include @@ -223,6 +225,9 @@ static unsigned char setup_command_process(const char *workspace) return SANDBOX_ISO_NS; if (remount_procfs() != 0) return SANDBOX_ISO_NS; + if (workspace && workspace[0] && + sandbox_landlock_restrict_to_workspace(workspace) != 0) + return SANDBOX_ISO_LL; return 0; } diff --git a/src/sandbox/sandbox.h b/src/sandbox/sandbox.h index 280a7dc..277d513 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -1,15 +1,14 @@ /** * @file sandbox.h - * @brief Process sandbox API: namespaces, timeout, cgroups v2. + * @brief Process sandbox API: namespaces, Landlock FS bound, timeout, cgroups v2. * * On Linux, sandbox_exec forks an isolator, unshares PID/mount/network * namespaces (entering a user namespace when unprivileged), then forks again * so the command is PID 1. Isolation failure is fail-closed via a control - * pipe (not sh exit 122/123). The copied mount tree must become - * MS_REC|MS_PRIVATE, and the command child remounts proc. Optional cgroups - * v2 resource limits are joined before the command fork. A hard timeout - * SIGKILLs the isolator; the command child sets PR_SET_PDEATHSIG so the - * PID-1 process cannot outlive it. + * pipe (not sh exit 122/123). When a workspace path is set, a Landlock + * ruleset is the kernel filesystem bound. Optional cgroups v2 resource + * limits and a hard timeout with SIGKILL (the command child sets + * PR_SET_PDEATHSIG so the PID-1 process cannot outlive the isolator). * On other platforms (macOS, BSDs) it falls back to a plain fork+exec * and logs a warning. */ @@ -51,11 +50,13 @@ typedef struct sandbox_config { * * On Linux, enters a user namespace when needed, then unshares * CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET and forks so the command is - * PID 1. If those namespaces or MS_PRIVATE cannot be applied, returns -1 - * (fail-closed). Setup failure is a byte on the control pipe; EOF is - * success. Applies cgroups v2 limits when available, joining before the - * command fork. On timeout the parent SIGKILLs the isolator; the command - * is PID 1 and dies via PR_SET_PDEATHSIG and process-group kill. + * PID 1. If those namespaces cannot be applied, returns -1 (fail-closed). + * When @p cfg->workspace_path is set, applies a Landlock ruleset that denies + * host filesystem reads/writes outside the workspace (blocking symlink and + * interpreter path escapes such as `chr(47)+`); Landlock setup failure also + * returns -1. Applies cgroups v2 limits when available. On timeout the + * parent SIGKILLs the isolator; the command is PID 1 and dies via + * PR_SET_PDEATHSIG and process-group kill. * * On non-Linux platforms the function executes the command via fork()+exec() * without namespace isolation and emits a warning to stderr. diff --git a/src/sandbox/sandbox_landlock.c b/src/sandbox/sandbox_landlock.c new file mode 100644 index 0000000..fd2d040 --- /dev/null +++ b/src/sandbox/sandbox_landlock.c @@ -0,0 +1,243 @@ +/** + * @file sandbox_landlock.c + * @brief Linux Landlock ruleset: RW workspace, RO system paths, RW /dev/null. + * + * Probe the kernel ABI and pass only bits it understands. create_ruleset uses + * the ABI-1 attr size so older kernels are not E2BIG. Missing optional RO + * paths are skipped; the workspace rule and restrict_self fail closed. + */ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include "sandbox/sandbox_landlock.h" + +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#endif + +#ifndef __linux__ +int sandbox_landlock_restrict_to_workspace(const char *workspace) +{ + (void)workspace; + return 0; +} + +int sandbox_landlock_prepare(const char *workspace) +{ + (void)workspace; + return 0; +} +#else + +#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV +#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15) +#endif + +static __u64 landlock_abi1_fs_rights(void) +{ + return LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; +} + +static int landlock_handled_fs(__u64 *handled_out) +{ + int abi; + __u64 handled; + + if (!handled_out) + return -1; + abi = (int)syscall(__NR_landlock_create_ruleset, NULL, 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 1) + return -1; + handled = landlock_abi1_fs_rights(); +#ifdef LANDLOCK_ACCESS_FS_REFER + if (abi >= 2) + handled |= LANDLOCK_ACCESS_FS_REFER; +#endif +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + if (abi >= 3) + handled |= LANDLOCK_ACCESS_FS_TRUNCATE; +#endif + if (abi >= 5) + handled |= LANDLOCK_ACCESS_FS_IOCTL_DEV; + *handled_out = handled; + return 0; +} + +static int landlock_add_path(int ruleset_fd, const char *path, __u64 dir_access, + __u64 file_access) +{ + int pfd; + struct stat st; + struct landlock_path_beneath_attr pb; + long rc; + + pfd = open(path, O_PATH | O_CLOEXEC); + if (pfd < 0) + return 0; + memset(&pb, 0, sizeof(pb)); + pb.parent_fd = pfd; + if (fstat(pfd, &st) == 0 && S_ISDIR(st.st_mode)) + pb.allowed_access = dir_access; + else + pb.allowed_access = file_access; + rc = syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &pb, 0); + close(pfd); + (void)rc; + return 0; +} + +static int landlock_add_workspace(int ruleset_fd, const char *workspace, __u64 access) +{ + int ws_fd; + struct landlock_path_beneath_attr pb; + long rc; + + ws_fd = open(workspace, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (ws_fd < 0) + return -1; + memset(&pb, 0, sizeof(pb)); + pb.allowed_access = access; + pb.parent_fd = ws_fd; + rc = syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &pb, 0); + close(ws_fd); + return (rc == 0) ? 0 : -1; +} + +static __u64 mask_workspace_access(__u64 handled) +{ + __u64 access; + + access = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_SOCK) & handled; +#ifdef LANDLOCK_ACCESS_FS_REFER + access |= (LANDLOCK_ACCESS_FS_REFER & handled); +#endif +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + access |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); +#endif + return access; +} + +#define LANDLOCK_FD_SKIP (-2) + +static int landlock_make_ruleset(const char *workspace) +{ + static const char *const RO_PATHS[] = { + "/bin", "/usr", "/usr/local", "/lib", "/lib64", "/lib32", + "/etc/ld.so.cache", "/etc/ld.so.conf", "/etc/ld.so.conf.d", + /* Public CA bundle only. /etc/ssl/private stays outside the ruleset. */ + "/etc/ssl/certs", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf", + "/proc", + NULL + }; + static const char *const RW_DEV[] = { + "/dev/null", "/dev/zero", "/dev/urandom", "/dev/tty", + NULL + }; + __u64 handled; + __u64 workspace_access; + __u64 ro_dir; + __u64 ro_file; + __u64 rw_file; + struct landlock_ruleset_attr attr; + int ruleset_fd; + size_t i; + + if (!workspace || !workspace[0]) + return LANDLOCK_FD_SKIP; + if (landlock_handled_fs(&handled) != 0) + return -1; + workspace_access = mask_workspace_access(handled); + ro_dir = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR) & handled; + ro_file = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE) & handled; + rw_file = (LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_WRITE_FILE) & handled; +#ifdef LANDLOCK_ACCESS_FS_TRUNCATE + rw_file |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); +#endif + rw_file |= (LANDLOCK_ACCESS_FS_IOCTL_DEV & handled); + memset(&attr, 0, sizeof(attr)); + attr.handled_access_fs = handled; + ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, + sizeof(attr.handled_access_fs), 0); + if (ruleset_fd < 0) + return -1; + if (landlock_add_workspace(ruleset_fd, workspace, workspace_access) != 0) { + close(ruleset_fd); + return -1; + } + /* Traverse-only `/` so execl("/bin/sh") can walk to RO trees. READ_DIR + * does not grant READ_FILE, so /etc/passwd stays closed. */ + if (landlock_add_workspace(ruleset_fd, "/", + LANDLOCK_ACCESS_FS_READ_DIR & handled) != 0) { + close(ruleset_fd); + return -1; + } + for (i = 0; RO_PATHS[i]; i++) + (void)landlock_add_path(ruleset_fd, RO_PATHS[i], ro_dir, ro_file); + for (i = 0; RW_DEV[i]; i++) + (void)landlock_add_path(ruleset_fd, RW_DEV[i], ro_dir, rw_file); + return ruleset_fd; +} + +int sandbox_landlock_prepare(const char *workspace) +{ + int fd; + fd = landlock_make_ruleset(workspace); + if (fd == LANDLOCK_FD_SKIP) + return 0; + if (fd < 0) + return -1; + close(fd); + return 0; +} + +int sandbox_landlock_restrict_to_workspace(const char *workspace) +{ + int fd; + long rc; + fd = landlock_make_ruleset(workspace); + if (fd == LANDLOCK_FD_SKIP) + return 0; + if (fd < 0) + return -1; + rc = syscall(__NR_landlock_restrict_self, fd, 0); + close(fd); + return (rc == 0) ? 0 : -1; +} + +#endif /* __linux__ */ diff --git a/src/sandbox/sandbox_landlock.h b/src/sandbox/sandbox_landlock.h new file mode 100644 index 0000000..ff48819 --- /dev/null +++ b/src/sandbox/sandbox_landlock.h @@ -0,0 +1,39 @@ +/** + * @file sandbox_landlock.h + * @brief Landlock workspace filesystem bound for sandbox_exec (Linux). + * + * Non-Linux builds compile a stub that returns 0. On Linux, failure is + * fail-closed: the caller must not exec on the host tree. + */ +#ifndef SHELLCLAW_SANDBOX_LANDLOCK_H +#define SHELLCLAW_SANDBOX_LANDLOCK_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Restrict the calling thread to @p workspace (RW) plus a small RO/exec set + * needed by /bin/sh. Empty @p workspace skips Landlock (returns 0). + * + * @param workspace Absolute workspace directory, or NULL/empty to skip. + * @return 0 on success or skip; -1 if the ruleset cannot apply. + * + * Example: sandbox_landlock_restrict_to_workspace("/home/user/.shellclaw"); + */ +int sandbox_landlock_restrict_to_workspace(const char *workspace); + +/** + * Build and discard the workspace ruleset without restrict_self. + * Same skip/error contract as restrict. Used so tests can cover the + * builder without locking the process (gcov cannot write after restrict). + * + * Example: sandbox_landlock_prepare("/tmp/ws"); + */ +int sandbox_landlock_prepare(const char *workspace); + +#ifdef __cplusplus +} +#endif + +#endif /* SHELLCLAW_SANDBOX_LANDLOCK_H */ diff --git a/src/tools/shell.c b/src/tools/shell.c index d2c02fa..65a2d97 100644 --- a/src/tools/shell.c +++ b/src/tools/shell.c @@ -3,8 +3,9 @@ * @brief Shell tool: execute commands, with optional sandbox isolation. * * When config_sandbox_enabled() is true, commands are checked via - * allowlist_check_shell_command() and executed inside sandbox_exec() (namespace - * isolation + cgroups v2 where available). + * allowlist_check_shell_command() and executed inside sandbox_exec() + * (namespaces + Landlock workspace bound + cgroups v2 where available). + * Isolation failure is fail-closed. * * When the sandbox is disabled (default), a best-effort substring blocklist * is applied and the command runs via fork()/execl() with the same pipe-and- diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 6febd6f..45fa2d0 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -2,10 +2,11 @@ * @file test_sandbox.c * @brief Unit tests for sandbox_exec. * - * Linux-specific namespace and cgroup tests are guarded by + * Linux-specific namespace, Landlock, and cgroup tests are guarded by * #ifdef __linux__. GitHub-hosted runners often cannot apply user namespaces - * (sandbox_exec fail-closes; success-path tests skip). Do not weaken - * sandbox.c for CI. + * (sandbox_exec fail-closes; success-path tests skip). The Landlock + * builder is exercised in-process via prepare(); restrict_self runs in a + * child so gcov can still write. Do not weaken sandbox.c for CI. * * 5.7 Benchmark: run sandbox_exec("true") 200 times and report median. * The benchmark is informational only — it does not gate the test suite. @@ -15,6 +16,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_landlock.h" #include #include #include @@ -361,6 +363,166 @@ static int test_proc_is_namespaced(void) return 0; } +static int test_landlock_probe_without_restrict(void) +{ + char workspace[] = "/tmp/sc_ll_prep_XXXXXX"; + char *ws; + ASSERT(sandbox_landlock_restrict_to_workspace(NULL) == 0); + ASSERT(sandbox_landlock_restrict_to_workspace("") == 0); + ASSERT(sandbox_landlock_restrict_to_workspace("/no/such/sc_ll_ws") == -1); + ASSERT(sandbox_landlock_prepare(NULL) == 0); + ASSERT(sandbox_landlock_prepare("") == 0); + ASSERT(sandbox_landlock_prepare("/no/such/sc_ll_ws") == -1); + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_landlock_probe_without_restrict: mkdtemp failed\n"); + return 1; + } + ASSERT(sandbox_landlock_prepare(ws) == 0); + rmdir(ws); + return 0; +} + +static int test_landlock_restrict_denies_etc_passwd(void) +{ + char workspace[] = "/tmp/sc_ll_XXXXXX"; + char *ws; + pid_t pid; + int st; + int status; + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_landlock_restrict_denies_etc_passwd: mkdtemp failed\n"); + return 1; + } + pid = fork(); + if (pid < 0) { + rmdir(ws); + return 1; + } + if (pid == 0) { + FILE *f; + int pfd; + if (chdir(ws) != 0) + _exit(5); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + _exit(3); + if (sandbox_landlock_restrict_to_workspace(ws) != 0) + _exit(2); + f = fopen("ok", "w"); + if (!f) + _exit(4); + fclose(f); + unlink("ok"); + pfd = open("/etc/passwd", O_RDONLY | O_CLOEXEC); + if (pfd >= 0) { + close(pfd); + _exit(1); + } + _exit(0); + } + if (waitpid(pid, &st, 0) < 0) { + rmdir(ws); + return 1; + } + rmdir(ws); + if (!WIFEXITED(st)) { + fprintf(stderr, "FAIL: tests/test_sandbox.c: landlock child did not exit (st=%d)\n", + st); + return 1; + } + status = WEXITSTATUS(st); + if (status == 2 || status == 3) { + fprintf(stderr, "test_sandbox: skip test_landlock_restrict_denies_etc_passwd (WEXITSTATUS=%d)\n", + status); + return 0; + } + if (status != 0) { + fprintf(stderr, "FAIL: tests/test_sandbox.c: test_landlock_restrict_denies_etc_passwd WEXITSTATUS=%d (0=denied 1=passwd_open 4=ws_write 5=chdir)\n", + status); + return 1; + } + return 0; +} + +static int test_landlock_denies_etc_ssl_outside_certs(void) +{ + char workspace[] = "/tmp/sc_ll_ssl_XXXXXX"; + char *ws; + pid_t pid; + int st; + int status; + int host_cnf; + int host_private; + + host_cnf = access("/etc/ssl/openssl.cnf", R_OK) == 0; + host_private = access("/etc/ssl/private", R_OK) == 0; + if (!host_cnf && !host_private) { + fprintf(stderr, + "test_sandbox: skip test_landlock_denies_etc_ssl_outside_certs (no readable /etc/ssl targets)\n"); + return 0; + } + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_landlock_denies_etc_ssl_outside_certs: mkdtemp failed\n"); + return 1; + } + pid = fork(); + if (pid < 0) { + rmdir(ws); + return 1; + } + if (pid == 0) { + int pfd; + int saw_cnf = access("/etc/ssl/openssl.cnf", R_OK) == 0; + int saw_private = access("/etc/ssl/private", R_OK) == 0; + if (chdir(ws) != 0) + _exit(5); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + _exit(3); + if (sandbox_landlock_restrict_to_workspace(ws) != 0) + _exit(2); + if (saw_cnf) { + pfd = open("/etc/ssl/openssl.cnf", O_RDONLY | O_CLOEXEC); + if (pfd >= 0) { + close(pfd); + _exit(1); + } + } + if (saw_private) { + pfd = open("/etc/ssl/private", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (pfd >= 0) { + close(pfd); + _exit(7); + } + } + _exit(0); + } + if (waitpid(pid, &st, 0) < 0) { + rmdir(ws); + return 1; + } + rmdir(ws); + if (!WIFEXITED(st)) { + fprintf(stderr, + "FAIL: tests/test_sandbox.c: ssl landlock child did not exit (st=%d)\n", st); + return 1; + } + status = WEXITSTATUS(st); + if (status == 2 || status == 3) { + fprintf(stderr, + "test_sandbox: skip test_landlock_denies_etc_ssl_outside_certs (WEXITSTATUS=%d)\n", + status); + return 0; + } + if (status != 0) { + fprintf(stderr, + "FAIL: tests/test_sandbox.c: test_landlock_denies_etc_ssl_outside_certs WEXITSTATUS=%d (0=denied 1=cnf_open 7=private_open)\n", + status); + return 1; + } + return 0; +} #endif #ifdef __linux__ @@ -378,6 +540,137 @@ static int test_shadow_not_accessible(void) return 0; } +static int test_workspace_landlock_blocks_symlink_escape(void) +{ + char workspace[] = "/tmp/sc_sb_ws_XXXXXX"; + char leak_path[256]; + char out[4096]; + sandbox_config_t cfg; + char *ws; + int rc; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: mkdtemp failed\n"); + return 1; + } + snprintf(leak_path, sizeof(leak_path), "%s/leak", ws); + if (symlink("/etc/passwd", leak_path) != 0) { + rmdir(ws); + fprintf(stderr, "test_workspace_landlock_blocks_symlink_escape: symlink failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("cat leak 2>&1; echo EXIT:$?", out, sizeof(out), 5000, &cfg); + unlink(leak_path); + rmdir(ws); + ASSERT(strstr(out, "root:x:") == NULL); + if (isolation_was_denied(rc, out)) + return 0; + ASSERT(rc == 0); + ASSERT(strstr(out, "Permission denied") != NULL || + strstr(out, "No such file") != NULL || + strstr(out, "EXIT:1") != NULL || + strstr(out, "EXIT:2") != NULL); + return 0; +} + +static int test_workspace_landlock_blocks_abs_etc(void) +{ + char workspace[] = "/tmp/sc_sb_ws2_XXXXXX"; + char out[4096]; + char outp[256]; + sandbox_config_t cfg; + char *ws; + int rc; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_blocks_abs_etc: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("cat /etc/passwd 2>&1; echo EXIT:$?", out, sizeof(out), 5000, &cfg); + ASSERT(strstr(out, "root:x:") == NULL); + if (isolation_was_denied(rc, out)) { + rmdir(ws); + return 0; + } + ASSERT(rc == 0); + ASSERT(strstr(out, "EXIT:0") == NULL); + if (access("/usr/bin/python3", X_OK) == 0 || access("/bin/python3", X_OK) == 0) { + FILE *wrote; + char buf[64]; + size_t nread; + rc = sandbox_exec( + "python3 -c 'open(\"out\",\"w\").write(open(chr(47)+\"etc\"+chr(47)+\"passwd\").read())' 2>&1; " + "echo EXIT:$?", + out, sizeof(out), 8000, &cfg); + snprintf(outp, sizeof(outp), "%s/out", ws); + wrote = fopen(outp, "r"); + if (wrote) { + nread = fread(buf, 1, sizeof buf - 1, wrote); + fclose(wrote); + buf[nread] = '\0'; + ASSERT(strstr(buf, "root:") == NULL); + } + unlink(outp); + ASSERT(strstr(out, "root:x:") == NULL); + if (!isolation_was_denied(rc, out)) { + ASSERT(rc == 0); + ASSERT(strstr(out, "EXIT:0") == NULL); + } + } + rmdir(ws); + return 0; +} + +static int test_workspace_landlock_allows_workspace_write(void) +{ + char workspace[] = "/tmp/sc_sb_wr_XXXXXX"; + char out[4096]; + char wrote[256]; + char buf[64]; + sandbox_config_t cfg; + char *ws; + FILE *f; + int rc; + + ws = mkdtemp(workspace); + if (!ws) { + fprintf(stderr, "test_workspace_landlock_allows_workspace_write: mkdtemp failed\n"); + return 1; + } + memset(&cfg, 0, sizeof cfg); + cfg.workspace_path = ws; + rc = sandbox_exec("echo landlock_ok > wrote.txt", out, sizeof(out), 5000, &cfg); + snprintf(wrote, sizeof(wrote), "%s/wrote.txt", ws); + if (isolation_was_denied(rc, out)) { + unlink(wrote); + rmdir(ws); + fprintf(stderr, "test_sandbox: skip test_workspace_landlock_allows_workspace_write (%s)\n", + out); + return 0; + } + ASSERT(rc == 0); + f = fopen(wrote, "r"); + ASSERT(f != NULL); + if (fgets(buf, sizeof(buf), f) == NULL) { + fclose(f); + unlink(wrote); + rmdir(ws); + fprintf(stderr, "FAIL: %s:%d fgets wrote.txt\n", __FILE__, __LINE__); + return 1; + } + fclose(f); + ASSERT(strstr(buf, "landlock_ok") != NULL); + unlink(wrote); + rmdir(ws); + return 0; +} + static int listen_loopback_ephemeral(int *port_out) { int fd; @@ -541,15 +834,23 @@ int main(void) RUN(test_missing_workspace_fail_closed()); RUN(test_timeout_kills_process()); #ifdef __linux__ + RUN(test_landlock_probe_without_restrict()); RUN(test_command_inherits_cgroup()); RUN(test_inherited_fd_is_closed()); RUN(test_proc_is_namespaced()); RUN(test_shadow_not_accessible()); + RUN(test_workspace_landlock_blocks_symlink_escape()); + RUN(test_workspace_landlock_blocks_abs_etc()); + RUN(test_workspace_landlock_allows_workspace_write()); RUN(test_network_namespace_blocks_host_loopback()); #else fprintf(stderr, "test_sandbox: Linux-only namespace tests skipped on this platform\n"); #endif RUN(benchmark_sandbox_exec()); +#ifdef __linux__ + RUN(test_landlock_restrict_denies_etc_passwd()); + RUN(test_landlock_denies_etc_ssl_outside_certs()); +#endif printf("test_sandbox: all tests passed\n"); return 0; } From ec9ef6ba548731159f2df282e160d9e794be428f Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 17:52:50 -0300 Subject: [PATCH 86/92] docs(security): record Landlock, MS_PRIVATE, and proc remount Namespace setup is fail-closed, the mount tree is made private, and proc is remounted for PID 1. Landlock, not the string allowlist, is the kernel host-FS bound when a workspace path is set. --- docs/ARCHITECTURE.md | 10 ++++++---- docs/SECURITY.md | 33 +++++++++++++++++---------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7b24d2..bd52b26 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,7 +62,7 @@ Shell commands run in a **Linux sandbox** (namespaces + cgroups v2). Hardware to | `channels/` | Inbound/outbound I/O | CLI, Telegram, Discord, WebChat, heartbeat | | `gateway/` | Embedded HTTP/WebSocket server, pairing auth, rate limits, static Web UI | `http_lws`, `routes.c`, `routes_hardware.c` | | `asap/` | Protocol client/server, envelope, ULID, registry cache, signed manifest | `manifest_build_signed_json()`, `POST /asap` | -| `sandbox/` | Process isolation for shell tool | `sandbox_run()` — `unshare(CLONE_NEWNS\|NEWNET\|NEWPID)`, no `pivot_root` | +| `sandbox/` | Process isolation for shell tool | `sandbox_exec()` — user ns + `unshare(CLONE_NEWNS\|NEWNET\|NEWPID)` then fork for PID 1, Landlock workspace bound, no `pivot_root` | | `hardware/` | Board abstraction: GPIO (libgpiod), I2C (`/dev/i2c-N`), camera (fixed-argv CLI spawn) | `hardware_init()`, `board_detect()` | | `crypto/` | Ed25519 signing + JCS canonicalization for manifests | `manifest_keys_ensure_loaded()` (lazy on manifest GET), `jcs.c` | @@ -137,9 +137,11 @@ v1.0 ships static manifest discovery; live cross-agent HTTP and full compliance Linux path (`src/sandbox/sandbox.c`): -- Child: `unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID)` then `exec` shell command. -- **No** `mount()`, bind-mount, or `pivot_root()` — GPU device nodes and Argus socket are not injected. -- Allowlist in `src/sandbox/allowlist.c` blocks dangerous paths and Jetson GPU `/dev` literals. +- Isolator: user namespace when unprivileged, then `unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID)`, then **fork** so the command is PID 1. +- Isolation failure is fail-closed via a control pipe (not `sh` exit 122/123). +- Landlock workspace bound when `workspace_path` is set (RW workspace, RO `/bin` `/usr` `/lib*`, RW `/dev/null`). +- **No** `pivot_root()` — GPU device nodes are not granted by Landlock; the allowlist still blocks `/dev/nv*` literals. +- Allowlist in `src/sandbox/allowlist.c` is defense-in-depth (quoted paths, `$HOME`/`$PWD`, `file:` URLs). - cgroups v2: `memory.max`, `cpu.max` when writable under `/sys/fs/cgroup`. Non-Linux: plain `fork`/`exec` with a stderr warning. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b93aa9d..073bdcc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -23,7 +23,7 @@ The primary goals are: prevent sandboxed shell commands from escaping to host de | ID | Finding | Severity | Mitigation | Residual risk | Tests / verification | |----|---------|----------|------------|---------------|----------------------| -| 7.1 | Jetson GPU `/dev` nodes visible in shell mount namespace (no `pivot_root`, no bind-mount isolation) | Medium | Substring blocklist for `/dev/nvhost`, `/dev/nvgpu`, `/dev/nvmap` in `allowlist.c`; `sandbox_exec()` uses `unshare` only | Indirect paths or globs may bypass literal blocklist | `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`) | +| 7.1 | Jetson GPU `/dev` nodes visible in shell mount namespace (no `pivot_root`, no bind-mount isolation) | Medium | Landlock workspace bound does not grant `/dev/nv*`; substring blocklist for `/dev/nvhost`, `/dev/nvgpu`, `/dev/nvmap` in `allowlist.c` | Without Landlock (non-Linux / sandbox off), globs may bypass the literal blocklist | `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`), `tests/test_sandbox.c` | | 7.1 | `pivot_root` hardening not implemented | Low (documented) | Allowlist + namespace network/PID isolation | Full mount-slave `/dev` tmpfs deferred post-v1.0 | Source audit of `sandbox.c` | | 7.2 | Camera capture could invoke shell with user-controlled pipeline strings | High (if present) | **Not present:** `hardware_camera_capture()` uses `execvp` + fixed `argv[]`; strict input validation | N/A when validation holds | `tests/test_hardware_camera.c` (injection + `test_no_shell_invocation`) | | 7.3 | Sandboxed shell could reach Argus IPC socket | Medium | Blocklist `/tmp/argus_socket` and `argus_socket`; camera spawn runs outside shell sandbox by design | Compromised agent process can still spawn GStreamer | `tests/test_allowlist.c` (`test_block_argus_socket`) | @@ -39,10 +39,11 @@ The primary goals are: prevent sandboxed shell commands from escaping to host de | Surface | Mechanism | Notes | |---------|-----------|-------| -| Shell (sandbox on) | `fork()` + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + `prctl(PR_SET_NO_NEW_PRIVS)` | See [Linux sandbox (Jetson)](#linux-sandbox-jetson) | +| Shell (sandbox on) | `fork()` + user ns when needed + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + fork for PID 1 + Landlock workspace bound + `prctl(PR_SET_NO_NEW_PRIVS)` | Fail-closed if namespaces or Landlock cannot apply. Isolation uses a control pipe, not `sh` exit 122/123. See [Linux sandbox (Jetson)](#linux-sandbox-jetson) | | Shell (sandbox off) | Plain `fork()` + substring fallback blocklist | **Not** a security boundary; stderr warning | -| Allowlist | Substring blocklist + optional workspace `realpath` containment | Defense in depth before `sandbox_exec` | -| cgroups v2 | `memory.max`, `cpu.max` on child PID | Best-effort; non-fatal if cgroup write fails | +| Allowlist | Substring blocklist + workspace containment (ancestor walk, quoted/embedded `/` `~`, `$HOME`/`$PWD`, `file:` URLs, relative names) | Defense-in-depth string scan; Landlock is the kernel host-FS bound | +| Landlock | Ruleset on configured `workspace_path` (RW workspace + traverse-only `/` + RO `/bin` `/usr` `/lib` and `/etc/ssl/certs` only; RW `/dev/null`). Child `fchdir`s the workspace before `restrict_self`. | Primary host-FS gate; `/` is `READ_DIR` only so `/etc/passwd` stays closed. `/etc/ssl/private` is not granted. Blocks symlink and `chr(47)+` host reads | +| cgroups v2 | `memory.max`, `cpu.max`; isolator writes `cgroup.procs` before the command fork | Best-effort; non-fatal if cgroup write fails | | Hardware GPIO/I2C | libgpiod / `i2c-dev` in agent process | Not exposed inside shell namespace | Implementation: [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c), [`src/sandbox/allowlist.c`](../src/sandbox/allowlist.c). @@ -55,13 +56,13 @@ Implementation: [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c), [`src/sandb ### GPU device nodes — not bind-mounted -`sandbox_exec()` does **not** call `mount()`, `bind()`, or `pivot_root()`. The child namespace is created only with: +`sandbox_exec()` does **not** call `bind()` or `pivot_root()`. After `unshare(CLONE_NEWNS)` the child makes the copied mount tree `MS_REC|MS_PRIVATE` and, once the command is PID 1, remounts `proc` on `/proc`. It does not bind-mount Tegra GPU devices. ```c unshare(CLONE_NEWNS | CLONE_NEWNET | CLONE_NEWPID); ``` -Therefore ShellClaw never bind-mounts Tegra GPU devices into the sandbox. In particular, these paths are **not** explicitly mounted into the shell namespace: +In particular, these paths are **not** explicitly mounted into the shell namespace: - `/dev/nvhost-*` - `/dev/nvgpu` @@ -69,11 +70,11 @@ Therefore ShellClaw never bind-mounts Tegra GPU devices into the sandbox. In par ### `pivot_root` — not used (v1.0) -The task checklist references `unshare(CLONE_NEWNS) + pivot_root` as a hardened pattern. **v1.0 does not implement `pivot_root`.** After `unshare(CLONE_NEWNS)`, the child inherits a **copy** of the host mount tree (default propagation). Jetson `/dev` nodes remain visible inside the new mount namespace unless blocked elsewhere. +The task checklist references `unshare(CLONE_NEWNS) + pivot_root` as a hardened pattern. **v1.0 does not implement `pivot_root`.** After `unshare(CLONE_NEWNS)`, the child inherits a **copy** of the host mount tree (made `MS_PRIVATE`). Jetson `/dev` nodes remain in that mount namespace; Landlock (when a workspace path is set) is the kernel FS bound and does not grant `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap`. -**Mitigation in v1.0:** the shell allowlist rejects commands whose text references `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap` (substring blocklist). Regression tests live in `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`). +**Mitigation in v1.0:** Landlock plus the shell allowlist, which rejects commands whose text references `/dev/nvhost`, `/dev/nvgpu`, or `/dev/nvmap` (substring blocklist). Regression tests live in `tests/test_allowlist.c` (`test_block_jetson_gpu_devices`) and `tests/test_sandbox.c`. -**Residual risk:** a crafted command that opens GPU nodes without those literal substrings (e.g. shell globs or indirect paths) may still reach devices until a future release adds mount-slave propagation, a minimal `/dev` tmpfs, or seccomp. Track as post-v1.0 hardening. +**Residual risk:** a crafted command that opens GPU nodes without those literal substrings (e.g. shell globs) is still denied by Landlock when a workspace path is set (`/dev/nv*` is not in the grant list). Without Landlock (non-Linux, or sandbox off) the substring blocklist remains best-effort. `workspace_only` walks a missing destination’s existing ancestor, collapses `..` lexically (without cancelling `..` across a symlink), scans quoted/embedded `/` `~` and relative `../`, extracts and percent-decodes `file:` URLs, fail-closes in-command `HOME`/`PWD` assignment, and expands or fail-closes `$` on the full command (including glued `$IFS` and mid-token `$HOME`). The string scanner is not a language interpreter: `open(chr(47)+'etc/passwd')` has no path character in the command text and stays residual **on the scanner**; Landlock is the kernel bound for that class. Encoded-slash / identity-escape cat-and-mouse is frozen. Conservative regex false positives such as `awk '/foo/'` remain. ### Board-agnostic blocklist entries (Jetson literals) @@ -82,7 +83,7 @@ The task checklist references `unshare(CLONE_NEWNS) + pivot_root` as a hardened ### Network and PID isolation - `CLONE_NEWNET` — sandboxed shell has no routable network (no interface setup in child). -- `CLONE_NEWPID` — PID namespace; child is PID 1 in its namespace for the `sh -c` session. +- `CLONE_NEWPID` — PID namespace; the isolator forks so the command process is PID 1 (unshare does not move the caller). ### JetPack 6 / kernel 5.15 note @@ -120,7 +121,7 @@ On Jetson CSI cameras, frame capture uses GStreamer `nvarguscamerasrc`, which is | `/tmp/argus_socket` | Unix domain socket used by Argus clients (JetPack 6.x default path) | | `gst-launch-1.0` + `nvarguscamerasrc` | Child process spawned by ShellClaw **outside** the shell sandbox | -ShellClaw does **not** bind-mount `/tmp/argus_socket` into the sandboxed shell namespace (`sandbox_exec` uses only `unshare`, per § [Linux sandbox (Jetson)](#linux-sandbox-jetson)). +ShellClaw does **not** bind-mount `/tmp/argus_socket` into the sandboxed shell namespace (`sandbox_exec` uses `unshare` without `pivot_root`, per § [Linux sandbox (Jetson)](#linux-sandbox-jetson)). Landlock does not grant that socket path. ### Who may talk to Argus @@ -267,8 +268,8 @@ This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do | Surface | Jetson-specific behavior | Primary mitigation | Source | |---------|-------------------------|-------------------|--------| -| Shell sandbox | Tegra GPU character devices remain in inherited mount namespace | Literal blocklist on `/dev/nvhost*`, `/dev/nvgpu`, `/dev/nvmap` | `src/sandbox/allowlist.c` | -| Shell sandbox | No `pivot_root` / minimal `/dev` in v1.0 | Documented residual risk; allowlist defense in depth | `src/sandbox/sandbox.c` | +| Shell sandbox | Tegra GPU character devices remain in inherited mount namespace | Landlock does not grant `/dev/nv*`; literal blocklist on `/dev/nvhost*`, `/dev/nvgpu`, `/dev/nvmap` | `src/sandbox/allowlist.c`, `sandbox_landlock.c` | +| Shell sandbox | No `pivot_root` / minimal `/dev` in v1.0 | Landlock workspace bound (fail-closed); allowlist defense in depth | `src/sandbox/sandbox.c` | | CSI camera | Argus daemon (`root`) + `/tmp/argus_socket` | Shell blocklist; camera only via agent `execvp` path | `src/hardware/hardware_camera.c`, `allowlist.c` | | Gateway | GPU telemetry via `tegrastats` parsing | Bearer auth on `/api/hardware/gpu`; read-only GET | `src/gateway/routes_hardware.c` | | GPIO / I2C | `tegra234-gpio` chips via libgpiod | Hardware tools run in agent process, not shell namespace | `src/hardware/` backends | @@ -284,8 +285,8 @@ This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do | Area | Directory / file | Security-relevant behavior | |------|------------------|----------------------------| -| Sandbox isolation | [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c) | `unshare` namespaces, cgroups v2 limits, no mount/bind | -| Command policy | [`src/sandbox/allowlist.c`](../src/sandbox/allowlist.c) | Blocklist (incl. Jetson GPU + Argus), workspace path containment | +| Sandbox isolation | [`src/sandbox/sandbox.c`](../src/sandbox/sandbox.c), [`src/sandbox/sandbox_landlock.c`](../src/sandbox/sandbox_landlock.c) | user ns + `unshare` + PID-1 fork, Landlock workspace bound, cgroups v2, fail-closed isolation | +| Command policy | [`src/sandbox/allowlist.c`](../src/sandbox/allowlist.c) | Blocklist (incl. Jetson GPU + Argus), workspace path containment (DiD) | | Gateway auth | [`src/gateway/http_lws.c`](../src/gateway/http_lws.c) | `requires_auth()` Bearer gate for `/api/*` | | Hardware HTTP API | [`src/gateway/routes_hardware.c`](../src/gateway/routes_hardware.c) | Read-only GET handlers, camera POST deferred stub | | Rate limits | [`src/gateway/rate_limit.c`](../src/gateway/rate_limit.c) | Per-IP `/asap` RPM (64-slot table); reuses expired windows; **fail-closed** (429) when table is full and no slot expired | @@ -293,7 +294,7 @@ This section summarizes Jetson Orin Nano Super / JetPack 6.2.x concerns that do | Signing keys | [`src/asap/manifest_keys.c`](../src/asap/manifest_keys.c) | `0600` create, loose-perm rejection, load/rotate guards | | Signed manifest gate | [`src/gateway/routes.c`](../src/gateway/routes.c) | `manifest_keys_ensure_loaded()` before `manifest_build_signed_json()` | -Automated regression coverage: `tests/test_allowlist.c`, `tests/test_hardware_camera.c`, `tests/test_gateway_http.c`, `tests/test_rate_limit.c`, `tests/test_manifest_build`, `tests/test_manifest_keys`. +Automated regression coverage: `tests/test_allowlist.c`, `tests/test_sandbox.c`, `tests/test_hardware_camera.c`, `tests/test_gateway_http.c`, `tests/test_rate_limit.c`, `tests/test_manifest_build`, `tests/test_manifest_keys`. ### Board-agnostic blocklist entries (Jetson literals) From 0e5bc515e3519df859b0cb3221161f62ca8d66e6 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Wed, 23 Sep 2026 21:04:48 -0300 Subject: [PATCH 87/92] fix(cron): deliver other due jobs while one remains unacked Deferred ack kept the earliest due row at the head of the queue, so a failed send or agent run blocked every later reminder until that job succeeded. --- src/core/memory.c | 35 ++++++++++++-- src/core/memory.h | 18 +++++++ src/tools/cron.c | 116 +++++++++++++++++++++++++++++++++++++++------- tests/test_cron.c | 48 +++++++++++++++++++ 4 files changed, 196 insertions(+), 21 deletions(-) diff --git a/src/core/memory.c b/src/core/memory.c index 6757d57..1c28d61 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -475,15 +475,29 @@ int cron_job_list(cron_job_row_t *out, int max_count) return count; } -int cron_job_get_next_due(long long now, cron_job_row_t *out) +static int cron_job_select_due(long long now, int use_cursor, long long after_run, + const char *after_id, cron_job_row_t *out) { - if (!g_db || !out) return -1; - const char *sql = "SELECT id, schedule, message, channel, recipient, next_run, enabled FROM cron_jobs " - "WHERE next_run <= ?1 AND enabled = 1 ORDER BY next_run ASC LIMIT 1"; + const char *sql; sqlite3_stmt *stmt = NULL; + int ret = 0; + + if (!g_db || !out) return -1; + if (use_cursor && (!after_id || after_id[0] == '\0')) return -1; + sql = use_cursor + ? "SELECT id, schedule, message, channel, recipient, next_run, enabled FROM cron_jobs " + "WHERE next_run <= ?1 AND enabled = 1 " + "AND (next_run > ?2 OR (next_run = ?2 AND id > ?3)) " + "ORDER BY next_run ASC, id ASC LIMIT 1" + : "SELECT id, schedule, message, channel, recipient, next_run, enabled FROM cron_jobs " + "WHERE next_run <= ?1 AND enabled = 1 " + "ORDER BY next_run ASC, id ASC LIMIT 1"; if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; sqlite3_bind_int64(stmt, 1, now); - int ret = 0; + if (use_cursor) { + sqlite3_bind_int64(stmt, 2, after_run); + sqlite3_bind_text(stmt, 3, after_id, -1, SQLITE_TRANSIENT); + } if (sqlite3_step(stmt) == SQLITE_ROW) { if (fill_cron_job_row(stmt, out) != 0) { sqlite3_finalize(stmt); @@ -495,6 +509,17 @@ int cron_job_get_next_due(long long now, cron_job_row_t *out) return ret; } +int cron_job_get_next_due(long long now, cron_job_row_t *out) +{ + return cron_job_select_due(now, 0, 0, NULL, out); +} + +int cron_job_get_next_due_after(long long now, long long after_run, const char *after_id, + cron_job_row_t *out) +{ + return cron_job_select_due(now, 1, after_run, after_id, out); +} + int cron_job_get_by_id(const char *id, cron_job_row_t *out) { const char *sql; diff --git a/src/core/memory.h b/src/core/memory.h index 676a547..99cd0e1 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -192,12 +192,30 @@ int cron_job_list(cron_job_row_t *out, int max_count); /** * Get the next due job (next_run <= now, enabled). * + * Ties on next_run are ordered by id ascending. + * * @param now Current Unix timestamp. * @param out Filled with job data if found. Caller must cron_job_row_free(). * @return 1 if found, 0 if none, -1 on error. */ int cron_job_get_next_due(long long now, cron_job_row_t *out); +/** + * Get the next due job after a (next_run, id) cursor. + * + * Uses the same order as cron_job_get_next_due(). Example: + * if (cron_job_get_next_due_after(now, row.next_run, row.id, &next) == 1) + * cron_job_row_free(&next); + * + * @param now Current Unix timestamp. + * @param after_run next_run of the last row already visited. + * @param after_id Id of the last row already visited (must be non-empty). + * @param out Filled with job data if found. Caller must cron_job_row_free(). + * @return 1 if found, 0 if none, -1 on error. + */ +int cron_job_get_next_due_after(long long now, long long after_run, const char *after_id, + cron_job_row_t *out); + /** * Load a cron job by id. * diff --git a/src/tools/cron.c b/src/tools/cron.c index 6114621..9074954 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -178,26 +178,56 @@ static int cron_init(const config_t *cfg) return 0; } -static char s_offered_id[128]; -static struct timespec s_offered_mono; +#define CRON_OFFER_TRACK 16 -static void cron_wait_if_reoffer(const char *job_id, int timeout_ms) +typedef struct cron_offer_slot { + char id[128]; + struct timespec mono; +} cron_offer_slot_t; + +static cron_offer_slot_t s_offers[CRON_OFFER_TRACK]; + +static long cron_elapsed_ms(const struct timespec *then, const struct timespec *now) +{ + return (now->tv_sec - then->tv_sec) * 1000L + + (now->tv_nsec - then->tv_nsec) / 1000000L; +} + +static int cron_offer_age_ms(const char *job_id, const struct timespec *now, long *age_out) +{ + int i; + for (i = 0; i < CRON_OFFER_TRACK; i++) { + if (s_offers[i].id[0] == '\0' || strcmp(s_offers[i].id, job_id) != 0) + continue; + *age_out = cron_elapsed_ms(&s_offers[i].mono, now); + return 1; + } + return 0; +} + +static int cron_offer_is_hot(const char *job_id, int timeout_ms, const struct timespec *now) +{ + long age = 0; + if (timeout_ms <= 0 || !job_id) + return 0; + if (!cron_offer_age_ms(job_id, now, &age)) + return 0; + return age < (long)timeout_ms; +} + +static void cron_wait_remaining(const char *job_id, int timeout_ms) { struct timespec now; struct timespec remain; - long elapsed_ms; + long age = 0; long wait_ms; - if (timeout_ms <= 0 || !job_id || job_id[0] == '\0') - return; - if (s_offered_id[0] == '\0' || strcmp(s_offered_id, job_id) != 0) + if (timeout_ms <= 0 || !job_id) return; if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) return; - elapsed_ms = (now.tv_sec - s_offered_mono.tv_sec) * 1000L - + (now.tv_nsec - s_offered_mono.tv_nsec) / 1000000L; - if (elapsed_ms >= timeout_ms) + if (!cron_offer_age_ms(job_id, &now, &age) || age >= (long)timeout_ms) return; - wait_ms = timeout_ms - elapsed_ms; + wait_ms = (long)timeout_ms - age; remain.tv_sec = wait_ms / 1000; remain.tv_nsec = (wait_ms % 1000) * 1000000L; nanosleep(&remain, NULL); @@ -205,10 +235,64 @@ static void cron_wait_if_reoffer(const char *job_id, int timeout_ms) static void cron_mark_offered(const char *job_id) { - if (!job_id) + struct timespec now; + int i; + int slot = -1; + int oldest = 0; + if (!job_id || job_id[0] == '\0') + return; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) return; - snprintf(s_offered_id, sizeof(s_offered_id), "%s", job_id); - clock_gettime(CLOCK_MONOTONIC, &s_offered_mono); + for (i = 0; i < CRON_OFFER_TRACK; i++) { + if (strcmp(s_offers[i].id, job_id) == 0) { + s_offers[i].mono = now; + return; + } + if (slot < 0 && s_offers[i].id[0] == '\0') + slot = i; + if (cron_elapsed_ms(&s_offers[i].mono, &now) > + cron_elapsed_ms(&s_offers[oldest].mono, &now)) + oldest = i; + } + if (slot < 0) + slot = oldest; + snprintf(s_offers[slot].id, sizeof(s_offers[slot].id), "%s", job_id); + s_offers[slot].mono = now; +} + +/** Prefer a due job outside its re-offer window so one stuck job cannot hide the rest. */ +static int cron_pick_due_row(long long now, int timeout_ms, cron_job_row_t *row) +{ + struct timespec mono; + long long cursor_run = 0; + char cursor_id[128]; + int have_cursor = 0; + + if (clock_gettime(CLOCK_MONOTONIC, &mono) != 0) + memset(&mono, 0, sizeof(mono)); + cursor_id[0] = '\0'; + for (;;) { + int rc = have_cursor + ? cron_job_get_next_due_after(now, cursor_run, cursor_id, row) + : cron_job_get_next_due(now, row); + if (rc != 1) + break; + if (have_cursor && (row->next_run < cursor_run || + (row->next_run == cursor_run && strcmp(row->id, cursor_id) <= 0))) { + cron_job_row_free(row); + break; + } + if (!cron_offer_is_hot(row->id, timeout_ms, &mono)) + return 1; + cursor_run = row->next_run; + snprintf(cursor_id, sizeof(cursor_id), "%s", row->id); + have_cursor = 1; + cron_job_row_free(row); + } + if (cron_job_get_next_due(now, row) != 1) + return 0; + cron_wait_remaining(row->id, timeout_ms); + return 1; } static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) @@ -219,8 +303,8 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) if (!out) return -1; now = (long long)time(NULL); memset(&row, 0, sizeof(row)); - if (cron_job_get_next_due(now, &row) != 1) return 0; - cron_wait_if_reoffer(row.id, timeout_ms); + if (!cron_pick_due_row(now, timeout_ms, &row)) + return 0; memset(out, 0, sizeof(*out)); snprintf(session_id, sizeof(session_id), "%s:%s", row.channel[0] ? row.channel : "cli", diff --git a/tests/test_cron.c b/tests/test_cron.c index d213768..4589dd5 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -449,6 +449,53 @@ static int test_cron_job_rejects_oversized_text(void) return 0; } +/* An unacked earlier job must not hide another job that is also due. */ +static int test_cron_poll_offers_sibling_while_earlier_unacked(void) +{ + const char *path = "/tmp/shellclaw_test_cron_sibling.db"; + const channel_t *cron_ch; + channel_incoming_msg_t msg; + cron_job_row_t row; + struct timespec t0; + struct timespec t1; + long elapsed_ms; + long long now; + + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + ASSERT(cron_job_create("job_a", "interval:60", "A", "cli", "default", now - 10, 1) == 0); + ASSERT(cron_job_create("job_b", "interval:60", "B", "cli", "default", now - 5, 1) == 0); + cron_ch = channel_cron_get(); + ASSERT(cron_ch != NULL && cron_ch->poll != NULL); + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 0) == 1); + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "job_a") == 0); + channel_incoming_msg_clear(&msg); + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 2000) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "job_b") == 0); + ASSERT(elapsed_ms < 400); + channel_incoming_msg_clear(&msg); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("job_a", &row) == 1); + ASSERT(row.next_run == now - 10); + cron_job_row_free(&row); + memset(&row, 0, sizeof(row)); + ASSERT(cron_job_get_by_id("job_b", &row) == 1); + ASSERT(row.next_run == now - 5); + cron_job_row_free(&row); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -469,6 +516,7 @@ int main(void) RUN(test_cron_ack_fail_closed_on_parse_error()); RUN(test_cron_poll_waits_before_reoffer()); RUN(test_cron_job_rejects_oversized_text()); + RUN(test_cron_poll_offers_sibling_while_earlier_unacked()); printf("test_cron: all tests passed\n"); return 0; } From e09b2e94ae856f1879a587d6aed509d1f32342cd Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Wed, 23 Sep 2026 21:24:20 -0300 Subject: [PATCH 88/92] fix(cron): keep the re-offer wait when the offer table is full A full ring used to drop the oldest id, and the next poll treated that job as cold, so a down channel tight-looped agent_run. Reuse a slot only after its recorded window has elapsed, and wait on the earliest due job when every slot is still hot. --- src/core/memory.c | 26 +++++++++++- src/core/memory.h | 23 ++++++++++- src/tools/cron.c | 81 +++++++++++++++++++++++------------- tests/test_cron.c | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 31 deletions(-) diff --git a/src/core/memory.c b/src/core/memory.c index 1c28d61..9edaa7e 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -398,6 +398,8 @@ int cron_job_create(const char *id, const char *schedule, const char *message, const char *channel, const char *recipient, long long next_run, int enabled) { if (!g_db || !id || !schedule || !message) return -1; + /* fill_cron_job_row truncates id[128]; a longer id breaks the due cursor. */ + if (strlen(id) >= sizeof(((cron_job_row_t *)0)->id)) return -1; if (strlen(schedule) > (size_t)CRON_JOB_TEXT_MAX) return -1; if (strlen(message) > (size_t)CRON_JOB_TEXT_MAX) return -1; const char *ch = channel ? channel : ""; @@ -458,7 +460,8 @@ int cron_job_update_next_run(const char *id, long long next_run) int cron_job_list(cron_job_row_t *out, int max_count) { if (!g_db || !out || max_count <= 0) return -1; - const char *sql = "SELECT id, schedule, message, channel, recipient, next_run, enabled FROM cron_jobs ORDER BY next_run ASC"; + const char *sql = "SELECT id, schedule, message, channel, recipient, next_run, enabled " + "FROM cron_jobs ORDER BY next_run ASC, id ASC"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; int count = 0; @@ -475,6 +478,27 @@ int cron_job_list(cron_job_row_t *out, int max_count) return count; } +int cron_job_list_due(long long now, cron_due_key_t *out, int max_count) +{ + const char *sql; + sqlite3_stmt *stmt = NULL; + int count = 0; + + if (!g_db || !out || max_count <= 0) return -1; + sql = "SELECT id, next_run FROM cron_jobs WHERE next_run <= ?1 AND enabled = 1 " + "ORDER BY next_run ASC, id ASC"; + if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) return -1; + sqlite3_bind_int64(stmt, 1, now); + while (count < max_count && sqlite3_step(stmt) == SQLITE_ROW) { + copy_str_bounded(out[count].id, sizeof(out[count].id), + (const char *)sqlite3_column_text(stmt, 0)); + out[count].next_run = sqlite3_column_int64(stmt, 1); + count++; + } + sqlite3_finalize(stmt); + return count; +} + static int cron_job_select_due(long long now, int use_cursor, long long after_run, const char *after_id, cron_job_row_t *out) { diff --git a/src/core/memory.h b/src/core/memory.h index 99cd0e1..b266660 100644 --- a/src/core/memory.h +++ b/src/core/memory.h @@ -134,7 +134,7 @@ int config_kv_set(const char *key, const char *value); * cron_job_row_free(&row); */ typedef struct cron_job_row { - char id[128]; + char id[128]; /* stored ids must fit with a trailing NUL (127 chars) */ char *schedule; char *message; char channel[64]; @@ -179,9 +179,30 @@ int cron_job_toggle(const char *id); */ int cron_job_update_next_run(const char *id, long long next_run); +/** + * Key of a due cron job. No heap fields. Example: + * cron_due_key_t keys[8]; + * int n = cron_job_list_due(now, keys, 8); + */ +typedef struct cron_due_key { + char id[128]; + long long next_run; +} cron_due_key_t; + +/** + * List enabled jobs with next_run <= now, ordered by next_run then id. + * + * @param out Array to fill (caller-allocated). + * @param max_count Maximum keys to return. + * @return Number of keys written, or -1 on error. + */ +int cron_job_list_due(long long now, cron_due_key_t *out, int max_count); + /** * List cron jobs into output array. * + * Ties on next_run are ordered by id ascending, matching the due poller. + * * @param out Array to fill (caller-allocated). Heap fields are owned * by the caller on success; on -1, no row is owned. * @param max_count Maximum jobs to return. diff --git a/src/tools/cron.c b/src/tools/cron.c index 9074954..c6df97c 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -179,10 +179,12 @@ static int cron_init(const config_t *cfg) } #define CRON_OFFER_TRACK 16 +#define CRON_DUE_SCAN_MAX 64 typedef struct cron_offer_slot { char id[128]; struct timespec mono; + int timeout_ms; } cron_offer_slot_t; static cron_offer_slot_t s_offers[CRON_OFFER_TRACK]; @@ -205,6 +207,27 @@ static int cron_offer_age_ms(const char *job_id, const struct timespec *now, lon return 0; } +static int cron_slot_reusable(const cron_offer_slot_t *slot, const struct timespec *now) +{ + long age; + if (slot->id[0] == '\0') + return 1; + if (slot->timeout_ms <= 0) + return 0; + age = cron_elapsed_ms(&slot->mono, now); + return age >= (long)slot->timeout_ms; +} + +static int cron_offer_has_room(const struct timespec *now) +{ + int i; + for (i = 0; i < CRON_OFFER_TRACK; i++) { + if (cron_slot_reusable(&s_offers[i], now)) + return 1; + } + return 0; +} + static int cron_offer_is_hot(const char *job_id, int timeout_ms, const struct timespec *now) { long age = 0; @@ -215,6 +238,17 @@ static int cron_offer_is_hot(const char *job_id, int timeout_ms, const struct ti return age < (long)timeout_ms; } +/** A missing id is deliverable only when a cold slot can remember the offer. */ +static int cron_due_is_returnable(const char *job_id, int timeout_ms, const struct timespec *now) +{ + long age = 0; + if (cron_offer_is_hot(job_id, timeout_ms, now)) + return 0; + if (!cron_offer_age_ms(job_id, now, &age) && !cron_offer_has_room(now)) + return 0; + return 1; +} + static void cron_wait_remaining(const char *job_id, int timeout_ms) { struct timespec now; @@ -233,12 +267,11 @@ static void cron_wait_remaining(const char *job_id, int timeout_ms) nanosleep(&remain, NULL); } -static void cron_mark_offered(const char *job_id) +static void cron_mark_offered(const char *job_id, int timeout_ms) { struct timespec now; int i; int slot = -1; - int oldest = 0; if (!job_id || job_id[0] == '\0') return; if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) @@ -246,50 +279,39 @@ static void cron_mark_offered(const char *job_id) for (i = 0; i < CRON_OFFER_TRACK; i++) { if (strcmp(s_offers[i].id, job_id) == 0) { s_offers[i].mono = now; + s_offers[i].timeout_ms = timeout_ms; return; } - if (slot < 0 && s_offers[i].id[0] == '\0') + if (slot < 0 && cron_slot_reusable(&s_offers[i], &now)) slot = i; - if (cron_elapsed_ms(&s_offers[i].mono, &now) > - cron_elapsed_ms(&s_offers[oldest].mono, &now)) - oldest = i; } if (slot < 0) - slot = oldest; + return; snprintf(s_offers[slot].id, sizeof(s_offers[slot].id), "%s", job_id); s_offers[slot].mono = now; + s_offers[slot].timeout_ms = timeout_ms; } /** Prefer a due job outside its re-offer window so one stuck job cannot hide the rest. */ static int cron_pick_due_row(long long now, int timeout_ms, cron_job_row_t *row) { + cron_due_key_t keys[CRON_DUE_SCAN_MAX]; struct timespec mono; - long long cursor_run = 0; - char cursor_id[128]; - int have_cursor = 0; + int n; + int i; if (clock_gettime(CLOCK_MONOTONIC, &mono) != 0) memset(&mono, 0, sizeof(mono)); - cursor_id[0] = '\0'; - for (;;) { - int rc = have_cursor - ? cron_job_get_next_due_after(now, cursor_run, cursor_id, row) - : cron_job_get_next_due(now, row); - if (rc != 1) - break; - if (have_cursor && (row->next_run < cursor_run || - (row->next_run == cursor_run && strcmp(row->id, cursor_id) <= 0))) { - cron_job_row_free(row); - break; - } - if (!cron_offer_is_hot(row->id, timeout_ms, &mono)) + n = cron_job_list_due(now, keys, CRON_DUE_SCAN_MAX); + if (n <= 0) + return 0; + for (i = 0; i < n; i++) { + if (!cron_due_is_returnable(keys[i].id, timeout_ms, &mono)) + continue; + if (cron_job_get_by_id(keys[i].id, row) == 1) return 1; - cursor_run = row->next_run; - snprintf(cursor_id, sizeof(cursor_id), "%s", row->id); - have_cursor = 1; - cron_job_row_free(row); } - if (cron_job_get_next_due(now, row) != 1) + if (cron_job_get_by_id(keys[0].id, row) != 1) return 0; cron_wait_remaining(row->id, timeout_ms); return 1; @@ -314,7 +336,7 @@ static int cron_poll(channel_incoming_msg_t *out, int timeout_ms) out->text = strdup(row.message ? row.message : ""); out->attachments = NULL; out->attachments_count = 0; - cron_mark_offered(row.id); + cron_mark_offered(row.id, timeout_ms); cron_job_row_free(&row); if (!out->session_id || !out->user_id || !out->text) { channel_incoming_msg_clear(out); @@ -364,6 +386,7 @@ static int cron_send(const char *recipient, const char *text, static void cron_cleanup(void) { + memset(s_offers, 0, sizeof(s_offers)); } static const channel_t cron_channel = { diff --git a/tests/test_cron.c b/tests/test_cron.c index 4589dd5..28c9cf5 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -15,6 +15,13 @@ #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) #define RUN(t) do { int r = (t); if (r) return r; } while (0) +static void cron_test_reset_offers(void) +{ + const channel_t *cron_ch = channel_cron_get(); + if (cron_ch && cron_ch->cleanup) + cron_ch->cleanup(); +} + static int test_interval_next_run(void) { long long now = 1700000000; @@ -205,6 +212,7 @@ static int test_one_shot_detection(void) static int test_due_job_delivers_full_message(void) { + cron_test_reset_offers(); const char *path = "/tmp/shellclaw_test_cron_long_message.db"; remove(path); ASSERT(memory_init(path) == 0); @@ -235,6 +243,7 @@ static int test_due_job_delivers_full_message(void) static int test_long_interval_schedule_roundtrips(void) { + cron_test_reset_offers(); const char *path = "/tmp/shellclaw_test_cron_long_schedule.db"; const channel_t *ch; channel_incoming_msg_t msg; @@ -314,6 +323,7 @@ static int test_cron_ack_delivery_deferred(void) static int test_cron_poll_keeps_job_until_ack(void) { + cron_test_reset_offers(); const channel_t *cron_ch = channel_cron_get(); ASSERT(cron_ch != NULL); const char *path = "/tmp/shellclaw_test_cron_poll.db"; @@ -340,6 +350,7 @@ static int test_cron_poll_keeps_job_until_ack(void) static int test_cron_ack_advances_recurring_past_due_minute(void) { + cron_test_reset_offers(); const char *path = "/tmp/shellclaw_test_cron_ack_advance.db"; const channel_t *cron_ch; channel_incoming_msg_t msg; @@ -404,6 +415,7 @@ static int test_cron_ack_fail_closed_on_parse_error(void) static int test_cron_poll_waits_before_reoffer(void) { + cron_test_reset_offers(); const char *path = "/tmp/shellclaw_test_cron_reoffer.db"; const channel_t *cron_ch; channel_incoming_msg_t msg; @@ -443,6 +455,11 @@ static int test_cron_job_rejects_oversized_text(void) too_big[CRON_JOB_TEXT_MAX + 1] = '\0'; ASSERT(cron_job_create("bigmsg", "interval:60", too_big, "cli", "default", 1, 1) != 0); ASSERT(cron_job_create("bigsched", too_big, "tick", "cli", "default", 1, 1) != 0); + memset(too_big, 'i', 128); + too_big[128] = '\0'; + ASSERT(cron_job_create(too_big, "interval:60", "tick", "cli", "default", 1, 1) != 0); + too_big[127] = '\0'; + ASSERT(cron_job_create(too_big, "interval:60", "tick", "cli", "default", 1, 1) == 0); free(too_big); memory_cleanup(); remove(path); @@ -461,6 +478,7 @@ static int test_cron_poll_offers_sibling_while_earlier_unacked(void) long elapsed_ms; long long now; + cron_test_reset_offers(); remove(path); ASSERT(memory_init(path) == 0); now = (long long)time(NULL); @@ -491,6 +509,88 @@ static int test_cron_poll_offers_sibling_while_earlier_unacked(void) ASSERT(cron_job_get_by_id("job_b", &row) == 1); ASSERT(row.next_run == now - 5); cron_job_row_free(&row); + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 2000) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "job_a") == 0); + ASSERT(elapsed_ms >= 400); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + +/* A full offer table must wait instead of re-offering an evicted id immediately. */ +static int test_cron_poll_waits_when_offer_table_is_full(void) +{ + const char *path = "/tmp/shellclaw_test_cron_offer_full.db"; + const channel_t *cron_ch; + channel_incoming_msg_t msg; + struct timespec t0; + struct timespec t1; + char id[8]; + long elapsed_ms; + long long now; + int i; + + cron_test_reset_offers(); + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + for (i = 0; i < 17; i++) { + snprintf(id, sizeof(id), "j%02d", i); + ASSERT(cron_job_create(id, "interval:60", id, "cli", "default", now - (20 - i), 1) == 0); + } + cron_ch = channel_cron_get(); + for (i = 0; i < 16; i++) { + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 400) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + snprintf(id, sizeof(id), "j%02d", i); + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, id) == 0); + ASSERT(elapsed_ms < 150); + channel_incoming_msg_clear(&msg); + } + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 400) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "j00") == 0); + ASSERT(elapsed_ms >= 200); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + +static int test_cron_list_orders_ties_by_id(void) +{ + const char *path = "/tmp/shellclaw_test_cron_list_order.db"; + cron_job_row_t rows[4]; + long long now; + + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + ASSERT(cron_job_create("b_job", "interval:60", "B", "cli", "default", now, 1) == 0); + ASSERT(cron_job_create("a_job", "interval:60", "A", "cli", "default", now, 1) == 0); + memset(rows, 0, sizeof(rows)); + ASSERT(cron_job_list(rows, 4) == 2); + ASSERT(strcmp(rows[0].id, "a_job") == 0); + ASSERT(strcmp(rows[1].id, "b_job") == 0); + cron_job_row_free(&rows[0]); + cron_job_row_free(&rows[1]); memory_cleanup(); remove(path); return 0; @@ -517,6 +617,8 @@ int main(void) RUN(test_cron_poll_waits_before_reoffer()); RUN(test_cron_job_rejects_oversized_text()); RUN(test_cron_poll_offers_sibling_while_earlier_unacked()); + RUN(test_cron_poll_waits_when_offer_table_is_full()); + RUN(test_cron_list_orders_ties_by_id()); printf("test_cron: all tests passed\n"); return 0; } From 7ed691b4f8b350c795f79ab5f8f0acc70d0ad83f Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Thu, 24 Sep 2026 08:08:06 -0300 Subject: [PATCH 89/92] fix(cron): wait when a due job cannot be recorded A full offer table returned the next id immediately because that id had no stored age. Sleep the poll timeout in that case, and free the slot when the job is acked so the following due job can be delivered and then backed off. --- src/tools/cron.c | 37 +++++++++++++++++++++++++++++-- tests/test_cron.c | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/tools/cron.c b/src/tools/cron.c index c6df97c..a6a753b 100644 --- a/src/tools/cron.c +++ b/src/tools/cron.c @@ -292,11 +292,35 @@ static void cron_mark_offered(const char *job_id, int timeout_ms) s_offers[slot].timeout_ms = timeout_ms; } +static void cron_offer_release(const char *job_id) +{ + int i; + if (!job_id || job_id[0] == '\0') + return; + for (i = 0; i < CRON_OFFER_TRACK; i++) { + if (strcmp(s_offers[i].id, job_id) != 0) + continue; + memset(&s_offers[i], 0, sizeof(s_offers[i])); + return; + } +} + +static void cron_sleep_ms(long wait_ms) +{ + struct timespec remain; + if (wait_ms <= 0) + return; + remain.tv_sec = wait_ms / 1000; + remain.tv_nsec = (wait_ms % 1000) * 1000000L; + nanosleep(&remain, NULL); +} + /** Prefer a due job outside its re-offer window so one stuck job cannot hide the rest. */ static int cron_pick_due_row(long long now, int timeout_ms, cron_job_row_t *row) { cron_due_key_t keys[CRON_DUE_SCAN_MAX]; struct timespec mono; + long age = 0; int n; int i; @@ -313,7 +337,11 @@ static int cron_pick_due_row(long long now, int timeout_ms, cron_job_row_t *row) } if (cron_job_get_by_id(keys[0].id, row) != 1) return 0; - cron_wait_remaining(row->id, timeout_ms); + /* An id that never fit in the table has no age, so wait_remaining would return immediately. */ + if (cron_offer_age_ms(row->id, &mono, &age)) + cron_wait_remaining(row->id, timeout_ms); + else + cron_sleep_ms((long)timeout_ms); return 1; } @@ -358,6 +386,8 @@ int cron_ack_delivery(const char *job_id) if (cron_is_one_shot(row.schedule)) { rc = cron_job_delete(row.id); cron_job_row_free(&row); + if (rc == 0) + cron_offer_release(job_id); return rc; } now = (long long)time(NULL); @@ -365,7 +395,10 @@ int cron_ack_delivery(const char *job_id) cron_job_row_free(&row); if (rc != 0) next = now + 365LL * 24 * 3600; - return cron_job_update_next_run(job_id, next); + rc = cron_job_update_next_run(job_id, next); + if (rc == 0) + cron_offer_release(job_id); + return rc; } static int cron_send(const char *recipient, const char *text, diff --git a/tests/test_cron.c b/tests/test_cron.c index 28c9cf5..2e8369c 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -596,6 +596,60 @@ static int test_cron_list_orders_ties_by_id(void) return 0; } +/* After the tracked jobs are acked, the freed slot must deliver the 17th id and then wait. */ +static int test_cron_poll_delivers_17th_after_ack(void) +{ + const char *path = "/tmp/shellclaw_test_cron_offer_17th.db"; + const channel_t *cron_ch; + channel_incoming_msg_t msg; + struct timespec t0; + struct timespec t1; + char id[8]; + long elapsed_ms; + long long now; + int i; + + cron_test_reset_offers(); + remove(path); + ASSERT(memory_init(path) == 0); + now = (long long)time(NULL); + for (i = 0; i < 17; i++) { + snprintf(id, sizeof(id), "k%02d", i); + ASSERT(cron_job_create(id, "interval:60", id, "cli", "default", now - (20 - i), 1) == 0); + } + cron_ch = channel_cron_get(); + for (i = 0; i < 16; i++) { + memset(&msg, 0, sizeof(msg)); + ASSERT(cron_ch->poll(&msg, 300) == 1); + channel_incoming_msg_clear(&msg); + snprintf(id, sizeof(id), "k%02d", i); + ASSERT(cron_ack_delivery(id) == 0); + } + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 300) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "k16") == 0); + ASSERT(elapsed_ms < 150); + channel_incoming_msg_clear(&msg); + memset(&msg, 0, sizeof(msg)); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t0) == 0); + ASSERT(cron_ch->poll(&msg, 300) == 1); + ASSERT(clock_gettime(CLOCK_MONOTONIC, &t1) == 0); + elapsed_ms = (t1.tv_sec - t0.tv_sec) * 1000L + + (t1.tv_nsec - t0.tv_nsec) / 1000000L; + ASSERT(msg.user_id != NULL); + ASSERT(strcmp(msg.user_id, "k16") == 0); + ASSERT(elapsed_ms >= 150); + channel_incoming_msg_clear(&msg); + memory_cleanup(); + remove(path); + return 0; +} + int main(void) { RUN(test_interval_next_run()); @@ -618,6 +672,7 @@ int main(void) RUN(test_cron_job_rejects_oversized_text()); RUN(test_cron_poll_offers_sibling_while_earlier_unacked()); RUN(test_cron_poll_waits_when_offer_table_is_full()); + RUN(test_cron_poll_delivers_17th_after_ack()); RUN(test_cron_list_orders_ties_by_id()); printf("test_cron: all tests passed\n"); return 0; From cc431556bc852e3ea2819862b545d58f4db0a265 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Thu, 24 Sep 2026 08:08:06 -0300 Subject: [PATCH 90/92] fix(sandbox): fail closed when a Landlock rule cannot be added Device nodes stay writable without IOCTL_DEV, and a failed landlock_add_rule now rejects the ruleset instead of continuing with a partial grant. The security note lists the same device paths. --- docs/SECURITY.md | 2 +- src/sandbox/sandbox_landlock.c | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 073bdcc..4a2b3fd 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -42,7 +42,7 @@ The primary goals are: prevent sandboxed shell commands from escaping to host de | Shell (sandbox on) | `fork()` + user ns when needed + `unshare(CLONE_NEWNS \| CLONE_NEWNET \| CLONE_NEWPID)` + fork for PID 1 + Landlock workspace bound + `prctl(PR_SET_NO_NEW_PRIVS)` | Fail-closed if namespaces or Landlock cannot apply. Isolation uses a control pipe, not `sh` exit 122/123. See [Linux sandbox (Jetson)](#linux-sandbox-jetson) | | Shell (sandbox off) | Plain `fork()` + substring fallback blocklist | **Not** a security boundary; stderr warning | | Allowlist | Substring blocklist + workspace containment (ancestor walk, quoted/embedded `/` `~`, `$HOME`/`$PWD`, `file:` URLs, relative names) | Defense-in-depth string scan; Landlock is the kernel host-FS bound | -| Landlock | Ruleset on configured `workspace_path` (RW workspace + traverse-only `/` + RO `/bin` `/usr` `/lib` and `/etc/ssl/certs` only; RW `/dev/null`). Child `fchdir`s the workspace before `restrict_self`. | Primary host-FS gate; `/` is `READ_DIR` only so `/etc/passwd` stays closed. `/etc/ssl/private` is not granted. Blocks symlink and `chr(47)+` host reads | +| Landlock | Ruleset on configured `workspace_path` (RW workspace + traverse-only `/` + RO `/bin` `/usr` `/lib` and `/etc/ssl/certs` only; RW `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/tty` without `IOCTL_DEV`). Child `fchdir`s the workspace before `restrict_self`. A failed `landlock_add_rule` fails the ruleset closed. | Primary host-FS gate; `/` is `READ_DIR` only so `/etc/passwd` stays closed. `/etc/ssl/private` is not granted. Blocks symlink and `chr(47)+` host reads | | cgroups v2 | `memory.max`, `cpu.max`; isolator writes `cgroup.procs` before the command fork | Best-effort; non-fatal if cgroup write fails | | Hardware GPIO/I2C | libgpiod / `i2c-dev` in agent process | Not exposed inside shell namespace | diff --git a/src/sandbox/sandbox_landlock.c b/src/sandbox/sandbox_landlock.c index fd2d040..4dac6a7 100644 --- a/src/sandbox/sandbox_landlock.c +++ b/src/sandbox/sandbox_landlock.c @@ -102,8 +102,7 @@ static int landlock_add_path(int ruleset_fd, const char *path, __u64 dir_access, rc = syscall(__NR_landlock_add_rule, ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &pb, 0); close(pfd); - (void)rc; - return 0; + return (rc == 0) ? 0 : -1; } static int landlock_add_workspace(int ruleset_fd, const char *workspace, __u64 access) @@ -189,7 +188,6 @@ static int landlock_make_ruleset(const char *workspace) #ifdef LANDLOCK_ACCESS_FS_TRUNCATE rw_file |= (LANDLOCK_ACCESS_FS_TRUNCATE & handled); #endif - rw_file |= (LANDLOCK_ACCESS_FS_IOCTL_DEV & handled); memset(&attr, 0, sizeof(attr)); attr.handled_access_fs = handled; ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, @@ -207,10 +205,18 @@ static int landlock_make_ruleset(const char *workspace) close(ruleset_fd); return -1; } - for (i = 0; RO_PATHS[i]; i++) - (void)landlock_add_path(ruleset_fd, RO_PATHS[i], ro_dir, ro_file); - for (i = 0; RW_DEV[i]; i++) - (void)landlock_add_path(ruleset_fd, RW_DEV[i], ro_dir, rw_file); + for (i = 0; RO_PATHS[i]; i++) { + if (landlock_add_path(ruleset_fd, RO_PATHS[i], ro_dir, ro_file) != 0) { + close(ruleset_fd); + return -1; + } + } + for (i = 0; RW_DEV[i]; i++) { + if (landlock_add_path(ruleset_fd, RW_DEV[i], ro_dir, rw_file) != 0) { + close(ruleset_fd); + return -1; + } + } return ruleset_fd; } From fab3e7dfcc4c155df812ec02dda10c2b444eeb23 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Thu, 24 Sep 2026 08:08:06 -0300 Subject: [PATCH 91/92] fix(gateway): save config through a unique temp and publish once PUT /api/config and the JSON patch check used predictable sidecars that follow a symlink. Write those temps with mkstemp and fsync, and leave the live gateway config pointer to the reload that already runs under the agent lock. --- CHANGELOG.md | 3 ++ src/core/config_patch.c | 60 +++++++++++++++++-------- src/gateway/routes.c | 92 ++++++++++++++++++++++++++------------- tests/test_config_patch.c | 33 ++++++++++++++ 4 files changed, 141 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f91e866..4ce4ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Cron re-offer waits the full timeout when a due id could not be stored in the 16-slot table, and an ack frees that slot so the next due job can be delivered and then backed off. +- Landlock device grants stay `/dev/null`, `/dev/zero`, `/dev/urandom`, and `/dev/tty` without `IOCTL_DEV`. A failed `landlock_add_rule` fails the ruleset closed. +- Dashboard config save and JSON patch validation use a unique temp plus `fsync`, and the live gateway config pointer is published once under `agent_lock`. - Shell `workspace_only` walks the first existing ancestor, collapses `..` lexically (without cancelling across a symlink), and scans quoted/embedded paths, `file:` URLs, `$HOME`/`$PWD` (including glued `$IFS`), and bare relative names such as `cat leak`. `strdup` OOM is fail-closed. Encoded-slash cat-and-mouse is frozen; Landlock is the kernel host-FS bound. - Shell `sandbox_exec` applies a Landlock ruleset to the configured workspace (fail-closed) as the kernel host-FS bound, so symlink and `chr(47)+` host reads cannot skip the string scanner. Mount/network/PID namespaces fail closed (user namespace first when unprivileged). After `CLONE_NEWPID` the isolator forks so the command is PID 1. Isolation failure uses a control pipe, not `sh` exit 122/123. `/dev/null` stays writable under Landlock. - Default `workspace_path` is `~/.shellclaw/workspace`, and file/shell tools still refuse `auth_tokens.json`, `shellclaw.pid`, `shellclaw.log`, and `.shellclaw` `config.toml` / `memory.db` (including `memory.db-*` sidecars) when a custom workspace contains them. A bare `cat config.toml` after the sandbox `chdir` is blocked, and a symlink at the workspace path is not accepted. diff --git a/src/core/config_patch.c b/src/core/config_patch.c index 38b9684..3962446 100644 --- a/src/core/config_patch.c +++ b/src/core/config_patch.c @@ -8,6 +8,9 @@ #include "core/config.h" #include "cJSON.h" #include +#include +#include +#include #include #include #include @@ -385,42 +388,65 @@ static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size return 0; } +static int write_all_fd(int fd, const char *buf, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, buf + off, len - off); + if (n <= 0) + return -1; + off += (size_t)n; + } + return 0; +} + static int validate_patched_toml(const char *config_path, const char *content, size_t len, char *errbuf, size_t errbufsz) { - size_t path_len; - char *tmp_path; - FILE *f; + char path_copy[PATH_MAX]; + char tmp_path[PATH_MAX]; + char *dir; config_t *cfg = NULL; - path_len = strlen(config_path); - tmp_path = malloc(path_len + 16); - if (!tmp_path) { - PATCH_ERR(errbuf, errbufsz, "out of memory"); + int fd; + int n; + + if (snprintf(path_copy, sizeof(path_copy), "%s", config_path) >= (int)sizeof(path_copy)) { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); return -1; } - snprintf(tmp_path, path_len + 16, "%s.patch-test", config_path); - f = fopen(tmp_path, "w"); - if (!f) { + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') { PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); - free(tmp_path); return -1; } - if (fwrite(content, 1, len, f) != len) { - fclose(f); + n = snprintf(tmp_path, sizeof(tmp_path), "%s/.sc-patch-XXXXXX", dir); + if (n < 0 || (size_t)n >= sizeof(tmp_path)) { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + fd = mkstemp(tmp_path); + if (fd < 0) { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + if (write_all_fd(fd, content, len) != 0 || fsync(fd) != 0) { + close(fd); + unlink(tmp_path); + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + if (close(fd) != 0) { unlink(tmp_path); - free(tmp_path); PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); return -1; } - fclose(f); if (config_load(tmp_path, &cfg, errbuf, errbufsz) != 0) { unlink(tmp_path); - free(tmp_path); return -1; } config_free(cfg); unlink(tmp_path); - free(tmp_path); return 0; } diff --git a/src/gateway/routes.c b/src/gateway/routes.c index e917c22..ab8fddb 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -27,6 +27,10 @@ #include "tools/cron.h" #include "cJSON.h" #include +#include +#include +#include +#include #include #include #include @@ -226,6 +230,60 @@ static int config_put_patch_json(http_server_ctx_t *ctx, const char *body, size_ return 0; } +static int write_all_fd(int fd, const char *buf, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, buf + off, len - off); + if (n <= 0) + return -1; + off += (size_t)n; + } + return 0; +} + +static void discard_cfg_tmp(int fd, const char *path) +{ + if (fd >= 0) + close(fd); + if (path) + unlink(path); +} + +/* Unique temp so a planted config.toml.tmp symlink is not the sidecar. Caller renames. */ +static int write_config_temp(const char *path, const char *content, size_t len, + char *tmp_out, size_t tmp_cap) +{ + char path_copy[PATH_MAX]; + char *dir; + int fd; + int n; + + if (!path || !content || !tmp_out || tmp_cap == 0) + return -1; + if (snprintf(path_copy, sizeof(path_copy), "%s", path) >= (int)sizeof(path_copy)) + return -1; + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') + return -1; + n = snprintf(tmp_out, tmp_cap, "%s/.sc-cfg-XXXXXX", dir); + if (n < 0 || (size_t)n >= tmp_cap) + return -1; + fd = mkstemp(tmp_out); + if (fd < 0) + return -1; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); + if (fchmod(fd, 0600) != 0 || write_all_fd(fd, content, len) != 0 || fsync(fd) != 0) { + discard_cfg_tmp(fd, tmp_out); + return -1; + } + if (close(fd) != 0) { + discard_cfg_tmp(-1, tmp_out); + return -1; + } + return 0; +} + static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t body_len, char *buf, size_t size, int *status) { @@ -234,10 +292,7 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b char errbuf[256] = {0}; const char *write_body = body; size_t write_len = body_len; - size_t path_len; - char *tmp_path; - FILE *f; - size_t written; + char tmp_path[PATH_MAX]; config_t *cfg = NULL; if (!ctx->config_path || !body || body_len == 0) { json_error(buf, size, status, 400, "Bad request"); @@ -254,33 +309,13 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b write_body = patched_body; write_len = patched_len; } - path_len = strlen(ctx->config_path); - tmp_path = malloc(path_len + 8); - if (!tmp_path) { - free(patched_body); - json_error(buf, size, status, 500, "Out of memory"); - return; - } - snprintf(tmp_path, path_len + 8, "%s.tmp", ctx->config_path); - f = fopen(tmp_path, "w"); - if (!f) { - free(tmp_path); - free(patched_body); - json_error(buf, size, status, 500, "Failed to write config"); - return; - } - written = fwrite(write_body, 1, write_len, f); - fclose(f); - if (written != write_len) { - unlink(tmp_path); - free(tmp_path); + if (write_config_temp(ctx->config_path, write_body, write_len, tmp_path, sizeof(tmp_path)) != 0) { free(patched_body); json_error(buf, size, status, 500, "Failed to write config"); return; } if (config_load(tmp_path, &cfg, errbuf, sizeof(errbuf)) != 0) { unlink(tmp_path); - free(tmp_path); free(patched_body); json_error(buf, size, status, 400, errbuf[0] ? errbuf : "Invalid TOML"); return; @@ -288,17 +323,15 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b config_free(cfg); if (rename(tmp_path, ctx->config_path) != 0) { unlink(tmp_path); - free(tmp_path); free(patched_body); json_error(buf, size, status, 500, "Failed to save config"); return; } - free(tmp_path); free(patched_body); /* Dashboard/TOML save: swap live cfg now instead of waiting for SIGHUP. * agent_lock matches the SIGHUP path in main_loop so the two threads cannot - * enqueue the same pointer. http_set_live_config stays here because - * test_reload rebuilds reload.o with GATEWAY=0. */ + * enqueue the same pointer. try_config_reload publishes the gateway pointer + * while that lock is held. */ { config_t *live_cfg = bootstrap_get_cfg(); int reload_rc; @@ -313,7 +346,6 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b json_error(buf, size, status, 500, "Config saved but live reload failed"); return; } - http_set_live_config(bootstrap_get_cfg()); } *status = 200; json_response(buf, size, status, "{\"ok\":true}"); diff --git a/tests/test_config_patch.c b/tests/test_config_patch.c index 315202e..3980f4a 100644 --- a/tests/test_config_patch.c +++ b/tests/test_config_patch.c @@ -179,6 +179,35 @@ static int test_patch_creates_missing_section(void) return 0; } +static int test_patch_leaves_predictable_sidecar(void) +{ + char path[128]; + char side[160]; + char kept[32]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + FILE *f; + + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, + "[agent]\nmodel = \"old-model\"\nmax_tokens = 1024\ntemperature = 0.2\n" + "[gateway]\nhost = \"127.0.0.1\"\nport = 18789\n") == 0); + snprintf(side, sizeof(side), "%s.patch-test", path); + ASSERT(write_toml(side, "KEEP") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":\"new-model\"}", &patched, &patched_len, + errbuf, sizeof(errbuf)) == 0); + free(patched); + f = fopen(side, "r"); + ASSERT(f != NULL); + ASSERT(fgets(kept, sizeof(kept), f) != NULL); + fclose(f); + ASSERT(strcmp(kept, "KEEP") == 0); + remove(side); + remove(path); + return 0; +} + int main(void) { int failed = 0; @@ -210,6 +239,10 @@ int main(void) fprintf(stderr, "test_patch_rejects_wrong_json_types failed\n"); failed++; } + if (test_patch_leaves_predictable_sidecar() != 0) { + fprintf(stderr, "test_patch_leaves_predictable_sidecar failed\n"); + failed++; + } if (failed == 0) printf("test_config_patch: all tests passed\n"); return failed; From 3b3786705fbc51b9c8718f53e6a4c890b6d4e775 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Thu, 24 Sep 2026 09:17:43 -0300 Subject: [PATCH 92/92] fix(gateway): publish live config before releasing the agent lock test_reload rebuilds reload.o without the gateway, so the publish inside try_config_reload is not in the shellclaw binary. Set the gateway pointer while the lock is still held; a later call could put the previous config back. --- src/gateway/routes.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/gateway/routes.c b/src/gateway/routes.c index ab8fddb..39c24a8 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -330,8 +330,7 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b free(patched_body); /* Dashboard/TOML save: swap live cfg now instead of waiting for SIGHUP. * agent_lock matches the SIGHUP path in main_loop so the two threads cannot - * enqueue the same pointer. try_config_reload publishes the gateway pointer - * while that lock is held. */ + * enqueue the same pointer. The gateway pointer is published before unlock. */ { config_t *live_cfg = bootstrap_get_cfg(); int reload_rc; @@ -341,6 +340,10 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b } agent_lock(); reload_rc = try_config_reload(&live_cfg); + /* test_reload rebuilds reload.o without SHELLCLAW_GATEWAY, so the + * publish inside try_config_reload may be compiled out of this binary. */ + if (reload_rc == 0) + http_set_live_config(bootstrap_get_cfg()); agent_unlock(); if (reload_rc != 0) { json_error(buf, size, status, 500, "Config saved but live reload failed");