diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a9eb4..c55475c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha - 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). +- 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). @@ -40,6 +41,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/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/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++; } 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;