OpenCode-style TUI, model probing, sandboxed auto approval

- TUI: centered home screen with logo, boxed input with mode line, sidebar
  (>=120 cols), Ctrl+P palette, Tab Build/Plan, Shift+Tab approval cycle,
  mouse (cards, cursor, pickers, wheel), /sidebar /mouse /approval
- /models probing: key check on entry (401 re-asks), API models in /model
- Shell sandbox in C: Linux Landlock (writes limited to workspace/tmp,
  ABI>=4 blocks TCP), macOS sandbox_init, rules fallback elsewhere
- Approval modes ask/auto/yolo (default auto); read-only command whitelist;
  ask to rerun outside the sandbox when a command looks blocked
- Upgradable bootstrap scripts (git pull --ff-only, rebuild, reinstall) and
  cst-agent-update; cst-agent --version
- tests: test_sandbox, test_models
This commit is contained in:
WHC
2026-10-05 12:34:46 +08:00
parent a77f27ed59
commit ef63f6d612
25 changed files with 3021 additions and 405 deletions
+226
View File
@@ -221,3 +221,229 @@ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz) {
if (has_shell_meta(out))
out[0] = 0;
}
/* --- read-only command rules ---------------------------------------------- */
#define SAFE_MAX_WORDS 64
#define SAFE_WORD_MAX 256
typedef struct {
char w[SAFE_MAX_WORDS][SAFE_WORD_MAX];
int n;
} seg_t;
static int word_in(const char *w, const char *const *list, size_t n) {
for (size_t i = 0; i < n; i++)
if (strcmp(w, list[i]) == 0)
return 1;
return 0;
}
static int has_prefix(const char *s, const char *p) {
return strncmp(s, p, strlen(p)) == 0;
}
static int git_safe(const seg_t *s) {
static const char *const SUB[] = {"status", "diff", "log", "show", "branch"};
static const char *const BRANCH_OK[] = {
"-a", "-r", "-v", "-vv", "-l",
"--list", "--all", "--remotes", "--verbose", "--show-current",
"--merged", "--no-merged", "--contains", "--no-contains",
"--no-color", "--color"};
int i = 1;
while (i < s->n && strcmp(s->w[i], "--no-pager") == 0)
i++;
if (i >= s->n || !word_in(s->w[i], SUB, sizeof(SUB) / sizeof(SUB[0])))
return 0;
int branch = strcmp(s->w[i], "branch") == 0, listing = 0;
for (int j = i + 1; j < s->n; j++) {
const char *a = s->w[j];
if (has_prefix(a, "--output") || has_prefix(a, "--ext-diff") ||
has_prefix(a, "--textconv"))
return 0;
if (!branch)
continue;
if (a[0] == '-') {
if (!word_in(a, BRANCH_OK, sizeof(BRANCH_OK) / sizeof(BRANCH_OK[0])) &&
!has_prefix(a, "--sort=") && !has_prefix(a, "--format="))
return 0;
if (strcmp(a, "-l") == 0 || strcmp(a, "--list") == 0 ||
strcmp(a, "-a") == 0 || strcmp(a, "-r") == 0 ||
strcmp(a, "--all") == 0 || strcmp(a, "--remotes") == 0 ||
has_prefix(a, "--contains") || has_prefix(a, "--merged") ||
has_prefix(a, "--no-merged") || has_prefix(a, "--no-contains"))
listing = 1;
} else if (!listing) {
return 0; /* "git branch NAME" creates a branch */
}
}
return 1;
}
static int seg_safe(const seg_t *s) {
static const char *const CMDS[] = {
"ls", "cat", "head", "tail", "wc", "grep", "rg",
"find", "git", "pwd", "echo", "which", "file", "stat",
"du", "tree", "true", "false"};
static const char *const FIND_BAD[] = {"-exec", "-execdir", "-delete",
"-ok", "-okdir", "-fprint",
"-fprint0", "-fprintf", "-fls"};
if (s->n == 0)
return 0;
const char *c = s->w[0];
if (!word_in(c, CMDS, sizeof(CMDS) / sizeof(CMDS[0])))
return 0;
if (strcmp(c, "git") == 0)
return git_safe(s);
for (int i = 1; i < s->n; i++) {
const char *a = s->w[i];
if (strcmp(c, "find") == 0 &&
word_in(a, FIND_BAD, sizeof(FIND_BAD) / sizeof(FIND_BAD[0])))
return 0;
if (strcmp(c, "rg") == 0 && has_prefix(a, "--pre"))
return 0;
if (strcmp(c, "tree") == 0 && strcmp(a, "-o") == 0)
return 0;
if (strcmp(c, "file") == 0 && (strcmp(a, "-C") == 0 ||
strcmp(a, "--compile") == 0))
return 0;
}
return 1;
}
/* Accepts "2>&1", ">&2", ">/dev/null", "2>/dev/null", "&>/dev/null".
* *pp points at '>'; on success it is advanced past the target. */
static int redirect_ok(const char **pp) {
const char *p = *pp + 1;
if (*p == '>')
return 0; /* append */
while (*p == ' ' || *p == '\t')
p++;
if (*p == '&' && (p[1] == '1' || p[1] == '2')) {
p += 2;
} else if (strncmp(p, "/dev/null", 9) == 0) {
p += 9;
} else {
return 0;
}
if (*p && !strchr(" \t;|&", *p))
return 0;
*pp = p;
return 1;
}
static int seg_push_word(seg_t *s, char *word, size_t *wl, int *in_word) {
if (!*in_word)
return 1;
if (s->n >= SAFE_MAX_WORDS)
return 0;
word[*wl] = 0;
memcpy(s->w[s->n++], word, *wl + 1);
*wl = 0;
*in_word = 0;
return 1;
}
int cst_shell_is_safe(const char *cmd) {
if (!cmd)
return 0;
if (strstr(cmd, "$(") || strchr(cmd, '`') || strstr(cmd, "<(") ||
strstr(cmd, ">(") || strchr(cmd, '\n') || strchr(cmd, '\r'))
return 0;
seg_t *s = calloc(1, sizeof(*s));
if (!s)
return 0;
char word[SAFE_WORD_MAX];
size_t wl = 0;
int in_word = 0, nseg = 0, ok = 1;
char q = 0;
for (const char *p = cmd;; p++) {
char ch = *p;
if (q) {
if (ch == 0) {
ok = 0;
break;
}
if (ch == q) {
q = 0;
continue;
}
if (q == '"' && ch == '\\' && p[1]) {
ch = *++p;
}
if (wl + 1 >= sizeof(word)) {
ok = 0;
break;
}
word[wl++] = ch;
continue;
}
if (ch == '\'' || ch == '"') {
q = ch;
in_word = 1;
continue;
}
if (ch == '\\' && p[1]) {
if (wl + 1 >= sizeof(word)) {
ok = 0;
break;
}
word[wl++] = *++p;
in_word = 1;
continue;
}
int sep = ch == 0 || ch == ';' || ch == '|' ||
(ch == '&' && p[1] == '&');
if (ch == '>' || (ch == '&' && p[1] == '>') ||
((ch >= '0' && ch <= '9') && !in_word && p[1] == '>')) {
if (ch != '>')
p++;
if (!redirect_ok(&p)) {
ok = 0;
break;
}
p--;
continue;
}
if (ch == '&' && !sep) {
ok = 0; /* background job */
break;
}
if (ch == '<' && p[1] == '<') {
ok = 0; /* heredoc */
break;
}
if (sep || ch == ' ' || ch == '\t' || ch == '<') {
if (!seg_push_word(s, word, &wl, &in_word)) {
ok = 0;
break;
}
if (sep) {
if (s->n > 0) {
if (!seg_safe(s)) {
ok = 0;
break;
}
nseg++;
} else if (ch == '|' || ch == '&') {
ok = 0; /* "| x" or "a && && b" */
break;
}
s->n = 0;
if (ch == 0)
break;
if ((ch == '|' && p[1] == '|') || ch == '&')
p++;
}
continue;
}
if (wl + 1 >= sizeof(word)) {
ok = 0;
break;
}
word[wl++] = ch;
in_word = 1;
}
free(s);
return ok && nseg > 0;
}