Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down
85 changes: 73 additions & 12 deletions src/core/skill.c
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
* @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"
#include "skill.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
#include <pthread.h>
#include <stdatomic.h>
Expand Down Expand Up @@ -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);
Comment thread
adriannoes marked this conversation as resolved.
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;
Expand All @@ -382,25 +453,15 @@ 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)
{
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)
Expand Down
86 changes: 75 additions & 11 deletions src/gateway/auth.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -10,6 +13,7 @@
#include "cJSON.h"
#include <errno.h>
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
#include <sys/stat.h>
#include <stdio.h>
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Comment thread
adriannoes marked this conversation as resolved.
/* Read existing tokens and append (multi-device support). */
cJSON *arr = NULL;
{
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading