/* Persistent "always allow" rules: /.agent/permissions.json * {"tools": ["write_file"], "shell_prefixes": ["make", "git status"]} */ #ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif #include #include #include #include #include "cJSON.h" #include "cst_perms.h" #include "cst_util.h" #include "os_compat.h" #define PERM_MAX 64 struct cst_perms { char path[OS_PATH_MAX]; char tools[PERM_MAX][64]; int ntools; char prefixes[PERM_MAX][128]; int nprefixes; }; static void load(cst_perms_t *p) { FILE *fp = fopen(p->path, "rb"); if (!fp) return; cst_buf_t b = {0}; char tmp[4096]; size_t n; while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0 && b.len < 256 * 1024) cst_buf_append(&b, tmp, n); fclose(fp); cJSON *root = b.buf ? cJSON_Parse(b.buf) : NULL; cst_buf_free(&b); const cJSON *it = NULL; cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(root, "tools")) { if (cJSON_IsString(it) && p->ntools < PERM_MAX) snprintf(p->tools[p->ntools++], 64, "%.63s", it->valuestring); } cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(root, "shell_prefixes")) { if (cJSON_IsString(it) && p->nprefixes < PERM_MAX && it->valuestring[0]) snprintf(p->prefixes[p->nprefixes++], 128, "%.127s", it->valuestring); } cJSON_Delete(root); } cst_perms_t *cst_perms_open(const char *workspace) { cst_perms_t *p = calloc(1, sizeof(*p)); if (!p) return NULL; if (workspace) snprintf(p->path, sizeof(p->path), "%s/.agent/permissions.json", workspace); load(p); return p; } void cst_perms_close(cst_perms_t *p) { free(p); } static int save(const cst_perms_t *p) { if (!p->path[0]) return -1; char dir[OS_PATH_MAX]; snprintf(dir, sizeof(dir), "%s", p->path); char *sl = strrchr(dir, '/'); if (sl) { *sl = 0; os_mkdir_p(dir, 0755); } cJSON *root = cJSON_CreateObject(); cJSON *t = cJSON_AddArrayToObject(root, "tools"); for (int i = 0; i < p->ntools; i++) cJSON_AddItemToArray(t, cJSON_CreateString(p->tools[i])); cJSON *s = cJSON_AddArrayToObject(root, "shell_prefixes"); for (int i = 0; i < p->nprefixes; i++) cJSON_AddItemToArray(s, cJSON_CreateString(p->prefixes[i])); char *txt = cJSON_Print(root); cJSON_Delete(root); if (!txt) return -1; FILE *fp = fopen(p->path, "wb"); int rc = -1; if (fp) { rc = fputs(txt, fp) >= 0 && fputs("\n", fp) >= 0 ? 0 : -1; if (fclose(fp) != 0) rc = -1; } free(txt); return rc; } /* Commands chained or substituted can hide anything behind an allowed * prefix, so they always ask. */ static int has_shell_meta(const char *cmd) { return strpbrk(cmd, ";&|`$<>\n\r(){}") != NULL; } int cst_perms_shell_allowed(const cst_perms_t *p, const char *cmd) { if (!p || !cmd) return 0; while (*cmd == ' ' || *cmd == '\t') cmd++; if (!*cmd || has_shell_meta(cmd)) return 0; for (int i = 0; i < p->nprefixes; i++) { size_t n = strlen(p->prefixes[i]); if (strncmp(cmd, p->prefixes[i], n) == 0 && (cmd[n] == 0 || cmd[n] == ' ' || cmd[n] == '\t')) return 1; } return 0; } int cst_perms_tool_allowed(const cst_perms_t *p, const char *tool) { if (!p || !tool) return 0; for (int i = 0; i < p->ntools; i++) { if (strcmp(p->tools[i], tool) == 0) return 1; } return 0; } int cst_perms_allow_tool(cst_perms_t *p, const char *tool) { if (!p || !tool || !tool[0]) return -1; if (cst_perms_tool_allowed(p, tool)) return 0; if (p->ntools >= PERM_MAX) return -1; snprintf(p->tools[p->ntools++], 64, "%.63s", tool); return save(p); } int cst_perms_allow_prefix(cst_perms_t *p, const char *prefix) { if (!p || !prefix) return -1; while (*prefix == ' ') prefix++; if (!prefix[0] || has_shell_meta(prefix)) return -1; for (int i = 0; i < p->nprefixes; i++) { if (strcmp(p->prefixes[i], prefix) == 0) return 0; } if (p->nprefixes >= PERM_MAX) return -1; snprintf(p->prefixes[p->nprefixes++], 128, "%.127s", prefix); return save(p); } int cst_perms_reset(cst_perms_t *p) { if (!p) return -1; p->ntools = p->nprefixes = 0; return save(p); } char *cst_perms_describe(const cst_perms_t *p) { cst_buf_t b = {0}; cst_buf_puts(&b, "always allowed tools: "); if (!p || !p->ntools) cst_buf_puts(&b, "(none)"); for (int i = 0; p && i < p->ntools; i++) cst_buf_printf(&b, "%s%s", i ? ", " : "", p->tools[i]); cst_buf_puts(&b, "\nallowed shell prefixes: "); if (!p || !p->nprefixes) cst_buf_puts(&b, "(none)"); for (int i = 0; p && i < p->nprefixes; i++) cst_buf_printf(&b, "%s`%s`", i ? ", " : "", p->prefixes[i]); cst_buf_puts(&b, "\n"); return cst_buf_steal(&b); } /* "git status -s" → "git status"; "make -j8" → "make"; "./run.sh x" → * "./run.sh". Tools with sub-commands keep two words. */ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz) { static const char *const two_word[] = {"git", "npm", "pnpm", "yarn", "cargo", "go", "docker", "kubectl", "pip", "python", "python3", "uv", "bun", "dotnet", "mvn", "gradle"}; /* A prefix rule for these would allow almost anything. */ static const char *const never[] = { "sudo", "su", "doas", "rm", "dd", "mkfs", "shutdown", "reboot", "sh", "bash", "zsh", "fish", "dash", "env", "xargs", "eval", "exec", "nohup", "time", "nice", "timeout", "find", "chmod", "chown", "curl", "wget", "ssh", "scp", "node", "perl", "ruby", "busybox"}; out[0] = 0; if (!cmd) return; while (*cmd == ' ' || *cmd == '\t') cmd++; size_t w1 = strcspn(cmd, " \t"); if (w1 == 0) return; for (size_t i = 0; i < sizeof(never) / sizeof(never[0]); i++) { if (strlen(never[i]) == w1 && strncmp(cmd, never[i], w1) == 0) return; } size_t take = w1; for (size_t i = 0; i < sizeof(two_word) / sizeof(two_word[0]); i++) { if (strlen(two_word[i]) == w1 && strncmp(cmd, two_word[i], w1) == 0) { const char *r = cmd + w1; while (*r == ' ' || *r == '\t') r++; size_t w2 = strcspn(r, " \t"); if (w2 == 0 || r[0] == '-') return; /* "python3 -c ..." / bare "git": no safe rule */ take = (size_t)(r - cmd) + w2; break; } } if (take >= out_sz) take = out_sz - 1; memcpy(out, cmd, take); out[take] = 0; if (has_shell_meta(out)) out[0] = 0; }