diff options
Diffstat (limited to 'tests/test_limits.cpp')
| -rw-r--r-- | tests/test_limits.cpp | 379 |
1 files changed, 379 insertions, 0 deletions
diff --git a/tests/test_limits.cpp b/tests/test_limits.cpp new file mode 100644 index 0000000..f425d64 --- /dev/null +++ b/tests/test_limits.cpp @@ -0,0 +1,379 @@ +#include "limits.h" +#include "verbose.h" + +#include <cjson/cJSON.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_EQ_STR(a, b, msg) \ + do { \ + checks++; \ + const char *_a = (a), *_b = (b); \ + if (!_a || (_b && strcmp(_a, _b) != 0)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \ + __FILE__, __LINE__, (msg), _a ? _a : "(null)", \ + _b ? _b : "(null)"); \ + } \ + } while (0) + +static tool_limits mklimits(const char **allow, size_t nallow, + const char **deny, size_t ndeny) { + tool_limits l; + tool_limits_defaults(&l); + for (size_t i = 0; i < nallow; i++) + sl_push(&l.shell_allowlist, xstrdup(allow[i])); + for (size_t i = 0; i < ndeny; i++) + sl_push(&l.shell_denylist, xstrdup(deny[i])); + return l; +} + +static void test_defaults(void) { + tool_limits l; + tool_limits_defaults(&l); + CHECK(l.shell_timeout_secs == 120, "default shell_timeout_secs"); + CHECK(l.max_output_chars == 16000, "default max_output_chars"); + CHECK(l.max_file_bytes == 2000000, "default max_file_bytes"); + CHECK(l.shell_allowlist.len == 0, "default allowlist empty"); + CHECK(l.shell_denylist.len == 0, "default denylist empty"); + CHECK(!l.confirm_destructive, "default confirm_destructive off"); + CHECK(!l.verbose, "default verbose off"); + tool_limits_free(&l); +} + +static void test_set_get_roundtrip(void) { + const char *allow[] = {"git", "ls"}; + const char *deny[] = {"git push"}; + tool_limits l = mklimits(allow, 2, deny, 1); + l.confirm_destructive = true; + l.verbose = true; + tool_limits_set(&l); + tool_limits g; + tool_limits_get(&g); + CHECK(g.shell_timeout_secs == 120, "get timeout"); + CHECK(g.confirm_destructive, "get confirm_destructive"); + CHECK(g.verbose, "get verbose"); + CHECK(g.shell_allowlist.len == 2, "get allowlist len"); + CHECK(g.shell_allowlist.len == 2 && + strcmp(g.shell_allowlist.items[0], "git") == 0, + "get allowlist[0]"); + CHECK(g.shell_allowlist.len == 2 && + strcmp(g.shell_allowlist.items[1], "ls") == 0, + "get allowlist[1]"); + CHECK(g.shell_denylist.len == 1 && + strcmp(g.shell_denylist.items[0], "git push") == 0, + "get denylist[0]"); + tool_limits_free(&l); + tool_limits_free(&g); + /* restore defaults for later tests */ + tool_limits d; + tool_limits_defaults(&d); + tool_limits_set(&d); + tool_limits_free(&d); +} + +static void test_allowlist_allows_matching_and_refuses_others(void) { + const char *allow[] = {"git", "ls"}; + tool_limits l = mklimits(allow, 2, NULL, 0); + char *reason = NULL; + CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED, + "git status allowed"); + CHECK(shell_policy("ls -la", &l, NULL) == SHELL_ALLOWED, "ls -la allowed"); + CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED, + "rm -rf x refused"); + CHECK(reason && strstr(reason, "allowlist") != NULL, + "refusal mentions allowlist"); + free(reason); + tool_limits_free(&l); +} + +static void test_allowlist_entry_does_not_match_longer_word(void) { + const char *allow[] = {"git"}; + tool_limits l = mklimits(allow, 1, NULL, 0); + char *reason = NULL; + CHECK(shell_policy("igit x", &l, &reason) == SHELL_DENIED, + "igit x refused (no word-boundary match)"); + free(reason); + CHECK(shell_policy("git push", &l, NULL) == SHELL_ALLOWED, + "git push allowed by bare-word entry"); + tool_limits_free(&l); +} + +static void test_denylist_wins_over_allowlist(void) { + const char *allow[] = {"git"}; + const char *deny[] = {"git push"}; + tool_limits l = mklimits(allow, 1, deny, 1); + CHECK(shell_policy("git status", &l, NULL) == SHELL_ALLOWED, + "git status allowed despite denylist presence"); + char *reason = NULL; + CHECK(shell_policy("git push origin", &l, &reason) == SHELL_DENIED, + "git push origin refused by denylist"); + CHECK_EQ_STR( + reason, + "command matches the denylist entry `git push`", + "exact denylist reason"); + free(reason); + tool_limits_free(&l); +} + +static void test_allowlist_reason_string(void) { + const char *allow[] = {"git", "ls"}; + tool_limits l = mklimits(allow, 2, NULL, 0); + char *reason = NULL; + CHECK(shell_policy("rm -rf x", &l, &reason) == SHELL_DENIED, "denied"); + CHECK_EQ_STR(reason, "command not in the allowlist (git, ls)", + "exact allowlist reason"); + free(reason); + tool_limits_free(&l); +} + +static void test_no_allowlist_means_no_allowlist(void) { + tool_limits l; + tool_limits_defaults(&l); + CHECK(shell_policy("anything at all", &l, NULL) == SHELL_ALLOWED, + "no allowlist: everything allowed"); + tool_limits_free(&l); +} + +static void test_gate_reason_wrapping(void) { + const char *deny[] = {"git push"}; + tool_limits l = mklimits(NULL, 0, deny, 1); + char *err = NULL; + CHECK(shell_gate("git push origin", &l, &err) == -1, "gate denies"); + CHECK_EQ_STR(err, "run_shell blocked: command matches the denylist " + "entry `git push`", + "gate wraps policy reason"); + free(err); + tool_limits_free(&l); +} + +static void test_destructive_detection(void) { + CHECK(is_destructive("rm -rf /tmp/x"), "rm is destructive"); + CHECK(is_destructive("git reset --hard"), "git reset is destructive"); + CHECK(is_destructive("dd if=/dev/zero of=/dev/sda bs=1M"), + "dd with of= is destructive"); + CHECK(!is_destructive("ls -la"), "ls is not destructive"); + CHECK(!is_destructive("git status"), "git status is not destructive"); + CHECK(!is_destructive("echo hi"), "echo is not destructive"); + CHECK(is_destructive("chmod 777 /etc/passwd"), "chmod is destructive"); + CHECK(!is_destructive("dd if=/dev/zero bs=1M"), + "dd without of= is not destructive"); + CHECK(is_destructive("git checkout -- ."), "git checkout is destructive"); + CHECK(!is_destructive("git commit -m x"), "git commit is not destructive"); + CHECK(is_destructive(" rm x"), "leading whitespace still destructive"); + CHECK(is_destructive("echo safe; rm x"), "compound command requires confirmation"); + CHECK(is_destructive("echo safe | rm x"), "pipeline requires confirmation"); + CHECK(is_destructive("echo $(rm x)"), "expansion requires confirmation"); + CHECK(!is_destructive(""), "empty command not destructive"); +} + +static bool hook_yes(const char *cmd, void *ud) { (void)cmd; (void)ud; return true; } +static int hook_calls = 0; +static char hook_last[256]; +static bool hook_record(const char *cmd, void *ud) { + (void)ud; + hook_calls++; + snprintf(hook_last, sizeof hook_last, "%s", cmd); + return false; +} + +static void test_gate_refuses_when_hook_declines(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(hook_record, NULL); + hook_calls = 0; + char *err = NULL; + CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1, "declined => -1"); + CHECK(err && strstr(err, "declined") != NULL, "reason says declined"); + CHECK_EQ_STR(err, "user declined to run destructive command: " + "rm -rf /tmp/x", + "exact decline reason"); + free(err); + CHECK(hook_calls == 1, "hook invoked exactly once"); + CHECK_EQ_STR(hook_last, "rm -rf /tmp/x", "hook received the command"); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); +} + +static void test_gate_allows_when_hook_approves(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(hook_yes, NULL); + char *err = NULL; + CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == 0, "approved => 0"); + CHECK(err == NULL, "no error on approval"); + free(err); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); +} + +static void test_gate_no_hook_denies(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(NULL, NULL); + char *err = NULL; + CHECK(shell_gate("rm -rf /tmp/x", &l, &err) == -1, + "no hook: destructive command denied"); + CHECK(err && strstr(err, "declined"), "no-hook denial has reason"); + free(err); + tool_limits_free(&l); +} + +static void test_gate_non_destructive_skips_hook(void) { + tool_limits l; + tool_limits_defaults(&l); + l.confirm_destructive = true; + set_confirm_hook(hook_record, NULL); + hook_calls = 0; + char *err = NULL; + CHECK(shell_gate("ls -la", &l, &err) == 0, "non-destructive passes"); + CHECK(hook_calls == 0, "hook not consulted for non-destructive"); + free(err); + set_confirm_hook(NULL, NULL); + tool_limits_free(&l); +} + +static void test_policy_null_reason(void) { + const char *deny[] = {"git push"}; + tool_limits l = mklimits(NULL, 0, deny, 1); + CHECK(shell_policy("git push", &l, NULL) == SHELL_DENIED, "denied"); + CHECK(shell_policy("ls", &l, NULL) == SHELL_ALLOWED, "allowed"); + tool_limits_free(&l); +} + +static void test_policy_checks_wrapped_commands(void) { + const char *deny[] = {"rm"}; + tool_limits l = mklimits(NULL, 0, deny, 1); + CHECK(shell_policy("sudo -u root rm -rf /tmp/x", &l, NULL) == SHELL_DENIED, + "denylist catches sudo-wrapped rm"); + CHECK(is_destructive("env PATH=/bin git clean -fd"), + "destructive classifier catches env-wrapped git clean"); + CHECK(is_destructive("sudo git push origin main"), + "destructive classifier catches sudo-wrapped git push"); + tool_limits_free(&l); +} + +/* ---- verbose ---- */ + +static char **vlines = NULL; +static size_t vline_n = 0, vline_cap = 0; + +static void vrecord(const char *line, void *ud) { + (void)ud; + if (vline_n + 1 > vline_cap) { + vline_cap = vline_cap ? vline_cap * 2 : 8; + vlines = (char* * ) realloc(vlines, vline_cap * sizeof(char *)); + } + vlines[vline_n++] = xstrdup(line); +} + +static void vreset(void) { + for (size_t i = 0; i < vline_n; i++) + free(vlines[i]); + free(vlines); + vlines = NULL; + vline_n = 0; + vline_cap = 0; + verbose_set(false); + verbose_set_sink(NULL, NULL); +} + +static void test_verbose_defaults_off(void) { + verbose_set(false); + CHECK(!verbose_enabled(), "verbose defaults off"); +} + +static void test_log_emits_json_line_when_enabled(void) { + vreset(); + verbose_set(true); + verbose_set_sink(vrecord, NULL); + vlog_kv kvs[] = { + VLOG_STR("name", "grep"), + VLOG_STR("pattern", "x"), + VLOG_INT("count", 42), + VLOG_BOOL("ok", true), + VLOG_DBL("ratio", 0.5), + }; + verbose_log("tool_call", kvs, 5); + CHECK(vline_n == 1, "exactly one line emitted"); + cJSON *v = vline_n ? cJSON_Parse(vlines[0]) : NULL; + CHECK(v != NULL, "emitted line is valid JSON"); + if (v) { + CHECK(strcmp(cJSON_GetObjectItem(v, "event")->valuestring, + "tool_call") == 0, + "event field"); + CHECK(strcmp(cJSON_GetObjectItem(v, "name")->valuestring, "grep") == 0, + "name field"); + CHECK(strcmp(cJSON_GetObjectItem(v, "pattern")->valuestring, "x") == 0, + "pattern field"); + CHECK(cJSON_GetObjectItem(v, "count")->valueint == 42, "count field"); + CHECK(cJSON_IsTrue(cJSON_GetObjectItem(v, "ok")), "ok field"); + CHECK(cJSON_GetObjectItem(v, "ratio")->valuedouble == 0.5, + "ratio field"); + cJSON_Delete(v); + } + /* no kv at all: just the event */ + vreset(); + verbose_set(true); + verbose_set_sink(vrecord, NULL); + verbose_log("turn_start", NULL, 0); + CHECK(vline_n == 1, "event-only line emitted"); + cJSON *v2 = vline_n ? cJSON_Parse(vlines[0]) : NULL; + CHECK(v2 != NULL && + strcmp(cJSON_GetObjectItem(v2, "event")->valuestring, + "turn_start") == 0, + "event-only payload"); + if (v2) + cJSON_Delete(v2); + vreset(); +} + +static void test_log_noop_when_disabled(void) { + vreset(); + verbose_set(false); + verbose_set_sink(vrecord, NULL); + vlog_kv kvs[] = {VLOG_STR("k", "v")}; + verbose_log("never", kvs, 1); + CHECK(vline_n == 0, "no output when disabled"); + vreset(); +} + +int main(void) { + test_defaults(); + test_set_get_roundtrip(); + test_allowlist_allows_matching_and_refuses_others(); + test_allowlist_entry_does_not_match_longer_word(); + test_denylist_wins_over_allowlist(); + test_allowlist_reason_string(); + test_no_allowlist_means_no_allowlist(); + test_gate_reason_wrapping(); + test_destructive_detection(); + test_gate_refuses_when_hook_declines(); + test_gate_allows_when_hook_approves(); + test_gate_no_hook_denies(); + test_gate_non_destructive_skips_hook(); + test_policy_null_reason(); + test_policy_checks_wrapped_commands(); + test_verbose_defaults_off(); + test_log_emits_json_line_when_enabled(); + test_log_noop_when_disabled(); + vreset(); + printf("test_limits: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} |