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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user