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.
224 lines
6.4 KiB
C
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;
|
|
}
|