OpenCode-style TUI, model probing, sandboxed auto approval
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s

- 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
+82 -1
View File
@@ -6,6 +6,7 @@
#include "cst_agent.h"
#include "cst_metrics.h"
#include "cst_perms.h"
#include "cst_sandbox.h"
#include "cst_tools.h"
#include "cst_undo.h"
#include "cst_util.h"
@@ -240,6 +241,12 @@ char *cst_agent_build_prompt(const cst_agent_config_t *cfg, cst_protocol_t p,
}
if (!minimal && cst_protocol_is_ctl(p))
cst_buf_puts(&b, SEC_CTL_NUMBERS);
if (cfg && cfg->plan_mode)
cst_buf_puts(&b, "\nPLAN MODE: you may only read and research (no file "
"changes, no shell). Investigate, then answer with a "
"concrete step-by-step plan: files to touch, what to "
"change, how to verify. The user switches to Build mode "
"to carry it out.\n");
if (project_notes && project_notes[0]) {
size_t n = strlen(project_notes);
if (n > PROJECT_NOTES_MAX)
@@ -550,6 +557,62 @@ static int needs_confirm(const cst_agent_config_t *cfg, cst_tool_perm_t p) {
}
}
/* A sandboxed run_shell looked blocked: ask whether to rerun it outside the
* sandbox. On yes, *tr is replaced by the unsandboxed result; on no, a note
* tells the model why it failed. */
static void sandbox_escalate(turn_t *t, cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, const char *cmd,
cst_tool_result_t *tr) {
cst_agent_t *a = t->a;
int ans = CST_PERM_DENY;
if (t->hooks && t->hooks->on_permission && !a->cancel) {
size_t tail = tr->out_len > 1500 ? 1500 : tr->out_len;
cst_buf_t d = {0};
cst_buf_puts(&d, CST_PERM_SANDBOX);
if (tr->out)
cst_buf_append(&d, tr->out + tr->out_len - tail, tail);
char *detail = cst_buf_steal(&d);
hook_status(t, "blocked by the sandbox");
ans = t->hooks->on_permission(c->name, detail ? detail : CST_PERM_SANDBOX,
cmd, t->hooks->userdata);
free(detail);
}
if (ans == CST_PERM_DENY) {
const char *note = "\n[cst: blocked by the sandbox (writes are limited to "
"the workspace and temp dir, no network); the user did "
"not allow running it outside]\n";
cst_buf_t b = {0};
if (tr->out)
cst_buf_append(&b, tr->out, tr->out_len);
cst_buf_puts(&b, note);
free(tr->out);
tr->out_len = b.len;
tr->out = cst_buf_steal(&b);
return;
}
if (ans == CST_PERM_ALWAYS_PREFIX) {
char pre[128];
cst_perms_suggest_prefix(cmd, pre, sizeof(pre));
if (pre[0])
cst_perms_allow_prefix(a->perms, pre);
} else if (ans == CST_PERM_ALWAYS_TOOL) {
cst_perms_allow_tool(a->perms, c->name);
}
hook_status(t, "rerunning outside the sandbox");
cst_tool_result_free(tr);
memset(tr, 0, sizeof(*tr));
ctx->sandbox = 0;
cst_tool_run(ctx, c, tr);
cst_buf_t b = {0};
cst_buf_puts(&b, "[cst: the sandbox blocked this command; the user allowed "
"rerunning it outside the sandbox. Output of that rerun:]\n");
if (tr->out)
cst_buf_append(&b, tr->out, tr->out_len);
free(tr->out);
tr->out_len = b.len;
tr->out = cst_buf_steal(&b);
}
/* Execute (or refuse) one confirmed call. Irreversible once started. */
static void exec_call(turn_t *t, pcall_t *pc) {
if (pc->done)
@@ -602,11 +665,26 @@ static void exec_call(turn_t *t, pcall_t *pc) {
}
}
}
int allow = 1;
int allow = 1, ask = 0;
const char *cmd = def->perm == CST_TP_SHELL ? tool_cmd(c) : NULL;
if (!dup && needs_confirm(&a->cfg, def->perm) && !a->cfg.auto_approve &&
!cst_perms_tool_allowed(a->perms, c->name) &&
!(cmd && cst_perms_shell_allowed(a->perms, cmd))) {
ask = 1;
if (a->cfg.approval == CST_APPROVAL_AUTO) {
if (def->perm == CST_TP_WRITE) {
ask = 0;
} else if (def->perm == CST_TP_SHELL) {
if (cst_sandbox_probe() != CST_SANDBOX_NONE) {
ctx.sandbox = 1;
ask = 0;
} else if (cmd && cst_shell_is_safe(cmd)) {
ask = 0;
}
}
}
}
if (ask) {
if (t->hooks && t->hooks->on_permission) {
char *preview = NULL;
if (def->preview)
@@ -650,6 +728,9 @@ static void exec_call(turn_t *t, pcall_t *pc) {
undo_snapshot(a, c);
cst_tool_result_t tr = {0};
cst_tool_run(&ctx, c, &tr);
if (ctx.sandbox && cmd &&
cst_sandbox_blocked(tr.out, tr.out_len, tr.exit_code))
sandbox_escalate(t, &ctx, c, cmd, &tr);
set_output(pc, tr.out ? tr.out : "", tr.out_len, tr.exit_code);
cst_tool_result_free(&tr);
if (mutating)
+112
View File
@@ -698,3 +698,115 @@ finish:
free(body);
return ret;
}
/* --- model listing -------------------------------------------------------- */
#define MODELS_BODY_MAX (2 * 1024 * 1024)
static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
cst_buf_t *b = ud;
size_t n = size * nmemb;
if (b->len + n > MODELS_BODY_MAX || cst_buf_append(b, ptr, n) != 0)
return 0;
return n;
}
static int add_model(char out[][128], int n, int max_out, const char *id) {
if (!id || !id[0] || n >= max_out || strlen(id) >= 128)
return n;
for (int i = 0; i < n; i++)
if (strcmp(out[i], id) == 0)
return n;
snprintf(out[n], 128, "%s", id);
return n + 1;
}
/* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */
static int parse_models(const char *json, char out[][128], int max_out) {
cJSON *root = cJSON_Parse(json);
if (!root)
return -1;
const cJSON *arr = root;
if (!cJSON_IsArray(arr))
arr = cJSON_GetObjectItemCaseSensitive(root, "data");
if (!cJSON_IsArray(arr))
arr = cJSON_GetObjectItemCaseSensitive(root, "models");
int n = -1;
if (cJSON_IsArray(arr)) {
n = 0;
const cJSON *it;
cJSON_ArrayForEach(it, arr) {
const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id");
if (!cJSON_IsString(id))
id = cJSON_GetObjectItemCaseSensitive(it, "name");
if (cJSON_IsString(id))
n = add_model(out, n, max_out, id->valuestring);
else if (cJSON_IsString(it))
n = add_model(out, n, max_out, it->valuestring);
}
}
cJSON_Delete(root);
return n;
}
int cst_llm_list_models(const char *api_base, const char *api_key,
char out[][128], int max_out, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!api_base || !api_base[0] || !out || max_out <= 0) {
snprintf(err, err_sz, "bad request");
return -1;
}
CURL *curl = curl_easy_init();
if (!curl) {
snprintf(err, err_sz, "curl init failed");
return -1;
}
char url[1024];
size_t bl = strlen(api_base);
while (bl && api_base[bl - 1] == '/')
bl--;
snprintf(url, sizeof(url), "%.*s/models", (int)bl, api_base);
struct curl_slist *hdrs = curl_slist_append(NULL, "Accept: application/json");
char auth[700];
if (api_key && api_key[0]) {
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", api_key);
hdrs = curl_slist_append(hdrs, auth);
}
cst_buf_t body = {0};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, models_write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
CURLcode rc = curl_easy_perform(curl);
long http = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
memset(auth, 0, sizeof(auth));
int n = -1;
if (rc != CURLE_OK) {
snprintf(err, err_sz, "curl: %s", curl_easy_strerror(rc));
} else if (http == 401 || http == 403) {
snprintf(err, err_sz, "HTTP %ld: the API key was rejected", http);
n = CST_MODELS_AUTH;
} else if (http >= 400) {
snprintf(err, err_sz, "HTTP %ld from %s", http, url);
} else {
n = parse_models(body.buf ? body.buf : "", out, max_out);
if (n < 0)
snprintf(err, err_sz, "unexpected /models response");
}
cst_buf_free(&body);
return n;
}
+37 -4
View File
@@ -1,5 +1,6 @@
#include "cst_agent.h"
#include "cst_tools.h"
#include "cst_version.h"
#include "os_compat.h"
#include <curl/curl.h>
@@ -26,7 +27,9 @@ static void usage(const char *argv0) {
" -N, --new start a new session\n"
" -R, --resume ID open a session by id or unique prefix\n"
" -L, --list-sessions list sessions and exit\n"
" -y, --yolo never ask before shell / writes / web\n"
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
" the sandbox or by safe rules) | yolo\n"
" -y, --yolo same as --approval yolo (this run only)\n"
" -C, --confirm LIST shell,write,net (default) | none\n"
" -r, --protocol P native-eos (default) | native-inc | ctl |\n"
" ctl-heredoc | xml\n"
@@ -38,6 +41,7 @@ static void usage(const char *argv0) {
" -b, --api-base URL OpenAI-compatible base URL (…/v1)\n"
" -s, --session FILE explicit session file\n"
" -d, --demo-ctl local CTL parser demo (no API)\n"
" -V, --version print the version and exit\n"
"\n"
"API keys: the TUI asks for a missing key and saves it per provider\n"
" in the config dir (credentials, mode 0600); /key changes it.\n"
@@ -48,7 +52,8 @@ static void usage(const char *argv0) {
" CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n"
" CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n"
" CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n"
" CST_AUTO_APPROVE=1 CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
" CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n"
" CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n"
"TUI: type /help inside for commands and keys\n",
argv0);
}
@@ -235,6 +240,15 @@ int main(int argc, char **argv) {
return 1;
}
int approval = -1;
const char *env_appr = getenv("CST_APPROVAL");
if (env_appr && env_appr[0] && cst_approval_parse(env_appr, &approval) != 0) {
fprintf(stderr, "bad CST_APPROVAL=%s (ask | auto | yolo)\n", env_appr);
return 1;
}
if (cfg.auto_approve)
approval = CST_APPROVAL_YOLO;
int do_demo = 0;
int force_ask_key = 0;
int session_explicit = 0;
@@ -261,12 +275,14 @@ int main(int argc, char **argv) {
{"resume", required_argument, 0, 'R'},
{"list-sessions", no_argument, 0, 'L'},
{"yolo", no_argument, 0, 'y'},
{"approval", required_argument, 0, 'A'},
{"confirm", required_argument, 0, 'C'},
{"effort", required_argument, 0, 'e'},
{"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'},
{0, 0, 0, 0}};
int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahNcR:LyC:e:", opts, NULL)) !=
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
-1) {
switch (c) {
case 'w':
@@ -334,7 +350,14 @@ int main(int argc, char **argv) {
do_list = 1;
break;
case 'y':
cfg.auto_approve = 1;
approval = CST_APPROVAL_YOLO;
break;
case 'A':
if (cst_approval_parse(optarg, &approval) != 0) {
fprintf(stderr, "bad --approval %s (ask | auto | yolo)\n", optarg);
curl_global_cleanup();
return 1;
}
break;
case 'C':
if (parse_confirm(optarg, &cfg) != 0) {
@@ -356,6 +379,10 @@ int main(int argc, char **argv) {
usage(argv[0]);
curl_global_cleanup();
return 0;
case 'V':
printf("cst-agent %s\n", CST_VERSION);
curl_global_cleanup();
return 0;
default:
usage(argv[0]);
curl_global_cleanup();
@@ -425,6 +452,12 @@ int main(int argc, char **argv) {
while (alen > 0 && cfg.api_base[alen - 1] == '/')
cfg.api_base[--alen] = 0;
/* Option > environment > last picked (settings.json) > auto. */
if (approval < 0 && cst_approval_parse(saved.approval, &approval) != 0)
approval = CST_DEFAULT_APPROVAL;
cfg.approval = approval;
cfg.auto_approve = approval == CST_APPROVAL_YOLO;
/* Headless defaults to auto-approve shell/write (no interactive confirm). */
if (cfg.headless)
cfg.auto_approve = 1;
+29 -2
View File
@@ -588,9 +588,23 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
r->exit_code = (int)code;
return 0;
}
int os_run_capture_ex(const char *cwd, const char *const argv[],
size_t max_out, os_run_result_t *r,
os_child_hook_fn child_hook, void *ud) {
(void)child_hook;
(void)ud;
return os_run_capture(cwd, argv, max_out, r);
}
#else
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r) {
return os_run_capture_ex(cwd, argv, max_out, r, NULL, NULL);
}
int os_run_capture_ex(const char *cwd, const char *const argv[],
size_t max_out, os_run_result_t *r,
os_child_hook_fn child_hook, void *ud) {
if (!argv || !argv[0] || !r)
return -1;
if (max_out == 0)
@@ -619,6 +633,13 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
close(pipefd[1]);
if (cwd && cwd[0] && chdir(cwd) != 0)
_exit(127);
if (child_hook && child_hook(ud) != 0) {
static const char msg[] = "[cst: sandbox setup failed; command not "
"run]\n";
ssize_t w = write(STDERR_FILENO, msg, sizeof(msg) - 1);
(void)w;
_exit(126);
}
execvp(argv[0], (char *const *)argv);
_exit(127);
}
@@ -707,6 +728,12 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
os_run_result_t *r) {
return os_run_shell_ex(cwd, cmd, max_out, r, NULL, NULL);
}
int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out,
os_run_result_t *r, os_child_hook_fn child_hook,
void *ud) {
if (!cmd || !r)
return -1;
char shell[OS_PATH_MAX];
@@ -731,10 +758,10 @@ int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
if (is_busybox) {
const char *argv[] = {shell, "sh", "-c", cmd, NULL};
return os_run_capture(cwd, argv, max_out, r);
return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud);
} else {
const char *argv[] = {shell, "-c", cmd, NULL};
return os_run_capture(cwd, argv, max_out, r);
return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud);
}
}
+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;
}
+319
View File
@@ -0,0 +1,319 @@
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cst_sandbox.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__linux__) && !defined(__ANDROID__)
#define CST_HAVE_LANDLOCK 1
#include <fcntl.h>
#include <stdint.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
#elif defined(__APPLE__)
#define CST_HAVE_SEATBELT 1
#include <stdint.h>
#endif
static int g_kind = -1;
static int g_abi;
/* --- Linux: Landlock ------------------------------------------------------ */
#ifdef CST_HAVE_LANDLOCK
/* Defined here so older kernel headers still build. */
#ifndef __NR_landlock_create_ruleset
#define __NR_landlock_create_ruleset 444
#endif
#ifndef __NR_landlock_add_rule
#define __NR_landlock_add_rule 445
#endif
#ifndef __NR_landlock_restrict_self
#define __NR_landlock_restrict_self 446
#endif
#ifndef PR_SET_NO_NEW_PRIVS
#define PR_SET_NO_NEW_PRIVS 38
#endif
#define LL_CREATE_RULESET_VERSION (1U << 0)
#define LL_RULE_PATH_BENEATH 1
#define LL_FS_EXECUTE (1ULL << 0)
#define LL_FS_WRITE_FILE (1ULL << 1)
#define LL_FS_READ_FILE (1ULL << 2)
#define LL_FS_READ_DIR (1ULL << 3)
#define LL_FS_REMOVE_DIR (1ULL << 4)
#define LL_FS_REMOVE_FILE (1ULL << 5)
#define LL_FS_MAKE_CHAR (1ULL << 6)
#define LL_FS_MAKE_DIR (1ULL << 7)
#define LL_FS_MAKE_REG (1ULL << 8)
#define LL_FS_MAKE_SOCK (1ULL << 9)
#define LL_FS_MAKE_FIFO (1ULL << 10)
#define LL_FS_MAKE_BLOCK (1ULL << 11)
#define LL_FS_MAKE_SYM (1ULL << 12)
#define LL_FS_REFER (1ULL << 13) /* ABI 2 */
#define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */
#define LL_NET_BIND_TCP (1ULL << 0) /* ABI 4 */
#define LL_NET_CONNECT_TCP (1ULL << 1) /* ABI 4 */
struct ll_ruleset_attr {
uint64_t handled_access_fs;
uint64_t handled_access_net;
};
struct ll_path_beneath_attr {
uint64_t allowed_access;
int32_t parent_fd;
} __attribute__((packed));
static int ll_abi(void) {
long v = syscall(__NR_landlock_create_ruleset, NULL, 0,
LL_CREATE_RULESET_VERSION);
return v > 0 ? (int)v : 0;
}
/* Only write-type rights are handled, so reading and exec stay allowed
* everywhere; the rules grant them back beneath the writable roots. */
static uint64_t ll_write_rights(int abi) {
uint64_t r = LL_FS_WRITE_FILE | LL_FS_REMOVE_DIR | LL_FS_REMOVE_FILE |
LL_FS_MAKE_CHAR | LL_FS_MAKE_DIR | LL_FS_MAKE_REG |
LL_FS_MAKE_SOCK | LL_FS_MAKE_FIFO | LL_FS_MAKE_BLOCK |
LL_FS_MAKE_SYM;
if (abi >= 2)
r |= LL_FS_REFER;
if (abi >= 3)
r |= LL_FS_TRUNCATE;
return r;
}
static int ll_allow(int ruleset, const char *path, uint64_t rights,
int is_dir) {
int fd = open(path, O_PATH | O_CLOEXEC | (is_dir ? O_DIRECTORY : 0));
if (fd < 0)
return -1;
struct ll_path_beneath_attr pb;
pb.allowed_access = rights;
pb.parent_fd = fd;
long rc = syscall(__NR_landlock_add_rule, ruleset, LL_RULE_PATH_BENEATH, &pb,
0);
close(fd);
return rc == 0 ? 0 : -1;
}
static int ll_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
int abi = g_abi;
struct ll_ruleset_attr attr;
memset(&attr, 0, sizeof(attr));
attr.handled_access_fs = ll_write_rights(abi);
if (abi >= 4)
attr.handled_access_net = LL_NET_BIND_TCP | LL_NET_CONNECT_TCP;
int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0);
if (fd < 0)
return -1;
uint64_t dir_rights = attr.handled_access_fs;
uint64_t dev_rights = LL_FS_WRITE_FILE | (abi >= 3 ? LL_FS_TRUNCATE : 0);
if (ll_allow(fd, ws, dir_rights, 1) != 0) {
close(fd);
return -1;
}
if (tmp && tmp[0])
ll_allow(fd, tmp, dir_rights, 1); /* best effort: may not exist */
ll_allow(fd, "/dev/null", dev_rights, 0);
ll_allow(fd, "/dev/tty", dev_rights, 0);
p->ruleset_fd = fd;
return 0;
}
#endif /* CST_HAVE_LANDLOCK */
/* --- macOS: Seatbelt ------------------------------------------------------ */
#ifdef CST_HAVE_SEATBELT
/* Deprecated but still shipped; declared here to avoid the warning. */
extern int sandbox_init(const char *profile, uint64_t flags, char **errorbuf);
extern void sandbox_free_error(char *errorbuf);
static void sb_quote(char *out, size_t sz, const char *s) {
size_t o = 0;
for (; *s && o + 3 < sz; s++) {
if (*s == '"' || *s == '\\')
out[o++] = '\\';
out[o++] = *s;
}
out[o] = 0;
}
static int sb_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
char rws[OS_PATH_MAX], rtmp[OS_PATH_MAX];
char qws[2 * OS_PATH_MAX], qtmp[2 * OS_PATH_MAX];
if (os_realpath(ws, rws, sizeof(rws)) != 0)
return -1;
if (!tmp || os_realpath(tmp, rtmp, sizeof(rtmp)) != 0)
snprintf(rtmp, sizeof(rtmp), "/private/tmp");
sb_quote(qws, sizeof(qws), rws);
sb_quote(qtmp, sizeof(qtmp), rtmp);
size_t n = strlen(qws) + strlen(qtmp) + 512;
p->profile = malloc(n);
if (!p->profile)
return -1;
snprintf(p->profile, n,
"(version 1)(allow default)(deny file-write*)"
"(allow file-write* (subpath \"%s\") (subpath \"%s\") "
"(literal \"/dev/null\") (literal \"/dev/tty\") "
"(literal \"/dev/dtracehelper\"))"
"(deny network*)",
qws, qtmp);
return 0;
}
#endif /* CST_HAVE_SEATBELT */
/* --- common --------------------------------------------------------------- */
int cst_sandbox_probe(void) {
if (g_kind >= 0)
return g_kind;
g_kind = CST_SANDBOX_NONE;
const char *e = getenv("CST_SANDBOX");
if (e && (strcmp(e, "0") == 0 || strcmp(e, "off") == 0 ||
strcmp(e, "none") == 0))
return g_kind;
#ifdef CST_HAVE_LANDLOCK
g_abi = ll_abi();
if (g_abi > 0)
g_kind = CST_SANDBOX_LANDLOCK;
#elif defined(CST_HAVE_SEATBELT)
g_kind = CST_SANDBOX_SEATBELT;
#endif
return g_kind;
}
int cst_sandbox_landlock_abi(void) {
cst_sandbox_probe();
return g_abi;
}
const char *cst_sandbox_name(int kind) {
switch (kind) {
case CST_SANDBOX_LANDLOCK:
return "landlock";
case CST_SANDBOX_SEATBELT:
return "seatbelt";
default:
return "none";
}
}
int cst_sandbox_prepare(const char *workspace, const char *tmpdir,
cst_sandbox_plan_t *plan) {
memset(plan, 0, sizeof(*plan));
plan->ruleset_fd = -1;
int kind = cst_sandbox_probe();
int rc = -1;
#ifdef CST_HAVE_LANDLOCK
if (kind == CST_SANDBOX_LANDLOCK)
rc = ll_prepare(workspace, tmpdir, plan);
#endif
#ifdef CST_HAVE_SEATBELT
if (kind == CST_SANDBOX_SEATBELT)
rc = sb_prepare(workspace, tmpdir, plan);
#endif
(void)workspace;
(void)tmpdir;
plan->kind = rc == 0 ? kind : CST_SANDBOX_NONE;
return rc;
}
int cst_sandbox_enter(void *arg) {
const cst_sandbox_plan_t *p = arg;
if (!p)
return -1;
#ifdef CST_HAVE_LANDLOCK
if (p->kind == CST_SANDBOX_LANDLOCK) {
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0)
return -1;
return syscall(__NR_landlock_restrict_self, p->ruleset_fd, 0) == 0 ? 0
: -1;
}
#endif
#ifdef CST_HAVE_SEATBELT
if (p->kind == CST_SANDBOX_SEATBELT) {
char *err = NULL;
if (sandbox_init(p->profile, 0, &err) != 0) {
if (err)
sandbox_free_error(err);
return -1;
}
return 0;
}
#endif
return -1;
}
void cst_sandbox_release(cst_sandbox_plan_t *plan) {
if (!plan)
return;
#ifdef CST_HAVE_LANDLOCK
if (plan->ruleset_fd >= 0)
close(plan->ruleset_fd);
#endif
free(plan->profile);
plan->profile = NULL;
plan->ruleset_fd = -1;
plan->kind = CST_SANDBOX_NONE;
}
int cst_sandbox_run_shell(const char *workspace, const char *cmd,
size_t max_out, os_run_result_t *r) {
char tmp[OS_PATH_MAX];
if (os_temp_dir(tmp, sizeof(tmp)) != 0)
tmp[0] = 0;
cst_sandbox_plan_t plan;
if (cst_sandbox_prepare(workspace, tmp, &plan) != 0) {
memset(r, 0, sizeof(*r));
const char *msg = "[cst: sandbox unavailable; command not run]\n";
r->out = malloc(strlen(msg) + 1);
if (r->out) {
memcpy(r->out, msg, strlen(msg) + 1);
r->out_len = strlen(msg);
}
r->exit_code = 126;
return 0;
}
int rc = os_run_shell_ex(workspace, cmd, max_out, r, cst_sandbox_enter,
&plan);
cst_sandbox_release(&plan);
return rc;
}
static int has(const char *out, size_t n, const char *needle) {
size_t k = strlen(needle);
for (size_t i = 0; i + k <= n; i++)
if (memcmp(out + i, needle, k) == 0)
return 1;
return 0;
}
int cst_sandbox_blocked(const char *out, size_t n, int exit_code) {
if (exit_code == 0 || !out)
return 0;
static const char *const SIGNS[] = {
"Permission denied", "Read-only file system", "Operation not permitted",
"EACCES", "EPERM", "EROFS",
"[cst: sandbox", "Network is unreachable",
/* localized strerror(EACCES/EPERM/EROFS) */
"权限不够", "不允许的操作", "只读文件系统", "權限不足",
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
"Permission non accord", "Permiso denegado", "Отказано в доступе",
};
for (size_t i = 0; i < sizeof(SIGNS) / sizeof(SIGNS[0]); i++)
if (has(out, n, SIGNS[i]))
return 1;
return 0;
}
+27
View File
@@ -40,6 +40,8 @@ int cst_settings_load(cst_settings_t *s) {
cJSON_GetObjectItemCaseSensitive(root, "provider"));
copy_str(s->effort, sizeof(s->effort),
cJSON_GetObjectItemCaseSensitive(root, "effort"));
copy_str(s->approval, sizeof(s->approval),
cJSON_GetObjectItemCaseSensitive(root, "approval"));
const cJSON *models = cJSON_GetObjectItemCaseSensitive(root, "models");
const cJSON *m;
cJSON_ArrayForEach(m, models) {
@@ -69,6 +71,8 @@ int cst_settings_save(const cst_settings_t *s) {
cJSON_AddStringToObject(root, "provider", s->provider);
if (s->effort[0])
cJSON_AddStringToObject(root, "effort", s->effort);
if (s->approval[0])
cJSON_AddStringToObject(root, "approval", s->approval);
cJSON *models = cJSON_AddObjectToObject(root, "models");
for (int i = 0; i < s->nmodels; i++)
cJSON_AddStringToObject(models, s->models[i].provider, s->models[i].model);
@@ -93,6 +97,22 @@ int cst_settings_save(const cst_settings_t *s) {
return rc;
}
static const char *const APPROVAL_NAMES[] = {"ask", "auto", "yolo"};
const char *cst_approval_name(int a) {
return a >= 0 && a <= CST_APPROVAL_YOLO ? APPROVAL_NAMES[a] : "auto";
}
int cst_approval_parse(const char *s, int *out) {
for (int i = 0; s && i <= CST_APPROVAL_YOLO; i++) {
if (strcmp(s, APPROVAL_NAMES[i]) == 0) {
*out = i;
return 0;
}
}
return -1;
}
const char *cst_settings_model(const cst_settings_t *s, const char *provider) {
for (int i = 0; provider && i < s->nmodels; i++) {
if (strcmp(s->models[i].provider, provider) == 0 && s->models[i].model[0])
@@ -119,3 +139,10 @@ int cst_settings_remember(const cst_agent_config_t *cfg) {
snprintf(s.models[i].model, sizeof(s.models[i].model), "%s", cfg->model);
return cst_settings_save(&s);
}
int cst_settings_remember_approval(int approval) {
cst_settings_t s;
cst_settings_load(&s);
snprintf(s.approval, sizeof(s.approval), "%s", cst_approval_name(approval));
return cst_settings_save(&s);
}
+3 -1
View File
@@ -25,7 +25,7 @@ static int run_apply_patch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
}
static int run_shell(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
return cst_tool_run_shell(ctx->workspace_root, c, r);
return cst_tool_run_shell_ex(ctx->workspace_root, c, r, ctx->sandbox);
}
#define S "string"
@@ -193,6 +193,8 @@ int cst_tool_active(const cst_agent_config_t *cfg, const cst_tool_def_t *t) {
return 1;
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
return 0;
if (cfg->plan_mode && (t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
return 0;
int i = (int)(t - TOOLS);
return !(cfg->tools_off & (1ULL << i));
}
+11 -1
View File
@@ -10,6 +10,7 @@
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_sandbox.h"
#include "cst_util.h"
#include "os_compat.h"
#include "tools_internal.h"
@@ -288,6 +289,12 @@ int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r) {
return cst_tool_run_shell_ex(workspace_root, call, r, 0);
}
int cst_tool_run_shell_ex(const char *workspace_root,
const ctl_tool_call_t *call, cst_tool_result_t *r,
int sandboxed) {
const char *cmd = tool_arg(call, "cmd");
if (!cmd)
cmd = tool_arg(call, "command");
@@ -299,7 +306,10 @@ int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
return tool_set_result(r, "error: bad workspace", 1);
os_run_result_t rr = {0};
if (os_run_shell_c(root_real, cmd, SHELL_OUT_MAX, &rr) != 0) {
int rc = sandboxed
? cst_sandbox_run_shell(root_real, cmd, SHELL_OUT_MAX, &rr)
: os_run_shell_c(root_real, cmd, SHELL_OUT_MAX, &rr);
if (rc != 0) {
os_run_result_free(&rr);
return tool_set_result(r, "error: shell exec failed", 1);
}
+1459 -324
View File
File diff suppressed because it is too large Load Diff