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:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 时有用) |
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef CST_VERSION_H
|
||||
#define CST_VERSION_H
|
||||
|
||||
#define CST_VERSION "0.4.0"
|
||||
|
||||
#endif
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user