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
+2 -1
View File
@@ -50,6 +50,7 @@ set(CST_CORE_SOURCES
src/ctl_parse.c
src/os_compat.c
src/sandbox.c
src/sandbox_exec.c
src/session.c
src/tools/tools.c
src/tools/patch.c
@@ -166,7 +167,7 @@ endforeach()
# Tests that link the whole core (no TUI).
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip
test_shell test_diff test_tools_fs test_registry
test_undo test_config)
test_undo test_config test_models test_sandbox)
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
+4 -1
View File
@@ -56,6 +56,7 @@ CORE_SRC = \
src/ctl_parse.c \
src/os_compat.c \
src/sandbox.c \
src/sandbox_exec.c \
src/session.c \
src/tools/tools.c \
src/tools/patch.c \
@@ -96,7 +97,9 @@ TESTS = \
tests/test_tools_fs \
tests/test_registry \
tests/test_undo \
tests/test_config
tests/test_config \
tests/test_models \
tests/test_sandbox
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS)
+47 -12
View File
@@ -13,19 +13,23 @@ See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
### 5 分钟试用
```bash
# Linux 一键:克隆并编译
git clone https://git.whcself.xyz/WHC/CST-agent.git && ./CST-agent/scripts/bootstrap_linux.sh ./CST-agent
# Linux / WSL / macOS 一键:克隆到 ~/CST-agent、编译、装到 ~/.local/bin
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash
# 已有仓库目录内:
# 已有仓库目录内(装到别处:CST_AGENT_PREFIX=/usr/local)
./scripts/bootstrap_linux.sh .
# 装到 PATH(或 sudo make install)
make install PREFIX=$HOME/.local
# 以后升级:拉取最新代码、重新编译并安装(本地有未提交改动时跳过 pull)
cst-agent-update
cst-agent --version
# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)
cd ~/my-project && cst-agent
```
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
@@ -118,9 +122,12 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
### Termux
```bash
# 一键:装依赖、克隆、编译、安装到 $PREFIX/bin
# 一键:装依赖、克隆到 ~/CST-agent、编译、安装到 $PREFIX/bin
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_termux.sh | bash
# 以后升级(重跑上面那条也一样)
cst-agent-update
# 或手动
pkg install git clang make pkg-config binutils libcurl ncurses
git clone https://git.whcself.xyz/WHC/CST-agent.git && cd CST-agent
@@ -185,7 +192,8 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 |
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准、shell、旁路 busybox |
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off` | 审批模式 / 关闭沙箱(回落到只读白名单规则) |
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
## Run
@@ -211,7 +219,8 @@ cst-agent --headless --prompt 'List files'
| `-N` / `--new` | 开新会话 |
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
| `-y` / `--yolo` | 本次运行不再确认(等同 `--confirm none`) |
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
@@ -243,7 +252,28 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
### 权限、撤销与项目说明
- **确认**:默认 shell、写文件、联网三类工具需要确认。弹窗会显示命令或写入的 diff:
- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`):
| 模式 | 写文件 | run_shell | web_fetch |
|---|---|---|---|
| `ask` | 询问 | 询问 | 询问 |
| `auto`(默认) | 直接执行 | 有沙箱 → 在沙箱里直接执行;没有沙箱 → 只读白名单命令直接执行,其余询问 | 询问 |
| `yolo` | 直接执行 | 直接执行(不进沙箱) | 直接执行 |
已保存的规则(`a` / `p`)优先:匹配的命令不进沙箱直接运行。
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
- **沙箱**(C 实现,`src/sandbox_exec.c`):
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。
- Android / Termux、Windows、老内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱。
- 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。
- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree`
和 `git status/diff/log/show/branch`;可以用 `|` `&&` `||` `;` 串联,但每段都必须在白名单里。
`find -exec/-delete`、`rg --pre`、`git branch NAME`、`>` 重定向(`2>&1`、`>/dev/null` 除外)、`$(…)`、反引号、
`<(…)`、`sudo`、`rm` 一律不算安全。
- **确认弹窗**会显示命令或写入的 diff:
`y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。
`a` / `p` 写入 `<workspace>/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
@@ -260,9 +290,12 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F | 移动光标(按字 / 按词,支持中文宽字符) |
| Ctrl+W / Ctrl+U / Ctrl+K / Alt+Backspace / Delete | 删词 / 删到行首 / 删到行尾 |
| ↑ / ↓ | 提示历史(多行输入时先在行间移动) |
| Tab | 补全 `/命令` 和 `@文件`;空输入时展开/折叠 Thought |
| Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) |
| Shift+Tab | 循环切换审批模式 ask → auto → yolo |
| Ctrl+P | 命令面板(输入过滤,↑↓ / 鼠标选择) |
| Ctrl+O / Ctrl+T | 展开最近 tool 输出 / 开关任务面板 |
| PgUp / PgDn / 鼠标滚轮 | 滚动对话 |
| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中(`/mouse` 关闭后可用终端原生选择复制) |
| Esc | 取消当前 turn(不会退出) |
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
@@ -274,11 +307,13 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|---|---|
| `/help` * | 命令与按键 |
| `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 |
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入)、模型、推理强度;都会记住 |
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
| `/protocol [name]` | 切换工具调用协议 |
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
| `/permissions [reset]` *、`/yolo` *、`/max-steps [N]` * | 权限规则、本次免确认、每轮步数 |
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
| `/sidebar` *、`/mouse` * | 开关右侧边栏(≥120 列时显示)、开关鼠标 |
| `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 |
| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 |
| `/diff [path]` * | `git status` + `git diff` |
+28 -1
View File
@@ -32,11 +32,32 @@ the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
| What | Where |
|---|---|
| API keys (one per provider, entered in the TUI or with `--ask-key`) | `$XDG_CONFIG_HOME/cst-agent/credentials` (default `~/.config/cst-agent/`), mode 0600 |
| Last provider, models and effort | `$XDG_CONFIG_HOME/cst-agent/settings.json` |
| Last provider, models, effort and approval mode | `$XDG_CONFIG_HOME/cst-agent/settings.json` |
| Sessions, undo snapshots, exports | `<workspace>/.agent/` (git-ignored automatically) |
| Saved permission rules | `<workspace>/.agent/permissions.json` |
| Project notes injected into the prompt | `<workspace>/AGENTS.md` (or `CST.md`) |
## Shell sandbox
In the default `auto` approval mode, `run_shell` runs inside an OS sandbox
when one is available. Commands may read and execute anything but may only
write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
| Platform | Sandbox | Notes |
|---|---|---|
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind |
| macOS | `sandbox_init` profile | also denies network |
| Android / Termux | none | falls back to the read-only rules |
| WSL 2 | Landlock when the WSL kernel enables it | otherwise rules |
| Older kernels, Windows | none | falls back to the read-only rules |
The status bar and sidebar show `auto · landlock`, `auto · seatbelt` or
`auto · rules`. `CST_SANDBOX=off` disables the sandbox. When a sandboxed
command looks blocked, the TUI asks whether to rerun it outside the sandbox.
The ruleset is built in the parent; the forked child only calls `prctl` and
`landlock_restrict_self` (async-signal-safe), so the agent itself is never
confined.
## Terminal notes
- UTF-8 locale gives the full UI. Under a non-UTF-8 locale, or with
@@ -80,6 +101,12 @@ TUI:
- [ ] Todo panel appears while tasks are open; Ctrl+T hides it
- [ ] `/undo` restores files changed by the last turn; `/retry` re-asks
- [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work
- [ ] Mouse: click a tool card to expand it, click in the input to move the
cursor, click a picker row; `/mouse` turns it off
- [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette
- [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked
- [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the
sandbox?" (Linux 5.13+/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks
- [ ] Resize the terminal during streaming: layout recovers
- [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens
+3 -1
View File
@@ -76,7 +76,9 @@ export OPENAI_MODEL='your-model'
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL |
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(headless 默认开启) |
| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(= `--approval yolo`;headless 默认开启) |
| `CST_APPROVAL` | 审批模式 `ask` / `auto`(默认)/ `yolo` |
| `CST_SANDBOX=off` | 关闭 run_shell 沙箱(Landlock / macOS sandbox),`auto` 回落到只读白名单 |
| `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) |
| `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) |
+32
View File
@@ -73,10 +73,23 @@ typedef struct {
int confirm_write; /* confirm file-changing tools */
int confirm_net; /* confirm web_fetch */
int auto_approve; /* headless / CST_AUTO_APPROVE=1 / --yolo */
int approval; /* cst_approval_t (interactive sessions) */
int plan_mode; /* read-only tools + planning note */
int tool_profile; /* cst_tool_profile_t (CST_TOOLS) */
unsigned long long tools_off; /* bit i = registry tool i disabled */
} cst_agent_config_t;
/* ask: confirm per tool; auto: sandbox/rules decide, ask on doubt; yolo: never
* ask (same as auto_approve). */
typedef enum {
CST_APPROVAL_ASK = 0,
CST_APPROVAL_AUTO,
CST_APPROVAL_YOLO
} cst_approval_t;
#define CST_DEFAULT_APPROVAL CST_APPROVAL_AUTO
const char *cst_approval_name(int a);
int cst_approval_parse(const char *s, int *out);
typedef struct cst_session cst_session_t;
typedef struct cst_agent cst_agent_t;
@@ -143,6 +156,10 @@ int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
/* sandboxed: confine the command with the OS sandbox (cst_sandbox.h). */
int cst_tool_run_shell_ex(const char *workspace_root,
const ctl_tool_call_t *call, cst_tool_result_t *r,
int sandboxed);
int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
@@ -206,6 +223,14 @@ typedef struct {
} cst_llm_request_t;
int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz);
/* GET {api_base}/models (10 s timeout). Returns the number of model ids
* written, CST_MODELS_AUTH when the key is rejected (401/403), or -1 on
* network / format errors. */
#define CST_MODELS_AUTH (-2)
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);
/* JSON request body (malloc'd) exactly as sent on the wire. */
char *cst_llm_build_body(const cst_llm_request_t *req);
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
@@ -216,6 +241,10 @@ void cst_llm_trace_reset(void);
#define CST_PERM_ONCE 1
#define CST_PERM_ALWAYS_TOOL 2 /* saved to .agent/permissions.json */
#define CST_PERM_ALWAYS_PREFIX 3 /* run_shell: allow this command prefix */
/* on_permission detail prefix when a sandboxed run_shell looked blocked and
* the question is whether to rerun it outside the sandbox. The rest of the
* detail is the tail of the sandboxed output. */
#define CST_PERM_SANDBOX "[sandbox] "
/* Live UI / observability hooks for a turn */
typedef struct {
@@ -297,6 +326,7 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
typedef struct {
char provider[32];
char effort[16];
char approval[8]; /* "" = not chosen yet */
int nmodels;
struct {
char provider[32];
@@ -308,6 +338,8 @@ int cst_settings_save(const cst_settings_t *s);
const char *cst_settings_model(const cst_settings_t *s, const char *provider);
/* Record cfg's provider, model and effort as the last used ones. */
int cst_settings_remember(const cst_agent_config_t *cfg);
/* Record an approval mode the user picked (CST_APPROVAL_*). */
int cst_settings_remember_approval(int approval);
/* JSON baseline parser for experiments (buffer until valid object). */
typedef struct {
+3
View File
@@ -23,6 +23,9 @@ int cst_perms_reset(cst_perms_t *p);
char *cst_perms_describe(const cst_perms_t *p);
/* Reasonable prefix rule for a command ("" when it should not have one). */
void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz);
/* 1 when every segment of cmd (split on | || && ;) is a read-only command
* from a fixed whitelist, with no output redirection or substitution. */
int cst_shell_is_safe(const char *cmd);
#ifdef __cplusplus
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef CST_SANDBOX_H
#define CST_SANDBOX_H
/*
* OS sandbox for run_shell: reads and exec stay allowed everywhere, writes
* are limited to the workspace, the temp dir, /dev/null and /dev/tty, and
* the network is cut where the OS can do it.
* Linux Landlock (network needs ABI 4, kernel 6.7+)
* macOS sandbox_init (Seatbelt profile)
* other none: callers fall back to rule-based approval
*/
#include "os_compat.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
CST_SANDBOX_NONE = 0,
CST_SANDBOX_LANDLOCK,
CST_SANDBOX_SEATBELT
} cst_sandbox_kind_t;
/* What this process can use; cached. CST_SANDBOX=0 forces NONE. */
int cst_sandbox_probe(void);
const char *cst_sandbox_name(int kind); /* "landlock" | "seatbelt" | "none" */
/* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */
int cst_sandbox_landlock_abi(void);
typedef struct {
int kind;
int ruleset_fd; /* Landlock, built in the parent */
char *profile; /* Seatbelt */
} cst_sandbox_plan_t;
/* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */
int cst_sandbox_prepare(const char *workspace, const char *tmpdir,
cst_sandbox_plan_t *plan);
/* Child side, after fork and before exec (os_run_capture_ex hook):
* async-signal-safe for Landlock. Returns 0 when confined. */
int cst_sandbox_enter(void *plan);
void cst_sandbox_release(cst_sandbox_plan_t *plan);
/* `sh -c cmd` confined to workspace (+ temp dir). Fails closed: if the
* policy cannot be applied the command does not run (exit 126). */
int cst_sandbox_run_shell(const char *workspace, const char *cmd,
size_t max_out, os_run_result_t *r);
/* Output of a failed sandboxed run that looks like the sandbox got in the
* way (EACCES / EPERM / EROFS / blocked network). */
int cst_sandbox_blocked(const char *out, size_t n, int exit_code);
#ifdef __cplusplus
}
#endif
#endif
+1
View File
@@ -40,6 +40,7 @@ typedef struct cst_tool_ctx {
/* todo_write: the list was replaced (text form, one item per line). */
void (*set_todo)(const char *text, void *ud);
void *todo_ud;
int sandbox; /* run_shell: confine to the workspace (cst_sandbox.h) */
} cst_tool_ctx_t;
typedef int (*cst_tool_run_fn)(const cst_tool_ctx_t *ctx,
+6
View File
@@ -0,0 +1,6 @@
#ifndef CST_VERSION_H
#define CST_VERSION_H
#define CST_VERSION "0.4.0"
#endif
+10
View File
@@ -57,6 +57,16 @@ 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);
/* Same, with child_hook(ud) run in the child just before exec (Unix only;
* ignored on Windows). A non-zero return aborts the child with exit 126. */
typedef int (*os_child_hook_fn)(void *ud);
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);
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);
/* Child processes get stdin=/dev/null, their own process group, and are
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */
+102 -32
View File
@@ -1,46 +1,116 @@
#!/usr/bin/env bash
# One-shot: clone (optional) + build CST-Agent on Linux, WSL or macOS.
# Install or upgrade CST-Agent on Linux, WSL or macOS. Safe to rerun: an
# existing checkout is fast-forwarded, rebuilt and reinstalled.
#
# bootstrap_linux.sh [DIR] [--no-pull]
# cst-agent-update (installed by this script; same as rerunning it)
#
# Env: CST_AGENT_PREFIX (install prefix, default ~/.local), CST_AGENT_DIR
# (checkout, default: current checkout or ~/CST-agent), CST_AGENT_REPO.
set -euo pipefail
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-}"
if [[ -z "$DIR" ]]; then
if [[ -f Makefile && -d src ]]; then
DIR="$(pwd)"
else
DIR="CST-agent"
fi
fi
if [[ ! -f "$DIR/Makefile" ]]; then
echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR"
fi
cd "$DIR"
echo "==> deps hint: gcc/clang make cmake libcurl ncursesw"
deps_hint() {
echo "==> deps: gcc/clang make pkg-config libcurl ncursesw"
if [[ "$(uname -s)" == Darwin ]]; then
echo " macOS: xcode-select --install && brew install ncurses curl pkg-config"
elif command -v pacman >/dev/null 2>&1; then
echo " Arch: sudo pacman -S --needed base-devel cmake curl ncurses"
echo " Arch: sudo pacman -S --needed base-devel curl ncurses"
elif command -v apt-get >/dev/null 2>&1; then
echo " Debian/Ubuntu/WSL: sudo apt install build-essential cmake pkg-config libcurl4-openssl-dev libncurses-dev"
echo " Debian/Ubuntu/WSL: sudo apt install build-essential pkg-config libcurl4-openssl-dev libncurses-dev"
elif command -v dnf >/dev/null 2>&1; then
echo " Fedora: sudo dnf install gcc make cmake pkgconf libcurl-devel ncurses-devel"
echo " Fedora: sudo dnf install gcc make pkgconf libcurl-devel ncurses-devel"
elif command -v apk >/dev/null 2>&1; then
echo " Alpine: sudo apk add build-base pkgconf curl-dev ncurses-dev"
elif command -v nix-shell >/dev/null 2>&1; then
echo " Nix: nix-shell (uses shell.nix), then rerun this script"
fi
}
# On NixOS (outside a nix shell) build inside shell.nix.
run_make() {
if [[ -z "${IN_NIX_SHELL:-}" ]] && command -v nix-shell >/dev/null 2>&1 &&
[[ -f shell.nix ]]; then
nix-shell --run "make $*"
else
make "$@"
fi
}
main() {
local repo_url="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
local prefix="${CST_AGENT_PREFIX:-$HOME/.local}"
local dir="" pull=1 changed=0 arg
for arg in "$@"; do
case "$arg" in
--no-pull) pull=0 ;;
--rebuilt) changed=1 ;; # internal: re-exec after a pull
-h | --help)
sed -n '2,9p' "$0"
return 0
;;
*) dir="$arg" ;;
esac
done
if [[ -z "$dir" ]]; then
if [[ -n "${CST_AGENT_DIR:-}" ]]; then
dir="$CST_AGENT_DIR"
elif [[ -f Makefile && -f src/main.c && -d .git ]]; then
dir="$(pwd)"
else
dir="$HOME/CST-agent"
fi
fi
JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
echo "==> make"
make -j"$JOBS"
make check || true
local old="" new=""
if [[ ! -f "$dir/Makefile" ]]; then
echo "==> git clone $repo_url -> $dir"
git clone "$repo_url" "$dir"
changed=1
elif ((pull)); then
old="$(git -C "$dir" rev-parse HEAD)"
if git -C "$dir" diff --quiet && git -C "$dir" diff --cached --quiet; then
echo "==> git pull ($dir)"
git -C "$dir" pull --ff-only
else
echo "warning: $dir has local changes; skipping git pull" >&2
fi
new="$(git -C "$dir" rev-parse HEAD)"
if [[ "$old" != "$new" ]]; then
echo " $(git -C "$dir" log --oneline "$old..$new" | wc -l) new commit(s)"
# The script itself may have changed: continue with the new version.
exec bash "$dir/scripts/bootstrap_linux.sh" "$dir" --no-pull --rebuilt
fi
fi
cd "$dir"
dir="$(pwd)"
local jobs
jobs="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
echo "==> build"
if ((changed)); then
run_make clean >/dev/null 2>&1 || true
fi
if ! run_make -j"$jobs"; then
deps_hint
return 1
fi
run_make check >/dev/null 2>&1 || echo "warning: some tests failed (run 'make check')"
echo "==> install to $prefix/bin"
mkdir -p "$prefix/bin"
run_make install PREFIX="$prefix" >/dev/null
cat >"$prefix/bin/cst-agent-update" <<EOF
#!$(command -v bash)
exec bash "$dir/scripts/bootstrap_linux.sh" "$dir" "\$@"
EOF
chmod 0755 "$prefix/bin/cst-agent-update"
echo
echo "OK: ./cst-agent"
echo "Install: make install PREFIX=\$HOME/.local"
echo "Run it inside a project directory: cst-agent"
echo " (the first start asks for the provider and its API key and saves them)"
echo "OK: $("$prefix/bin/cst-agent" --version)"
case ":$PATH:" in
*":$prefix/bin:"*) ;;
*) echo "Add to PATH: export PATH=\"$prefix/bin:\$PATH\"" ;;
esac
echo "Run 'cst-agent' inside a project directory; upgrade later with 'cst-agent-update'."
echo "The first start asks for the provider and its API key and saves them."
}
main "$@"
+76 -12
View File
@@ -1,26 +1,90 @@
#!/data/data/com.termux/files/usr/bin/bash
# One-shot: clone, build and install CST-Agent on Android Termux.
# Install or upgrade CST-Agent on Android Termux. Safe to rerun: an existing
# checkout is fast-forwarded, rebuilt and reinstalled.
#
# bootstrap_termux.sh [DIR] [--no-pull]
# cst-agent-update (installed by this script; same as rerunning it)
set -euo pipefail
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-CST-agent}"
main() {
local repo_url="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
local dir="" pull=1 changed=0 arg
for arg in "$@"; do
case "$arg" in
--no-pull) pull=0 ;;
--rebuilt) changed=1 ;; # internal: re-exec after a pull
-h | --help)
sed -n '2,6p' "$0"
return 0
;;
*) dir="$arg" ;;
esac
done
if [[ -z "$dir" ]]; then
if [[ -n "${CST_AGENT_DIR:-}" ]]; then
dir="$CST_AGENT_DIR"
elif [[ -f Makefile && -f src/main.c && -d .git ]]; then
dir="$(pwd)"
else
dir="$HOME/CST-agent"
fi
fi
echo "==> Termux packages"
pkg install -y git clang make pkg-config binutils libcurl ncurses
if [[ ! -f "$DIR/Makefile" ]]; then
echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR"
local need=() p
for p in git clang make pkg-config binutils libcurl ncurses; do
dpkg -s "$p" >/dev/null 2>&1 || need+=("$p")
done
if ((${#need[@]})); then
pkg install -y "${need[@]}"
else
echo " all present"
fi
cd "$DIR"
local old="" new=""
if [[ ! -f "$dir/Makefile" ]]; then
echo "==> git clone $repo_url -> $dir"
git clone "$repo_url" "$dir"
changed=1
elif ((pull)); then
old="$(git -C "$dir" rev-parse HEAD)"
if git -C "$dir" diff --quiet && git -C "$dir" diff --cached --quiet; then
echo "==> git pull ($dir)"
git -C "$dir" pull --ff-only
else
echo "warning: $dir has local changes; skipping git pull" >&2
fi
new="$(git -C "$dir" rev-parse HEAD)"
if [[ "$old" != "$new" ]]; then
echo " $(git -C "$dir" log --oneline "$old..$new" | wc -l) new commit(s)"
# The script itself may have changed: continue with the new version.
exec bash "$dir/scripts/bootstrap_termux.sh" "$dir" --no-pull --rebuilt
fi
fi
cd "$dir"
dir="$(pwd)"
echo "==> build"
make -j"$(nproc 2>/dev/null || echo 4)"
make check || echo "warning: some tests failed (see above)"
local jobs
jobs="$(nproc 2>/dev/null || echo 4)"
if ((changed)); then
make clean >/dev/null 2>&1 || true
fi
make -j"$jobs"
make check >/dev/null 2>&1 || echo "warning: some tests failed (run 'make check')"
echo "==> install to $PREFIX/bin"
make install
cat >"$PREFIX/bin/cst-agent-update" <<EOF
#!$PREFIX/bin/bash
exec bash "$dir/scripts/bootstrap_termux.sh" "$dir" "\$@"
EOF
chmod 0755 "$PREFIX/bin/cst-agent-update"
echo
echo "OK: run 'cst-agent' inside a project directory."
echo "OK: $("$PREFIX/bin/cst-agent" --version)"
echo "Run 'cst-agent' inside a project directory; upgrade later with 'cst-agent-update'."
echo "The first start asks for the provider and its API key and saves them."
}
main "$@"
+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);
}
+1449 -314
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
int main(void) {
puts("test_models: skipped on Windows");
return 0;
}
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h>
#include <sys/socket.h>
#include <unistd.h>
#define CHECK(c) \
do { \
if (!(c)) { \
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
exit(1); \
} \
} while (0)
/* One canned HTTP response per accepted connection. */
static const char *g_responses[8];
static int g_nresp;
static int g_listen = -1;
static char g_request[4096];
static void *serve(void *arg) {
(void)arg;
for (int i = 0; i < g_nresp; i++) {
int c = accept(g_listen, NULL, NULL);
if (c < 0)
return NULL;
ssize_t n = recv(c, g_request, sizeof(g_request) - 1, 0);
g_request[n > 0 ? n : 0] = 0;
const char *r = g_responses[i];
ssize_t off = 0, len = (ssize_t)strlen(r);
while (off < len) {
ssize_t w = send(c, r + off, (size_t)(len - off), 0);
if (w <= 0)
break;
off += w;
}
close(c);
}
return NULL;
}
static int start_server(void) {
g_listen = socket(AF_INET, SOCK_STREAM, 0);
CHECK(g_listen >= 0);
int one = 1;
setsockopt(g_listen, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in a;
memset(&a, 0, sizeof(a));
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
CHECK(bind(g_listen, (struct sockaddr *)&a, sizeof(a)) == 0);
CHECK(listen(g_listen, 8) == 0);
socklen_t al = sizeof(a);
CHECK(getsockname(g_listen, (struct sockaddr *)&a, &al) == 0);
return ntohs(a.sin_port);
}
#define RESP(status, body) \
"HTTP/1.1 " status "\r\nContent-Type: application/json\r\n" \
"Connection: close\r\n\r\n" body
int main(void) {
g_responses[0] = RESP("200 OK", "{\"object\":\"list\",\"data\":["
"{\"id\":\"deepseek-flash\"},"
"{\"id\":\"deepseek-v4-pro\"},"
"{\"id\":\"deepseek-flash\"}]}");
g_responses[1] = RESP("401 Unauthorized", "{\"error\":\"bad key\"}");
g_responses[2] = RESP("200 OK", "<html>not json</html>");
g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"},"
"{\"name\":\"m2\"}]}");
g_responses[4] = RESP("500 Internal Server Error", "{}");
g_nresp = 5;
int port = start_server();
pthread_t th;
CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
char base[64];
snprintf(base, sizeof(base), "http://127.0.0.1:%d/v1/", port);
char ids[8][128], err[256];
/* data[].id, duplicates folded, trailing slash on the base */
int n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == 2);
CHECK(strcmp(ids[0], "deepseek-flash") == 0);
CHECK(strcmp(ids[1], "deepseek-v4-pro") == 0);
CHECK(strncmp(g_request, "GET /v1/models ", 15) == 0);
CHECK(strstr(g_request, "Authorization: Bearer sk-test") != NULL);
n = cst_llm_list_models(base, "sk-bad", ids, 8, err, sizeof(err));
CHECK(n == CST_MODELS_AUTH);
CHECK(strstr(err, "401") != NULL);
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1);
CHECK(err[0]);
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == 2 && strcmp(ids[1], "m2") == 0);
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1 && strstr(err, "500") != NULL);
pthread_join(th, NULL);
close(g_listen);
/* nothing listening: network error, not an auth failure */
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1);
puts("test_models: ok");
return 0;
}
#endif
+200
View File
@@ -0,0 +1,200 @@
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cst_perms.h"
#include "cst_sandbox.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CHECK(c) \
do { \
if (!(c)) { \
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
exit(1); \
} \
} while (0)
static void test_rules(void) {
static const char *const SAFE[] = {
"ls -la",
"cat README.md | head -n 20",
"git status && git diff --stat",
"grep -rn TODO src || true",
"find . -name '*.c' | wc -l",
"rg foo src 2>&1 | head",
"echo hi >/dev/null",
"pwd; which cc",
"git log --oneline -5",
"tree -L 2",
};
static const char *const UNSAFE[] = {
"rm -rf build",
"echo hi > out.txt",
"cat a >> b",
"ls $(pwd)",
"ls `pwd`",
"diff <(ls a) <(ls b)",
"sudo ls",
"find . -name x -delete",
"find . -exec rm {} ;",
"git push",
"git commit -m x",
"make",
"curl https://example.com",
"ls && rm x",
"echo ok | sh",
"",
"tee out.txt",
"git branch -D main",
};
for (size_t i = 0; i < sizeof(SAFE) / sizeof(SAFE[0]); i++) {
if (!cst_shell_is_safe(SAFE[i]))
fprintf(stderr, "expected safe: %s\n", SAFE[i]);
CHECK(cst_shell_is_safe(SAFE[i]));
}
for (size_t i = 0; i < sizeof(UNSAFE) / sizeof(UNSAFE[0]); i++) {
if (cst_shell_is_safe(UNSAFE[i]))
fprintf(stderr, "expected unsafe: %s\n", UNSAFE[i]);
CHECK(!cst_shell_is_safe(UNSAFE[i]));
}
}
static void test_blocked(void) {
const char *a = "sh: 1: cannot create /etc/x: Permission denied\n";
CHECK(cst_sandbox_blocked(a, strlen(a), 2));
CHECK(!cst_sandbox_blocked(a, strlen(a), 0));
const char *b = "error: no such file\n";
CHECK(!cst_sandbox_blocked(b, strlen(b), 1));
const char *c = "touch: cannot touch 'x': Read-only file system\n";
CHECK(cst_sandbox_blocked(c, strlen(c), 1));
}
#if !defined(_WIN32)
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
static int exists(const char *p) {
struct stat st;
return stat(p, &st) == 0;
}
static int run(const char *ws, const char *cmd, os_run_result_t *r) {
memset(r, 0, sizeof(*r));
CHECK(cst_sandbox_run_shell(ws, cmd, 0, r) == 0);
return r->exit_code;
}
/* Child: enter the sandbox, then try a TCP connect to a local listener. */
static void test_network(const char *ws) {
int ls = socket(AF_INET, SOCK_STREAM, 0);
CHECK(ls >= 0);
struct sockaddr_in a;
memset(&a, 0, sizeof(a));
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
CHECK(bind(ls, (struct sockaddr *)&a, sizeof(a)) == 0);
CHECK(listen(ls, 1) == 0);
socklen_t al = sizeof(a);
CHECK(getsockname(ls, (struct sockaddr *)&a, &al) == 0);
cst_sandbox_plan_t plan;
CHECK(cst_sandbox_prepare(ws, NULL, &plan) == 0);
pid_t pid = fork();
CHECK(pid >= 0);
if (pid == 0) {
if (cst_sandbox_enter(&plan) != 0)
_exit(3);
int s = socket(AF_INET, SOCK_STREAM, 0);
int rc = connect(s, (struct sockaddr *)&a, sizeof(a));
_exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2);
}
int st = 0;
waitpid(pid, &st, 0);
cst_sandbox_release(&plan);
close(ls);
CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0);
}
static void test_landlock(void) {
char cwd[OS_PATH_MAX], ws[OS_PATH_MAX + 32], out[OS_PATH_MAX + 32];
char cmd[3 * OS_PATH_MAX];
CHECK(getcwd(cwd, sizeof(cwd)) != NULL);
/* Both live in the build tree, outside TMPDIR, so "outside" is denied. */
snprintf(ws, sizeof(ws), "%s/tests/sbx_ws_XXXXXX", cwd);
snprintf(out, sizeof(out), "%s/tests/sbx_out_XXXXXX", cwd);
CHECK(mkdtemp(ws) && mkdtemp(out));
char outfile[OS_PATH_MAX + 64], readme[OS_PATH_MAX + 64];
snprintf(outfile, sizeof(outfile), "%s/escape.txt", out);
snprintf(readme, sizeof(readme), "%s/readme.txt", out);
FILE *fp = fopen(readme, "w");
CHECK(fp);
fputs("outside-content\n", fp);
fclose(fp);
os_run_result_t r;
CHECK(run(ws, "echo hi > inside.txt && mkdir sub && echo x > sub/y && "
"rm sub/y && cat inside.txt",
&r) == 0);
CHECK(strstr(r.out, "hi") != NULL);
os_run_result_free(&r);
snprintf(cmd, sizeof(cmd), "echo nope > '%s'", outfile);
CHECK(run(ws, cmd, &r) != 0);
CHECK(!exists(outfile));
if (!cst_sandbox_blocked(r.out, r.out_len, r.exit_code))
fprintf(stderr, "unexpected output (exit %d): %s\n", r.exit_code,
r.out ? r.out : "");
CHECK(cst_sandbox_blocked(r.out, r.out_len, r.exit_code));
os_run_result_free(&r);
snprintf(cmd, sizeof(cmd), "cat '%s'", readme);
CHECK(run(ws, cmd, &r) == 0);
CHECK(strstr(r.out, "outside-content") != NULL);
os_run_result_free(&r);
CHECK(run(ws, "echo x >/dev/null && echo ok", &r) == 0);
os_run_result_free(&r);
char tmp[OS_PATH_MAX];
CHECK(os_temp_dir(tmp, sizeof(tmp)) == 0);
snprintf(cmd, sizeof(cmd), "f='%s/cst_sbx_%d'; echo t > \"$f\" && rm \"$f\"",
tmp, (int)getpid());
CHECK(run(ws, cmd, &r) == 0);
os_run_result_free(&r);
/* the parent itself stays unconfined */
fp = fopen(outfile, "w");
CHECK(fp);
fclose(fp);
if (cst_sandbox_landlock_abi() >= 4)
test_network(ws);
else
printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n",
cst_sandbox_landlock_abi());
snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out);
CHECK(system(cmd) == 0);
}
#endif
int main(void) {
test_rules();
test_blocked();
int kind = cst_sandbox_probe();
printf("test_sandbox: sandbox %s\n", cst_sandbox_name(kind));
#if !defined(_WIN32)
if (kind == CST_SANDBOX_LANDLOCK)
test_landlock();
#endif
puts("test_sandbox: ok");
return 0;
}