summaryrefslogtreecommitdiffstats
path: root/tests/test_limits.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_limits.cpp')
-rw-r--r--tests/test_limits.cpp379
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;
+}