Files
CST-agent/src/permissions.c
T
WHC 2fde898da0 Usability pass: tool registry, TUI polish, saved keys and settings
Tools
- Table-driven tool registry with permission classes and default/minimal
  profiles; new tools: edit_file, list_dir, glob, grep, delete_file,
  move_file, todo_write, web_fetch, ask_user (plus read_file paging).
- Built-in Myers diff; write tools return unified diffs.
- Persistent permission rules (.agent/permissions.json), per-turn undo
  snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80%
  of the model context, cancellable run_shell with process-group kill.

TUI
- Table-driven slash commands: /provider /key /effort /status /tools
  /permissions /yolo /undo /retry /diff /export /copy /rename /delete
  /init /max-steps /todo, with / and @ completion.
- Permission modal with diff and prefix rules, ask_user modal, todo
  panel, header with model/effort/context, bracketed paste, line editing,
  wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits.

Startup and configuration
- API keys are entered in the TUI (hidden) and saved per provider in
  <config>/credentials (0600); env variables are only a fallback.
- Last provider, per-provider model and reasoning effort are remembered
  in <config>/settings.json; --effort/-e and /effort send
  reasoning_effort (low|medium|high|max) to models that accept it.
- DeepSeek preset uses deepseek-flash / deepseek-v4-pro.
- Session flags --new/--continue/--resume/--list-sessions, --yolo,
  --confirm.

Compatibility and tests
- ncurses detection, macOS/Homebrew support, make install, XDG config
  dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS).
- Unit tests for diff, fs tools, registry, undo/perms and config;
  e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
2026-10-05 01:47:01 +08:00

224 lines
6.4 KiB
C

/* Persistent "always allow" rules: <ws>/.agent/permissions.json
* {"tools": ["write_file"], "shell_prefixes": ["make", "git status"]} */
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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;
}