diff --git a/CHANGELOG.md b/CHANGELOG.md index 901fb38..4ce4ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,59 @@ 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. +- 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). +- 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). +- 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 `<`. +- 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). +- 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. + ### 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`. ### 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`. ### 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`). +- `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). - 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/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/Makefile b/Makefile index c075461..f40f391 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 @@ -124,8 +125,12 @@ 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 -ALLOWLIST_O := src/sandbox/allowlist.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) MANIFEST_O := src/asap/manifest.o MANIFEST_PROFILES_O := src/asap/manifest_profiles.o MANIFEST_BUILD_O := src/asap/manifest_build.o @@ -156,7 +161,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) @@ -188,7 +193,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 @@ -197,10 +202,13 @@ $(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 +$(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 -$(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 @@ -212,7 +220,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 @@ -364,7 +372,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.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 @@ -377,12 +385,18 @@ $(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 -$(ALLOWLIST_O): src/sandbox/allowlist.c src/sandbox/allowlist.h +$(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 +$(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 @@ -468,6 +482,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) @@ -588,9 +607,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 @@ -626,9 +645,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) \ @@ -713,7 +732,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) @@ -822,8 +841,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 +897,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 @@ -889,9 +909,9 @@ 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_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/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/config.example.toml b/config.example.toml index b666cfc..6ab83de 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 @@ -74,6 +76,13 @@ 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. 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] # assistant = "Override text for manifest capabilities.skills[].description" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 119c3e5..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` | @@ -78,7 +78,7 @@ Convention: one primary `.c` + `.h` per module; new tools go in `src/tools/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 owns rpc_id, same as asap_envelope_parse (Refs: #84). */ cJSON_Delete(root); return -1; } diff --git a/src/asap/envelope.h b/src/asap/envelope.h index d873eb8..fda2f90 100644 --- a/src/asap/envelope.h +++ b/src/asap/envelope.h @@ -72,7 +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 (not consumed) + * @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/src/asap/server.c b/src/asap/server.c index d681f9b..63bebdc 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) { @@ -98,13 +99,13 @@ 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); + /* Ownership of payload moves to out; asap_envelope_clear frees it once. */ out->payload = 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; @@ -112,11 +113,29 @@ 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 *sender, char *buf, size_t buf_size) +{ + 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, asap_server_ctx_t *ctx, char *err_message, size_t err_message_size) { char *prompt; char *resp_buf; + char sid_buf[ASAP_TASK_SESSION_ID_CAP]; const char *sid; int ar; cJSON *pl; @@ -134,7 +153,21 @@ 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). + * 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 { @@ -196,13 +229,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; } @@ -282,11 +326,16 @@ 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 + } 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/asap/server.h b/src/asap/server.h index 3762d94..f6ca15a 100644 --- a/src/asap/server.h +++ b/src/asap/server.h @@ -56,6 +56,18 @@ 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. + * 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("urn:alice", buf, sizeof buf) + * returns "asap:urn:alice". + */ +const char *asap_resolve_task_session_id(const char *sender, char *buf, size_t buf_size); + #ifdef __cplusplus } #endif diff --git a/src/channels/discord.c b/src/channels/discord.c index f29bbe1..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) @@ -294,16 +279,33 @@ 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 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 n; + int route; + const char **allowed; + char *bid; + + 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; + 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 +342,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)) - 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); + if (!discord_try_route_message(dc, d, sess_buf, sizeof(sess_buf))) 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); @@ -559,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/src/channels/webchat.c b/src/channels/webchat.c index 830273e..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[8192]; + 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/core/agent.c b/src/core/agent.c index 892f935..92bc6f0 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 @@ -36,6 +37,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) @@ -49,6 +60,26 @@ void agent_unlock(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. */ @@ -117,12 +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); - if (plen >= session_buf_size) plen = session_buf_size - 1; - memcpy(session_buf, printed, plen); - session_buf[plen] = '\0'; - cJSON_free(printed); + if (copy_printed_session_json(session_buf, session_buf_size, printed, session_id) != 0) + return -1; session_save(session_id, session_buf); return 0; } @@ -185,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. */ + prefix_len = strlen(prefix); + 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) + 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); @@ -248,20 +289,28 @@ 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 []. */ 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] == '[') { @@ -287,13 +336,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); - if (len >= out_size) len = out_size - 1; - memcpy(out_buf, printed, len); - out_buf[len] = '\0'; - 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) @@ -333,10 +376,12 @@ 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; int ret; + int skip_session_persist; } agent_run_ctx_t; static void agent_oom_msg(agent_run_ctx_t *ctx) @@ -347,6 +392,23 @@ 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; + /* 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; + 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) { @@ -383,7 +445,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) @@ -419,16 +484,24 @@ 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; } 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); } @@ -438,32 +511,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; @@ -472,14 +533,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) { @@ -513,24 +572,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/src/core/agent.h b/src/core/agent.h index 21c19d8..c530aef 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -49,24 +49,43 @@ 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(). + * 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. */ void agent_lock(void); /** - * Release the global agent mutex after agent_run() returns. + * Release the global agent mutex after agent_run(), a locked session_delete(), + * inbound mcp.tool_call execute, a locked state.query memory read, or a + * locked cron_ack_delivery(). */ 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. + * + * 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), and by ASAP server tests + * for inbound mcp.tool_call and state.query (#60). + */ +int agent_mutex_is_locked_for_test(void); + #ifdef __cplusplus } #endif diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index f097192..92ac554 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -16,8 +16,12 @@ #include "gateway/ws.h" #include "asap/manifest_keys.h" #endif +#include +#include #include #include +#include +#include #define SKILLS_BUF_SIZE (256 * 1024) #define SYSTEM_PROMPT_BUF_SIZE (256 * 1024) @@ -95,6 +99,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); @@ -211,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) + 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; @@ -313,12 +385,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..fbbbf4b 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" @@ -27,7 +28,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); @@ -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/config.c b/src/core/config.c index ba11309..3bec64e 100644 --- a/src/core/config.c +++ b/src/core/config.c @@ -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); diff --git a/src/core/config_patch.c b/src/core/config_patch.c new file mode 100644 index 0000000..3962446 --- /dev/null +++ b/src/core/config_patch.c @@ -0,0 +1,494 @@ +/** + * @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 +#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++) { + 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; + grown = realloc(*out, cap); + if (!grown) { + free(*out); + *out = NULL; + return -1; + } + *out = grown; + } + (*out)[len++] = '\\'; + (*out)[len++] = extra; + continue; + } + 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 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]; + 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 (!section_header_closed(p + marker_len)) + 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'); + if (!line_end || line_end > sec_end) + line_end = sec_end; + { + 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 = key_at + key_len; + while (after_key < line_end && + (*after_key == ' ' || *after_key == '\t')) + after_key++; + 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') + (*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 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 (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 && + patch_int_field(content, len, cap, "agent", "max_tokens", max_tokens->valueint) != 0) + return -1; + if (temperature && + patch_double_field(content, len, cap, "agent", "temperature", + temperature->valuedouble) != 0) + return -1; + if (gateway_host && + patch_string_field(content, len, cap, "gateway", "host", + gateway_host->valuestring) != 0) + return -1; + if (gateway_port && + patch_int_field(content, len, cap, "gateway", "port", gateway_port->valueint) != 0) + return -1; + 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) +{ + char path_copy[PATH_MAX]; + char tmp_path[PATH_MAX]; + char *dir; + config_t *cfg = NULL; + 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; + } + dir = dirname(path_copy); + if (!dir || dir[0] == '\0') { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + 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); + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + if (config_load(tmp_path, &cfg, errbuf, errbufsz) != 0) { + unlink(tmp_path); + return -1; + } + config_free(cfg); + unlink(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, 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; + } + 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/dispatch.c b/src/core/dispatch.c index 6b1dcfa..44b1479 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -9,42 +9,67 @@ #include "core/bootstrap.h" #include "core/memory.h" #include "core/version.h" +#include "tools/cron.h" #include #include #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 : ""; 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); - return ch->send(msg->session_id, "Session cleared.", NULL, 0); + agent_unlock(); + 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]; - 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, 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); + return send_then_maybe_ack_cron(ch, msg, resp_buf, err); } diff --git a/src/core/main.c b/src/core/main.c index 5d7fa2c..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; @@ -197,10 +200,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/memory.c b/src/core/memory.c index cb763ab..9edaa7e 100644 --- a/src/core/memory.c +++ b/src/core/memory.c @@ -15,6 +15,18 @@ #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); +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 (" @@ -118,6 +130,13 @@ int memory_init(const char *path) int file_existed = path_exists(path); int recreated = 0; if (sqlite3_open(path, &g_db) != SQLITE_OK) { + /* 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: %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) { @@ -226,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 SESSION_LOAD_TOO_LARGE; + } + memcpy(messages_out, msg, n + 1); ret = 0; } } @@ -253,6 +276,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; @@ -333,10 +358,50 @@ 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; + 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 : ""); +} + +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) { 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 : ""; const char *rec = recipient ? recipient : ""; const char *sql = "INSERT INTO cron_jobs(id, schedule, message, channel, recipient, next_run, enabled) " @@ -395,41 +460,105 @@ 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; 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); return count; } -int cron_job_get_next_due(long long now, cron_job_row_t *out) +int cron_job_list_due(long long now, cron_due_key_t *out, int max_count) { - 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 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) +{ + 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); + 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); + return -1; + } + ret = 1; + } + sqlite3_finalize(stmt); + 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; + 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) { - 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); @@ -459,6 +588,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 44aac17..b266660 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); @@ -69,6 +78,20 @@ 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)); + +/** + * 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. * @@ -97,17 +120,36 @@ 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. */ +/** 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. + * + * 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 id[128]; /* stored ids must fit with a trailing NUL (127 chars) */ + 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. * @@ -138,9 +180,31 @@ int cron_job_toggle(const char *id); int cron_job_update_next_run(const char *id, long long next_run); /** - * List cron jobs into output array. + * 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. * @return Number of jobs written, or -1 on error. */ @@ -149,12 +213,39 @@ 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. + * @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. + * + * @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/core/reload.c b/src/core/reload.c index 0eb6e14..17c64e5 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -59,23 +59,32 @@ 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. */ + old = bootstrap_get_cfg(); + if (!old) + old = *pcfg; + if (!old) + 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(*pcfg) != 0) { + 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); @@ -90,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/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/asap_http_body.c b/src/gateway/asap_http_body.c index 7ca5a2d..46f7475 100644 --- a/src/gateway/asap_http_body.c +++ b/src/gateway/asap_http_body.c @@ -44,6 +44,15 @@ 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) + return 1; + if (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..7728866 100644 --- a/src/gateway/asap_http_body.h +++ b/src/gateway/asap_http_body.h @@ -30,6 +30,19 @@ 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. + * 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, or body is NULL; 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/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/src/gateway/http.c b/src/gateway/http.c index 7bcd4bf..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", @@ -106,21 +109,26 @@ static void *http_thread_fn(void *arg) int http_start(const config_t *cfg, struct auth_ctx *auth_ctx, const char *config_path) { + const char *host; + const char *iface; + 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); + if (http_listen_iface(host, config_gateway_allow_bind_all(cfg), &iface) != 0) 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 = 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 24b043f..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,9 +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. - * 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 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. diff --git a/src/gateway/http_lws.c b/src/gateway/http_lws.c index dee91d6..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; @@ -410,6 +411,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 +458,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[8192]; - size_t len_out = 0; - if (ws_dequeue_outgoing(conn_id, buf, sizeof(buf), &len_out)) { - unsigned char frame[LWS_PRE + 8192]; - 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/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, diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 24cb6ef..39c24a8 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" @@ -14,14 +15,22 @@ #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" #include "core/memory.h" +#include "core/reload.h" #include "core/skill.h" #include "providers/provider.h" #include "tools/context.h" #include "tools/cron.h" #include "cJSON.h" #include +#include +#include +#include +#include #include #include #include @@ -186,9 +195,105 @@ 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 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) { + char *patched_body = NULL; + size_t patched_len = 0; + char errbuf[256] = {0}; + const char *write_body = body; + size_t write_len = body_len; + 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"); return; @@ -197,40 +302,54 @@ 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; } - snprintf(tmp_path, path_len + 8, "%s.tmp", ctx->config_path); - FILE *f = fopen(tmp_path, "w"); - if (!f) { - free(tmp_path); - json_error(buf, size, status, 500, "Failed to write config"); - 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; } - size_t written = fwrite(body, 1, body_len, f); - fclose(f); - if (written != body_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; } - 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; } 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. The gateway pointer is published before unlock. */ + { + config_t *live_cfg = bootstrap_get_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); + /* 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"); + return; + } + } *status = 200; json_response(buf, size, status, "{\"ok\":true}"); } @@ -389,30 +508,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); @@ -555,6 +696,47 @@ 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) +{ + 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; +} + +/** + * 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 int 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 -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, const char *body, size_t body_len, char *buf, size_t size, int *status) { @@ -584,11 +766,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); @@ -596,22 +781,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; } - *status = 200; - { - size_t rlen = strlen(resp_json); - if (rlen >= size) rlen = size - 1; - memcpy(buf, resp_json, rlen); - buf[rlen] = '\0'; + if (write_asap_jsonrpc(buf, size, status, resp_json) != 0) { + asap_envelope_clear(&out); + return; } - free(resp_json); + 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/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..9c781e9 100644 --- a/src/gateway/ws.h +++ b/src/gateway/ws.h @@ -12,6 +12,22 @@ extern "C" { #endif +/** 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/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/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/src/sandbox/allowlist.c b/src/sandbox/allowlist.c index 3f5ec65..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,16 +36,13 @@ 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_", @@ -57,18 +52,19 @@ static const char *const BLOCK_SUBSTRINGS[] = { "/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 @@ -76,68 +72,540 @@ 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] == '$'; } -/* ------------------------------------------------------------------ */ -/* Public: path-under-workspace check (5.4) */ -/* ------------------------------------------------------------------ */ +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; +} -int allowlist_path_is_under_workspace(const char *path, const char *workspace_root) +static int is_path_body_char(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 == '/' || c == '.' || c == '_' || + c == '-' || c == '+' || c == '%' || c == '@'; +} + +static int is_ident_cont(unsigned char c) +{ + 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; } -/* ------------------------------------------------------------------ */ -/* Public: combined check */ -/* ------------------------------------------------------------------ */ +static int at_word_start(const char *text, const char *p) +{ + unsigned char prev; -int allowlist_check_shell_command(const char *cmd, const allowlist_config_t *cfg, - char *reason_buf, size_t reason_cap) + 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) { - const char *const *p; - char ws_resolved[PATH_MAX]; - const char *workspace_root = NULL; - int workspace_only = 0; - char *cmd_copy = NULL; - char *tok; - char *saveptr; - if (!cmd) { - set_reason(reason_buf, reason_cap, "null command", ""); + 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; + } } - /* Phase 1: built-in substring blocklist */ + 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, @@ -153,43 +621,154 @@ 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'; - } - workspace_root = ws_resolved; - /* Tokenize the command and check each path-like token. */ + 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; +} + +static int deny_runtime_state(char *reason_buf, size_t reason_cap, const char *shown) +{ + set_reason(reason_buf, reason_cap, "command blocked: runtime state file: ", shown); + fprintf(stderr, "allowlist: blocked runtime state file: %s\n", shown); + return 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 ((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; +} + +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) +{ + char ws_resolved[PATH_MAX]; + const char *workspace_root = NULL; + int workspace_only; + char *cmd_copy; + char *tok; + char *saveptr; + + if (!cmd) { + set_reason(reason_buf, reason_cap, "null command", ""); + return 1; + } + 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; + memcpy(ws_resolved, cfg->workspace_path, n); + ws_resolved[n] = '\0'; + } + workspace_root = ws_resolved; + } + 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) { - if (has_path_chars(tok)) { - /* Expand a leading tilde naively */ + tok = strip_surrounding_quotes(tok); + if (block_if_runtime_state_token(tok, workspace_root, reason_buf, reason_cap)) { + free(cmd_copy); + return 1; + } + if (workspace_only && has_path_chars(tok)) { 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 (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 blocked; } - if (!allowlist_path_is_under_workspace(tok, workspace_root)) { - set_reason(reason_buf, reason_cap, - "command blocked: path escapes workspace: ", tok); - fprintf(stderr, "allowlist: blocked path outside workspace: %s\n", tok); + 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 76a4e4f..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). @@ -66,6 +78,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 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/src/sandbox/sandbox.c b/src/sandbox/sandbox.c index 927ef3d..4aac1a7 100644 --- a/src/sandbox/sandbox.c +++ b/src/sandbox/sandbox.c @@ -1,14 +1,18 @@ /** * @file sandbox.c - * @brief Process sandbox: Linux namespace isolation, cgroups v2, timeout/kill. + * @brief Process sandbox: Linux namespaces, Landlock FS bound, cgroups v2. * - * 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 (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. * * Non-Linux path: plain fork() + execl(); a warning is emitted to stderr. */ @@ -16,6 +20,7 @@ #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_landlock.h" #include #include #include @@ -31,18 +36,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 +71,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 +107,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 +145,161 @@ 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; + if (workspace && workspace[0] && + sandbox_landlock_restrict_to_workspace(workspace) != 0) + return SANDBOX_ISO_LL; + 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 +309,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 +353,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 +375,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..277d513 100644 --- a/src/sandbox/sandbox.h +++ b/src/sandbox/sandbox.h @@ -1,10 +1,16 @@ /** * @file sandbox.h - * @brief Process sandbox API: isolated execution with namespaces, timeout, and cgroups v2. + * @brief Process sandbox API: namespaces, Landlock FS bound, 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). 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. */ #ifndef SHELLCLAW_SANDBOX_H #define SHELLCLAW_SANDBOX_H @@ -42,9 +48,15 @@ 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 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. @@ -55,7 +67,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/src/sandbox/sandbox_landlock.c b/src/sandbox/sandbox_landlock.c new file mode 100644 index 0000000..4dac6a7 --- /dev/null +++ b/src/sandbox/sandbox_landlock.c @@ -0,0 +1,249 @@ +/** + * @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); + return (rc == 0) ? 0 : -1; +} + +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 + 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++) { + 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; +} + +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/cron.c b/src/tools/cron.c index eb46e18..a6a753b 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; @@ -171,35 +178,229 @@ static int cron_init(const config_t *cfg) return 0; } +#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]; + +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_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; + if (timeout_ms <= 0 || !job_id) + return 0; + if (!cron_offer_age_ms(job_id, now, &age)) + return 0; + 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; + struct timespec remain; + long age = 0; + long wait_ms; + if (timeout_ms <= 0 || !job_id) + return; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) + return; + if (!cron_offer_age_ms(job_id, &now, &age) || age >= (long)timeout_ms) + return; + wait_ms = (long)timeout_ms - age; + 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, int timeout_ms) +{ + struct timespec now; + int i; + int slot = -1; + if (!job_id || job_id[0] == '\0') + return; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) + return; + 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 && cron_slot_reusable(&s_offers[i], &now)) + slot = i; + } + if (slot < 0) + 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; +} + +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; + + if (clock_gettime(CLOCK_MONOTONIC, &mono) != 0) + memset(&mono, 0, sizeof(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; + } + if (cron_job_get_by_id(keys[0].id, row) != 1) + return 0; + /* 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; +} + 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; - 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); - } + if (!cron_pick_due_row(now, timeout_ms, &row)) + return 0; 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"); 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_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); + 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); + if (rc == 0) + cron_offer_release(job_id); + return rc; + } + now = (long long)time(NULL); + rc = cron_parse_next_run(row.schedule, now, &next); + cron_job_row_free(&row); + if (rc != 0) + next = now + 365LL * 24 * 3600; + 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, const channel_attachment_t *attachments, size_t att_count) { @@ -218,6 +419,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 = { @@ -251,21 +453,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/src/tools/cron.h b/src/tools/cron.h index 1635d89..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); @@ -33,6 +35,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/src/tools/file.c b/src/tools/file.c index e8170b1..4e07d83 100644 --- a/src/tools/file.c +++ b/src/tools/file.c @@ -2,14 +2,19 @@ * @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 #include "tools/tool.h" #include "tools/file.h" #include "core/config.h" +#include "sandbox/allowlist.h" #include "cJSON.h" #include +#include #include #include #include @@ -31,34 +36,89 @@ 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 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_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); + 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); - 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; + workspace = config_workspace_path(g_file_cfg); + if (!workspace || workspace[0] == '\0') + 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; + if (!resolved_is_under_workspace(resolved)) return 0; + if (path_is_reserved_runtime_state(path, resolved)) return 0; return 1; } - char path_copy[PATH_MAX]; + /* 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 (;;) { 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; + if (!resolved_is_under_workspace(resolved)) return 0; + if (path_is_reserved_runtime_state(path, resolved)) return 0; return 1; } if (strcmp(dir, ".") == 0 || strcmp(dir, "/") == 0) break; @@ -67,13 +127,124 @@ static int path_within_workspace(const char *path, char *resolved, size_t resolv 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_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 void discard_file_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 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 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) +{ + 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-write-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 (fchmod(fd, 0644) != 0) { + discard_file_tmp(fd, tmp_path); + return -1; + } + 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; + } + 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\"}"); @@ -98,46 +269,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]; + ws_only = g_file_cfg ? config_workspace_only(g_file_cfg) : 0; 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 (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; - } + if (write_file_atomic(safe_path, content ? content : "") != 0) { + snprintf(result_buf, max_len, "{\"error\":\"write failed\"}"); + return -1; } - fclose(f); snprintf(result_buf, max_len, "{\"status\":\"ok\"}"); return 0; } @@ -145,10 +294,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/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/src/tools/shell.c b/src/tools/shell.c index 89f125d..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- @@ -19,12 +20,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 @@ -41,6 +44,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 }; @@ -56,6 +60,50 @@ 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); +} + +/* 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; + int retries; + struct timespec ts; + kill_command_tree(pid); + wr = waitpid(pid, &status, WNOHANG); + if (wr != 0) + return 0; + 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 +116,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 +133,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 +151,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; @@ -186,13 +234,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/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; diff --git a/tests/test_agent.c b/tests/test_agent.c index 1ee8603..4b3b7e6 100644 --- a/tests/test_agent.c +++ b/tests/test_agent.c @@ -8,6 +8,8 @@ #include "core/config.h" #include "core/memory.h" #include "providers/provider.h" +#include "cJSON.h" +#include #include #include #include @@ -20,6 +22,8 @@ #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_first_last_char; static char spy_content[SPY_SLOTS][SPY_CONTENT_SIZE]; static char *spy_roles[SPY_SLOTS]; @@ -42,12 +46,17 @@ 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; + 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) { 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'; } @@ -567,6 +576,134 @@ 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_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; + 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 >= 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)); + 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; + 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)); + 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; + if (!multi_tool_round_saw_live_tool_calls) 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"; @@ -590,6 +727,455 @@ 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; +} + +/** + * 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; +} + +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 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}; + 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). */ + if (prepare_coffee_memory_store(db_path) != 0) + goto cleanup; + if (write_filled_soul_file(soul_path, 65535U) != 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; + 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) + 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; + 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); + 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()); @@ -597,10 +1183,15 @@ 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()); 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_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"); diff --git a/tests/test_allowlist.c b/tests/test_allowlist.c index d87f768..6ff4dc8 100644 --- a/tests/test_allowlist.c +++ b/tests/test_allowlist.c @@ -5,9 +5,14 @@ * Tests cover: built-in blocklist patterns, workspace-only path containment, * realpath-based symlink escape detection, and edge cases (NULL, empty string). */ +#if defined(__APPLE__) +#define _DARWIN_C_SOURCE +#endif +#define _DEFAULT_SOURCE #define _POSIX_C_SOURCE 200809L #include "sandbox/allowlist.h" +#include #include #include #include @@ -106,6 +111,93 @@ 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[PATH_MAX - 32]; + char cfg_path[PATH_MAX]; + char db_path[PATH_MAX]; + char *tmp; + FILE *f; + allowlist_config_t acfg; + char cmd[PATH_MAX + 16]; + + 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_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); + ASSERT(allowlist_path_is_runtime_state_file("/tmp/project/memory.db") == 0); + return 0; +} + /* ------------------------------------------------------------------ */ /* Workspace path containment */ /* ------------------------------------------------------------------ */ @@ -201,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 */ /* ------------------------------------------------------------------ */ @@ -219,12 +835,33 @@ 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_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; } 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; 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++; } 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; diff --git a/tests/test_asap_http_body.c b/tests/test_asap_http_body.c index 5e7df11..b7bb98c 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,20 @@ 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) == 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; + ASSERT(asap_http_body_exceeds_static_cap(&body, (long)BODY_BUF_SIZE + 1) == 0); + return 0; +} + int main(void) { int failed = 0; @@ -86,6 +101,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_asap_server.c b/tests/test_asap_server.c index 1669864..7d10490 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; @@ -46,6 +78,10 @@ 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 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) { (void)args_json; @@ -53,6 +89,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; @@ -73,6 +117,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 = "", @@ -80,6 +155,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 = "", @@ -127,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); @@ -686,6 +775,150 @@ 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; + 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]\ndb_path = \"%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); + 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(); + unlink(tmpl); + remove(cfg_path); + return rc; +} + +static int test_resolve_task_session_id(void) +{ + char buf[128]; + char tiny[8]; + const char *sid; + 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); + ASSERT(sid == buf); + ASSERT(strcmp(sid, "asap:urn:bob") == 0); + sid = asap_resolve_task_session_id(NULL, buf, sizeof buf); + ASSERT(sid != NULL && strcmp(sid, "asap:inbound") == 0); + 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; +} + static int test_trust_sender_rejects_blank_sender_when_list_nonempty(void) { const char *path = "/tmp/shellclaw_test_asap_trust_blank.toml"; @@ -720,6 +953,114 @@ 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; +} + +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; @@ -746,6 +1087,14 @@ 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(); + 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; } 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_config.c b/tests/test_config.c index e3be8a9..c32b633 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -106,6 +106,15 @@ 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(n >= 21); + ASSERT(strcmp(ws + n - 21, "/.shellclaw/workspace") == 0); + } config_free(cfg); remove(path); return 0; diff --git a/tests/test_config_patch.c b/tests/test_config_patch.c new file mode 100644 index 0000000..3980f4a --- /dev/null +++ b/tests/test_config_patch.c @@ -0,0 +1,249 @@ +/** + * @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_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]; + 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; +} + +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; + 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 (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 (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; +} diff --git a/tests/test_cron.c b/tests/test_cron.c index ea21c0c..2e8369c 100644 --- a/tests/test_cron.c +++ b/tests/test_cron.c @@ -2,9 +2,11 @@ * @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" +#include "channels/channel.h" #include #include #include @@ -13,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; @@ -41,7 +50,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; } @@ -50,7 +59,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; } @@ -74,16 +134,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 +174,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,24 +200,480 @@ 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) +{ + cron_test_reset_offers(); + 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) +{ + cron_test_reset_offers(); + 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); + memset(zeros, '0', 120); + zeros[120] = '\0'; + snprintf(schedule, sizeof(schedule), "interval:%s60", zeros); + ASSERT(strlen(schedule) > 127); + 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); + 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; +} + +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) +{ + 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"; + 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; +} + +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; + 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; +} + +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; + 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); + 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); + 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; + + cron_test_reset_offers(); + 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); + 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; +} + +/* 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()); 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()); 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()); + 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()); + 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; } diff --git a/tests/test_discord_helpers.c b/tests/test_discord_helpers.c index 954bfdb..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) @@ -90,6 +91,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); @@ -99,6 +102,111 @@ 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); + 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); + 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()); @@ -108,6 +216,9 @@ 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()); + RUN(test_rx_append_grows_for_nul()); + RUN(test_rx_append_rejects_over_max()); printf("test_discord_helpers: all tests passed\n"); return 0; } diff --git a/tests/test_dispatch.c b/tests/test_dispatch.c index 998bb99..2478f42 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" @@ -18,6 +19,7 @@ void bootstrap_add_tool_for_test(const tool_t *tool); #include #include #include +#include #include #define ASSERT(c) \ @@ -40,6 +42,8 @@ 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 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) @@ -62,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; @@ -114,6 +126,35 @@ 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 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; @@ -174,9 +215,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); @@ -303,6 +349,125 @@ 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}; + 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); + ASSERT(agent_mutex_is_locked_for_test() == 0); + config_free(cfg); + unlink(tmpl); + return 0; +} + int main(void) { RUN(test_reset_clears_session()); @@ -310,6 +475,9 @@ 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()); + RUN(test_cron_slash_commands_ack_jobs()); + RUN(test_cron_agent_failure_does_not_ack()); puts("test_dispatch OK"); return 0; } diff --git a/tests/test_file.c b/tests/test_file.c index b4087ac..0907d27 100644 --- a/tests/test_file.c +++ b/tests/test_file.c @@ -196,6 +196,373 @@ 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()); + MU_ASSERT(mkdir(tmpdir, 0755) == 0 || errno == EEXIST, "mkdir state tmpdir"); + snprintf(state_dir, sizeof(state_dir), "%s/.shellclaw", tmpdir); + 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); + 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); +} + +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); +} + +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 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; + 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); +} + +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); @@ -203,6 +570,13 @@ 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); + 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); + 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; } diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 13b3abf..58b3bfb 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1,11 +1,12 @@ /** * @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 #include "gateway/auth.h" +#include "gateway/http.h" #include "gateway/rate_limit.h" #include "core/config.h" #include "core/version.h" @@ -13,10 +14,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -65,6 +68,168 @@ 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"; + 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) + return LISTEN_BIND_LOOPBACK; + 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; + 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. */ + 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); + 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); @@ -420,6 +585,250 @@ static int test_asap_missing_fields(void) return 0; } +static int post_asap(const char *payload_type, const char *payload_json, + const char *env_id, long *code, char **body) +{ + char req[1536]; + int n; + n = snprintf(req, sizeof req, + "{\"jsonrpc\":\"2.0\",\"method\":\"asap.send\"," + "\"params\":{" + "\"id\":\"%s\"," + "\"asap_version\":\"2.1\"," + "\"sender\":\"urn:asap:agent:a\"," + "\"recipient\":\"urn:asap:agent:b\"," + "\"payload_type\":\"%s\"," + "\"payload\":%s," + "\"correlation_id\":\"c1\"," + "\"trace_id\":\"t1\"," + "\"timestamp\":\"2026-01-01T00:00:00Z\"" + "},\"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(asap_http_is(code, 200, body)); + ASSERT(body != NULL); + ASSERT(asap_result_payload_type_is(body, "task.response")); + free(body); + return 0; +} + +static int test_asap_mcp_tool_call(void) +{ + long code; + char *body = NULL; + int r = post_asap("mcp.tool_call", + "{\"name\":\"cron\",\"arguments\":{\"operation\":\"list\"}}", + "01HZABC124", &code, &body); + ASSERT(r == 0); + 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(asap_error_code_is(body, -32001)); + free(body); + 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/workspace", g_test_home); + if (mkdir(path, 0755) != 0 && errno != EEXIST) + return -1; + snprintf(path, path_sz, "%s/.shellclaw/workspace/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 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]; + long code; + 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) + 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); + if (token && token[0]) { + ASSERT(asap_log_outbound_count(token, &out_after) == 0); + ASSERT(out_after == out_before); + } + return 0; +} + static int test_manifest(void) { long code; @@ -468,7 +877,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); @@ -477,6 +887,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; @@ -623,6 +1073,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; @@ -652,6 +1123,38 @@ 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); + 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; +} + static int test_api_skills_list(const char *token) { long code; @@ -808,6 +1311,105 @@ static int test_api_asap_log_401(void) return 0; } +#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 ncreated = 0; + for (i = 0; i < n; i++) { + if (pthread_create(&thds[ncreated], NULL, shutdown_load_thread, load) != 0) + continue; + ncreated++; + } + return ncreated; +} + +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); + } +} + +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; @@ -1031,6 +1633,19 @@ 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++; + } 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"); @@ -1050,12 +1665,30 @@ 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++; } + 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++; } + 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(token) != 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"); @@ -1079,10 +1712,18 @@ 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++; } + 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++; } @@ -1110,8 +1751,14 @@ int main(int argc, char **argv) failed++; } } - kill(pid, SIGTERM); - waitpid(pid, NULL, 0); + if (test_shutdown_does_not_crash(pid, token, &shutdown_reaped) != 0) { + fprintf(stderr, "test_shutdown_does_not_crash failed\n"); + failed++; + if (!shutdown_reaped) { + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } + } unlink(config_path); unlink(tokens_path); unlink(pairing_file); 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()); diff --git a/tests/test_memory.c b/tests/test_memory.c index a72ae47..8b0c7b5 100644 --- a/tests/test_memory.c +++ b/tests/test_memory.c @@ -7,6 +7,7 @@ #include #include #include +#include #include "sqlite3.h" @@ -108,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)) == SESSION_LOAD_TOO_LARGE); + 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"; @@ -153,13 +176,51 @@ 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"; + 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); + 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]; + 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_session_load_rejects_oversized()); RUN(test_gateway_schema_new_db()); RUN(test_gateway_schema_migration_v01()); printf("test_memory: all tests passed\n"); diff --git a/tests/test_reload.c b/tests/test_reload.c index d3ff4f3..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(); @@ -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; } diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c index 4c653c3..45fa2d0 100644 --- a/tests/test_sandbox.c +++ b/tests/test_sandbox.c @@ -2,22 +2,37 @@ * @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, 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). 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. */ +#define _DEFAULT_SOURCE +#define _DARWIN_C_SOURCE #define _POSIX_C_SOURCE 200809L #include "sandbox/sandbox.h" +#include "sandbox/sandbox_landlock.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 +43,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 +140,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 +164,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 +249,491 @@ 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; } -/* ------------------------------------------------------------------ */ -/* Linux-only: namespace isolation (shadow should not be readable) */ -/* ------------------------------------------------------------------ */ +#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; +} + +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; +} + +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__ 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 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; + 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 +741,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 +759,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,37 +772,85 @@ 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_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; } diff --git a/tests/test_shell.c b/tests/test_shell.c index 8b43d94..a37341b 100644 --- a/tests/test_shell.c +++ b/tests/test_shell.c @@ -2,12 +2,22 @@ * @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" #include "core/config.h" +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include static int tests_run = 0; static int tests_failed = 0; @@ -60,6 +70,47 @@ 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[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) { const tool_t *t = tool_shell_get(); @@ -68,13 +119,205 @@ 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 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 + * 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[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) { 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); + 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; } 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; diff --git a/tests/test_ws.c b/tests/test_ws.c index f3f0507..af53973 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,8 +24,107 @@ 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 + * enqueue and dequeue intact. + */ +static int test_send_to_accepts_dispatch_sized_payload(void) +{ + char *payload; + char buf[WS_TEXT_BUF_SIZE]; + 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 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_BUF_SIZE); +} + +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_BUF_SIZE); +} + +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_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_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) { @@ -46,10 +145,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); @@ -67,10 +166,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); @@ -186,6 +285,11 @@ 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_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()); 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();