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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +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` (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).
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,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
Expand Down
2 changes: 2 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,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
Expand Down
50 changes: 50 additions & 0 deletions src/core/bootstrap.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
#include "gateway/ws.h"
#include "asap/manifest_keys.h"
#endif
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

#define SKILLS_BUF_SIZE (256 * 1024)
#define SYSTEM_PROMPT_BUF_SIZE (256 * 1024)
Expand Down Expand Up @@ -233,8 +237,54 @@ static void channels_cleanup(void)
g_cfg = NULL;
}

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 0;
slash = strrchr(workspace, '/');
if (slash && slash != workspace) {
char parent[PATH_MAX];
size_t 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)
Comment thread
adriannoes marked this conversation as resolved.
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)
{
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;
Expand Down
4 changes: 3 additions & 1 deletion src/core/config.c
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,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");
set_string(&cfg->asap_description, DEFAULT_ASAP_DESCRIPTION);
Expand Down
169 changes: 143 additions & 26 deletions src/sandbox/allowlist.c
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ static const char *const BLOCK_SUBSTRINGS[] = {
"~/.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",
Expand Down Expand Up @@ -119,6 +122,101 @@ 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 &&
strncmp(base, "memory.db-", 10) != 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;
}

/** 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 */
/* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -153,40 +251,59 @@ 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 */
tok = strtok_r(cmd_copy, " \t\n;|&><", &saveptr);
while (tok) {
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;
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)) {
Comment thread
adriannoes marked this conversation as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: HIGH

The new runtime-state denylist inspects raw strtok tokens and exact basenames. A quoted or globbed path such as cat "~/.shellclaw/auth_tok*" never matches auth_tokens.json / config.toml, has_path_chars skips the token because it does not start with /, ~, or ., and the unsandboxed fallback still calls this check with cfg == NULL.

Impact: An inbound Discord/WebChat/cron prompt that causes shell can still dump pairing tokens or config.toml after this PR’s default workspace split.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit e63a7ec. Configure here.

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;
}
/* 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 (!allowlist_path_is_under_workspace(tok, workspace_root)) {
}
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: ", 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;
}
Expand Down
14 changes: 14 additions & 0 deletions src/sandbox/allowlist.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ 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, 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.
*
* 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
Expand Down
Loading
Loading