#include "limits.h" #include "verbose.h" #include #include #include #include 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; }