forked from WHC/Hermit
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/ctl_parse.c
|
||||||
src/os_compat.c
|
src/os_compat.c
|
||||||
src/sandbox.c
|
src/sandbox.c
|
||||||
|
src/sandbox_exec.c
|
||||||
src/session.c
|
src/session.c
|
||||||
src/tools/tools.c
|
src/tools/tools.c
|
||||||
src/tools/patch.c
|
src/tools/patch.c
|
||||||
@@ -166,7 +167,7 @@ endforeach()
|
|||||||
# Tests that link the whole core (no TUI).
|
# Tests that link the whole core (no TUI).
|
||||||
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip
|
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip
|
||||||
test_shell test_diff test_tools_fs test_registry
|
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})
|
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
|
||||||
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
|
||||||
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ CORE_SRC = \
|
|||||||
src/ctl_parse.c \
|
src/ctl_parse.c \
|
||||||
src/os_compat.c \
|
src/os_compat.c \
|
||||||
src/sandbox.c \
|
src/sandbox.c \
|
||||||
|
src/sandbox_exec.c \
|
||||||
src/session.c \
|
src/session.c \
|
||||||
src/tools/tools.c \
|
src/tools/tools.c \
|
||||||
src/tools/patch.c \
|
src/tools/patch.c \
|
||||||
@@ -96,7 +97,9 @@ TESTS = \
|
|||||||
tests/test_tools_fs \
|
tests/test_tools_fs \
|
||||||
tests/test_registry \
|
tests/test_registry \
|
||||||
tests/test_undo \
|
tests/test_undo \
|
||||||
tests/test_config
|
tests/test_config \
|
||||||
|
tests/test_models \
|
||||||
|
tests/test_sandbox
|
||||||
|
|
||||||
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS)
|
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS)
|
||||||
|
|
||||||
|
|||||||
@@ -13,19 +13,23 @@ See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
|
|||||||
### 5 分钟试用
|
### 5 分钟试用
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux 一键:克隆并编译
|
# Linux / WSL / macOS 一键:克隆到 ~/CST-agent、编译、装到 ~/.local/bin
|
||||||
git clone https://git.whcself.xyz/WHC/CST-agent.git && ./CST-agent/scripts/bootstrap_linux.sh ./CST-agent
|
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 .
|
./scripts/bootstrap_linux.sh .
|
||||||
|
|
||||||
# 装到 PATH(或 sudo make install)
|
# 以后升级:拉取最新代码、重新编译并安装(本地有未提交改动时跳过 pull)
|
||||||
make install PREFIX=$HOME/.local
|
cst-agent-update
|
||||||
|
cst-agent --version
|
||||||
|
|
||||||
# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)
|
# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)
|
||||||
cd ~/my-project && cst-agent
|
cd ~/my-project && cst-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
|
||||||
|
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
|
||||||
|
|
||||||
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。
|
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。
|
||||||
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
|
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
|
||||||
|
|
||||||
@@ -118,9 +122,12 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
|
|||||||
### Termux
|
### Termux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 一键:装依赖、克隆、编译、安装到 $PREFIX/bin
|
# 一键:装依赖、克隆到 ~/CST-agent、编译、安装到 $PREFIX/bin
|
||||||
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_termux.sh | bash
|
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
|
pkg install git clang make pkg-config binutils libcurl ncurses
|
||||||
git clone https://git.whcself.xyz/WHC/CST-agent.git && cd CST-agent
|
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_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 |
|
||||||
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
|
| `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_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
|
## Run
|
||||||
|
|
||||||
@@ -211,7 +219,8 @@ cst-agent --headless --prompt 'List files'
|
|||||||
| `-N` / `--new` | 开新会话 |
|
| `-N` / `--new` | 开新会话 |
|
||||||
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
||||||
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
| `-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` |
|
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
|
||||||
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
|
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
|
||||||
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
|
| `-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 拒绝。
|
`y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。
|
||||||
`a` / `p` 写入 `<workspace>/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。
|
`a` / `p` 写入 `<workspace>/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。
|
||||||
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
|
`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 | 移动光标(按字 / 按词,支持中文宽字符) |
|
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F | 移动光标(按字 / 按词,支持中文宽字符) |
|
||||||
| Ctrl+W / Ctrl+U / Ctrl+K / Alt+Backspace / Delete | 删词 / 删到行首 / 删到行尾 |
|
| 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 输出 / 开关任务面板 |
|
| Ctrl+O / Ctrl+T | 展开最近 tool 输出 / 开关任务面板 |
|
||||||
| PgUp / PgDn / 鼠标滚轮 | 滚动对话 |
|
| PgUp / PgDn / 鼠标滚轮 | 滚动对话 |
|
||||||
|
| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中(`/mouse` 关闭后可用终端原生选择复制) |
|
||||||
| Esc | 取消当前 turn(不会退出) |
|
| Esc | 取消当前 turn(不会退出) |
|
||||||
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
|
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
|
||||||
|
|
||||||
@@ -274,11 +307,13 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `/help` * | 命令与按键 |
|
| `/help` * | 命令与按键 |
|
||||||
| `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 |
|
| `/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(遮罩输入并保存) |
|
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
|
||||||
| `/protocol [name]` | 切换工具调用协议 |
|
| `/protocol [name]` | 切换工具调用协议 |
|
||||||
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
|
| `/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` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 |
|
| `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 |
|
||||||
| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 |
|
| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 |
|
||||||
| `/diff [path]` * | `git status` + `git diff` |
|
| `/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 |
|
| 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 |
|
| 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) |
|
| Sessions, undo snapshots, exports | `<workspace>/.agent/` (git-ignored automatically) |
|
||||||
| Saved permission rules | `<workspace>/.agent/permissions.json` |
|
| Saved permission rules | `<workspace>/.agent/permissions.json` |
|
||||||
| Project notes injected into the prompt | `<workspace>/AGENTS.md` (or `CST.md`) |
|
| 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
|
## Terminal notes
|
||||||
|
|
||||||
- UTF-8 locale gives the full UI. Under a non-UTF-8 locale, or with
|
- 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
|
- [ ] Todo panel appears while tasks are open; Ctrl+T hides it
|
||||||
- [ ] `/undo` restores files changed by the last turn; `/retry` re-asks
|
- [ ] `/undo` restores files changed by the last turn; `/retry` re-asks
|
||||||
- [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work
|
- [ ] `/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
|
- [ ] Resize the terminal during streaming: layout recovers
|
||||||
- [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens
|
- [ ] `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_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL |
|
||||||
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
|
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
|
||||||
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
|
| `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_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) |
|
||||||
| `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) |
|
| `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) |
|
||||||
|
|
||||||
|
|||||||
@@ -73,10 +73,23 @@ typedef struct {
|
|||||||
int confirm_write; /* confirm file-changing tools */
|
int confirm_write; /* confirm file-changing tools */
|
||||||
int confirm_net; /* confirm web_fetch */
|
int confirm_net; /* confirm web_fetch */
|
||||||
int auto_approve; /* headless / CST_AUTO_APPROVE=1 / --yolo */
|
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) */
|
int tool_profile; /* cst_tool_profile_t (CST_TOOLS) */
|
||||||
unsigned long long tools_off; /* bit i = registry tool i disabled */
|
unsigned long long tools_off; /* bit i = registry tool i disabled */
|
||||||
} cst_agent_config_t;
|
} 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_session cst_session_t;
|
||||||
typedef struct cst_agent cst_agent_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);
|
cst_tool_result_t *r);
|
||||||
int cst_tool_run_shell(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);
|
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,
|
int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call,
|
||||||
cst_tool_result_t *r);
|
cst_tool_result_t *r);
|
||||||
int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
|
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;
|
} cst_llm_request_t;
|
||||||
|
|
||||||
int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz);
|
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. */
|
/* JSON request body (malloc'd) exactly as sent on the wire. */
|
||||||
char *cst_llm_build_body(const cst_llm_request_t *req);
|
char *cst_llm_build_body(const cst_llm_request_t *req);
|
||||||
/* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */
|
/* 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_ONCE 1
|
||||||
#define CST_PERM_ALWAYS_TOOL 2 /* saved to .agent/permissions.json */
|
#define CST_PERM_ALWAYS_TOOL 2 /* saved to .agent/permissions.json */
|
||||||
#define CST_PERM_ALWAYS_PREFIX 3 /* run_shell: allow this command prefix */
|
#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 */
|
/* Live UI / observability hooks for a turn */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -297,6 +326,7 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz);
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
char provider[32];
|
char provider[32];
|
||||||
char effort[16];
|
char effort[16];
|
||||||
|
char approval[8]; /* "" = not chosen yet */
|
||||||
int nmodels;
|
int nmodels;
|
||||||
struct {
|
struct {
|
||||||
char provider[32];
|
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);
|
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. */
|
/* Record cfg's provider, model and effort as the last used ones. */
|
||||||
int cst_settings_remember(const cst_agent_config_t *cfg);
|
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). */
|
/* JSON baseline parser for experiments (buffer until valid object). */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ int cst_perms_reset(cst_perms_t *p);
|
|||||||
char *cst_perms_describe(const 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). */
|
/* 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);
|
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
|
#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). */
|
/* todo_write: the list was replaced (text form, one item per line). */
|
||||||
void (*set_todo)(const char *text, void *ud);
|
void (*set_todo)(const char *text, void *ud);
|
||||||
void *todo_ud;
|
void *todo_ud;
|
||||||
|
int sandbox; /* run_shell: confine to the workspace (cst_sandbox.h) */
|
||||||
} cst_tool_ctx_t;
|
} cst_tool_ctx_t;
|
||||||
|
|
||||||
typedef int (*cst_tool_run_fn)(const cst_tool_ctx_t *ctx,
|
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,
|
int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
|
||||||
os_run_result_t *r);
|
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
|
/* Child processes get stdin=/dev/null, their own process group, and are
|
||||||
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when
|
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when
|
||||||
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */
|
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */
|
||||||
|
|||||||
+109
-39
@@ -1,46 +1,116 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
|
deps_hint() {
|
||||||
DIR="${1:-}"
|
echo "==> deps: gcc/clang make pkg-config libcurl ncursesw"
|
||||||
|
if [[ "$(uname -s)" == Darwin ]]; then
|
||||||
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"
|
|
||||||
if [[ "$(uname -s)" == Darwin ]]; then
|
|
||||||
echo " macOS: xcode-select --install && brew install ncurses curl pkg-config"
|
echo " macOS: xcode-select --install && brew install ncurses curl pkg-config"
|
||||||
elif command -v pacman >/dev/null 2>&1; then
|
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
|
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
|
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
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
echo " Alpine: sudo apk add build-base pkgconf curl-dev ncurses-dev"
|
echo " Alpine: sudo apk add build-base pkgconf curl-dev ncurses-dev"
|
||||||
elif command -v nix-shell >/dev/null 2>&1; then
|
fi
|
||||||
echo " Nix: nix-shell (uses shell.nix), then rerun this script"
|
}
|
||||||
fi
|
|
||||||
|
|
||||||
JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
|
# On NixOS (outside a nix shell) build inside shell.nix.
|
||||||
echo "==> make"
|
run_make() {
|
||||||
make -j"$JOBS"
|
if [[ -z "${IN_NIX_SHELL:-}" ]] && command -v nix-shell >/dev/null 2>&1 &&
|
||||||
make check || true
|
[[ -f shell.nix ]]; then
|
||||||
|
nix-shell --run "make $*"
|
||||||
|
else
|
||||||
|
make "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
echo
|
main() {
|
||||||
echo "OK: ./cst-agent"
|
local repo_url="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
|
||||||
echo "Install: make install PREFIX=\$HOME/.local"
|
local prefix="${CST_AGENT_PREFIX:-$HOME/.local}"
|
||||||
echo "Run it inside a project directory: cst-agent"
|
local dir="" pull=1 changed=0 arg
|
||||||
echo " (the first start asks for the provider and its API key and saves them)"
|
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
|
||||||
|
|
||||||
|
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: $("$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 "$@"
|
||||||
|
|||||||
+82
-18
@@ -1,26 +1,90 @@
|
|||||||
#!/data/data/com.termux/files/usr/bin/bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
|
main() {
|
||||||
DIR="${1:-CST-agent}"
|
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"
|
echo "==> Termux packages"
|
||||||
pkg install -y git clang make pkg-config binutils libcurl ncurses
|
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
|
||||||
|
|
||||||
if [[ ! -f "$DIR/Makefile" ]]; then
|
local old="" new=""
|
||||||
echo "==> git clone $REPO_URL -> $DIR"
|
if [[ ! -f "$dir/Makefile" ]]; then
|
||||||
git clone "$REPO_URL" "$DIR"
|
echo "==> git clone $repo_url -> $dir"
|
||||||
fi
|
git clone "$repo_url" "$dir"
|
||||||
cd "$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"
|
echo "==> build"
|
||||||
make -j"$(nproc 2>/dev/null || echo 4)"
|
local jobs
|
||||||
make check || echo "warning: some tests failed (see above)"
|
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"
|
echo "==> install to $PREFIX/bin"
|
||||||
make install
|
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
|
||||||
echo "OK: run 'cst-agent' inside a project directory."
|
echo "OK: $("$PREFIX/bin/cst-agent" --version)"
|
||||||
echo "The first start asks for the provider and its API key and saves them."
|
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_agent.h"
|
||||||
#include "cst_metrics.h"
|
#include "cst_metrics.h"
|
||||||
#include "cst_perms.h"
|
#include "cst_perms.h"
|
||||||
|
#include "cst_sandbox.h"
|
||||||
#include "cst_tools.h"
|
#include "cst_tools.h"
|
||||||
#include "cst_undo.h"
|
#include "cst_undo.h"
|
||||||
#include "cst_util.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))
|
if (!minimal && cst_protocol_is_ctl(p))
|
||||||
cst_buf_puts(&b, SEC_CTL_NUMBERS);
|
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]) {
|
if (project_notes && project_notes[0]) {
|
||||||
size_t n = strlen(project_notes);
|
size_t n = strlen(project_notes);
|
||||||
if (n > PROJECT_NOTES_MAX)
|
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. */
|
/* Execute (or refuse) one confirmed call. Irreversible once started. */
|
||||||
static void exec_call(turn_t *t, pcall_t *pc) {
|
static void exec_call(turn_t *t, pcall_t *pc) {
|
||||||
if (pc->done)
|
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;
|
const char *cmd = def->perm == CST_TP_SHELL ? tool_cmd(c) : NULL;
|
||||||
if (!dup && needs_confirm(&a->cfg, def->perm) && !a->cfg.auto_approve &&
|
if (!dup && needs_confirm(&a->cfg, def->perm) && !a->cfg.auto_approve &&
|
||||||
!cst_perms_tool_allowed(a->perms, c->name) &&
|
!cst_perms_tool_allowed(a->perms, c->name) &&
|
||||||
!(cmd && cst_perms_shell_allowed(a->perms, cmd))) {
|
!(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) {
|
if (t->hooks && t->hooks->on_permission) {
|
||||||
char *preview = NULL;
|
char *preview = NULL;
|
||||||
if (def->preview)
|
if (def->preview)
|
||||||
@@ -650,6 +728,9 @@ static void exec_call(turn_t *t, pcall_t *pc) {
|
|||||||
undo_snapshot(a, c);
|
undo_snapshot(a, c);
|
||||||
cst_tool_result_t tr = {0};
|
cst_tool_result_t tr = {0};
|
||||||
cst_tool_run(&ctx, c, &tr);
|
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);
|
set_output(pc, tr.out ? tr.out : "", tr.out_len, tr.exit_code);
|
||||||
cst_tool_result_free(&tr);
|
cst_tool_result_free(&tr);
|
||||||
if (mutating)
|
if (mutating)
|
||||||
|
|||||||
@@ -698,3 +698,115 @@ finish:
|
|||||||
free(body);
|
free(body);
|
||||||
return ret;
|
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_agent.h"
|
||||||
#include "cst_tools.h"
|
#include "cst_tools.h"
|
||||||
|
#include "cst_version.h"
|
||||||
#include "os_compat.h"
|
#include "os_compat.h"
|
||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
@@ -26,7 +27,9 @@ static void usage(const char *argv0) {
|
|||||||
" -N, --new start a new session\n"
|
" -N, --new start a new session\n"
|
||||||
" -R, --resume ID open a session by id or unique prefix\n"
|
" -R, --resume ID open a session by id or unique prefix\n"
|
||||||
" -L, --list-sessions list sessions and exit\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"
|
" -C, --confirm LIST shell,write,net (default) | none\n"
|
||||||
" -r, --protocol P native-eos (default) | native-inc | ctl |\n"
|
" -r, --protocol P native-eos (default) | native-inc | ctl |\n"
|
||||||
" ctl-heredoc | xml\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"
|
" -b, --api-base URL OpenAI-compatible base URL (…/v1)\n"
|
||||||
" -s, --session FILE explicit session file\n"
|
" -s, --session FILE explicit session file\n"
|
||||||
" -d, --demo-ctl local CTL parser demo (no API)\n"
|
" -d, --demo-ctl local CTL parser demo (no API)\n"
|
||||||
|
" -V, --version print the version and exit\n"
|
||||||
"\n"
|
"\n"
|
||||||
"API keys: the TUI asks for a missing key and saves it per provider\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"
|
" 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_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n"
|
||||||
" CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\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_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",
|
"TUI: type /help inside for commands and keys\n",
|
||||||
argv0);
|
argv0);
|
||||||
}
|
}
|
||||||
@@ -235,6 +240,15 @@ int main(int argc, char **argv) {
|
|||||||
return 1;
|
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 do_demo = 0;
|
||||||
int force_ask_key = 0;
|
int force_ask_key = 0;
|
||||||
int session_explicit = 0;
|
int session_explicit = 0;
|
||||||
@@ -261,12 +275,14 @@ int main(int argc, char **argv) {
|
|||||||
{"resume", required_argument, 0, 'R'},
|
{"resume", required_argument, 0, 'R'},
|
||||||
{"list-sessions", no_argument, 0, 'L'},
|
{"list-sessions", no_argument, 0, 'L'},
|
||||||
{"yolo", no_argument, 0, 'y'},
|
{"yolo", no_argument, 0, 'y'},
|
||||||
|
{"approval", required_argument, 0, 'A'},
|
||||||
{"confirm", required_argument, 0, 'C'},
|
{"confirm", required_argument, 0, 'C'},
|
||||||
{"effort", required_argument, 0, 'e'},
|
{"effort", required_argument, 0, 'e'},
|
||||||
{"help", no_argument, 0, 'h'},
|
{"help", no_argument, 0, 'h'},
|
||||||
|
{"version", no_argument, 0, 'V'},
|
||||||
{0, 0, 0, 0}};
|
{0, 0, 0, 0}};
|
||||||
int c;
|
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) {
|
-1) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case 'w':
|
case 'w':
|
||||||
@@ -334,7 +350,14 @@ int main(int argc, char **argv) {
|
|||||||
do_list = 1;
|
do_list = 1;
|
||||||
break;
|
break;
|
||||||
case 'y':
|
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;
|
break;
|
||||||
case 'C':
|
case 'C':
|
||||||
if (parse_confirm(optarg, &cfg) != 0) {
|
if (parse_confirm(optarg, &cfg) != 0) {
|
||||||
@@ -356,6 +379,10 @@ int main(int argc, char **argv) {
|
|||||||
usage(argv[0]);
|
usage(argv[0]);
|
||||||
curl_global_cleanup();
|
curl_global_cleanup();
|
||||||
return 0;
|
return 0;
|
||||||
|
case 'V':
|
||||||
|
printf("cst-agent %s\n", CST_VERSION);
|
||||||
|
curl_global_cleanup();
|
||||||
|
return 0;
|
||||||
default:
|
default:
|
||||||
usage(argv[0]);
|
usage(argv[0]);
|
||||||
curl_global_cleanup();
|
curl_global_cleanup();
|
||||||
@@ -425,6 +452,12 @@ int main(int argc, char **argv) {
|
|||||||
while (alen > 0 && cfg.api_base[alen - 1] == '/')
|
while (alen > 0 && cfg.api_base[alen - 1] == '/')
|
||||||
cfg.api_base[--alen] = 0;
|
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). */
|
/* Headless defaults to auto-approve shell/write (no interactive confirm). */
|
||||||
if (cfg.headless)
|
if (cfg.headless)
|
||||||
cfg.auto_approve = 1;
|
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;
|
r->exit_code = (int)code;
|
||||||
return 0;
|
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
|
#else
|
||||||
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
|
||||||
os_run_result_t *r) {
|
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)
|
if (!argv || !argv[0] || !r)
|
||||||
return -1;
|
return -1;
|
||||||
if (max_out == 0)
|
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]);
|
close(pipefd[1]);
|
||||||
if (cwd && cwd[0] && chdir(cwd) != 0)
|
if (cwd && cwd[0] && chdir(cwd) != 0)
|
||||||
_exit(127);
|
_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);
|
execvp(argv[0], (char *const *)argv);
|
||||||
_exit(127);
|
_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,
|
int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
|
||||||
os_run_result_t *r) {
|
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)
|
if (!cmd || !r)
|
||||||
return -1;
|
return -1;
|
||||||
char shell[OS_PATH_MAX];
|
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) {
|
if (is_busybox) {
|
||||||
const char *argv[] = {shell, "sh", "-c", cmd, NULL};
|
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 {
|
} else {
|
||||||
const char *argv[] = {shell, "-c", cmd, NULL};
|
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))
|
if (has_shell_meta(out))
|
||||||
out[0] = 0;
|
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"));
|
cJSON_GetObjectItemCaseSensitive(root, "provider"));
|
||||||
copy_str(s->effort, sizeof(s->effort),
|
copy_str(s->effort, sizeof(s->effort),
|
||||||
cJSON_GetObjectItemCaseSensitive(root, "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 *models = cJSON_GetObjectItemCaseSensitive(root, "models");
|
||||||
const cJSON *m;
|
const cJSON *m;
|
||||||
cJSON_ArrayForEach(m, models) {
|
cJSON_ArrayForEach(m, models) {
|
||||||
@@ -69,6 +71,8 @@ int cst_settings_save(const cst_settings_t *s) {
|
|||||||
cJSON_AddStringToObject(root, "provider", s->provider);
|
cJSON_AddStringToObject(root, "provider", s->provider);
|
||||||
if (s->effort[0])
|
if (s->effort[0])
|
||||||
cJSON_AddStringToObject(root, "effort", s->effort);
|
cJSON_AddStringToObject(root, "effort", s->effort);
|
||||||
|
if (s->approval[0])
|
||||||
|
cJSON_AddStringToObject(root, "approval", s->approval);
|
||||||
cJSON *models = cJSON_AddObjectToObject(root, "models");
|
cJSON *models = cJSON_AddObjectToObject(root, "models");
|
||||||
for (int i = 0; i < s->nmodels; i++)
|
for (int i = 0; i < s->nmodels; i++)
|
||||||
cJSON_AddStringToObject(models, s->models[i].provider, s->models[i].model);
|
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;
|
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) {
|
const char *cst_settings_model(const cst_settings_t *s, const char *provider) {
|
||||||
for (int i = 0; provider && i < s->nmodels; i++) {
|
for (int i = 0; provider && i < s->nmodels; i++) {
|
||||||
if (strcmp(s->models[i].provider, provider) == 0 && s->models[i].model[0])
|
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);
|
snprintf(s.models[i].model, sizeof(s.models[i].model), "%s", cfg->model);
|
||||||
return cst_settings_save(&s);
|
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,
|
static int run_shell(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
||||||
cst_tool_result_t *r) {
|
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"
|
#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;
|
return 1;
|
||||||
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
|
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
|
||||||
return 0;
|
return 0;
|
||||||
|
if (cfg->plan_mode && (t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
|
||||||
|
return 0;
|
||||||
int i = (int)(t - TOOLS);
|
int i = (int)(t - TOOLS);
|
||||||
return !(cfg->tools_off & (1ULL << i));
|
return !(cfg->tools_off & (1ULL << i));
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include "cJSON.h"
|
#include "cJSON.h"
|
||||||
#include "cst_agent.h"
|
#include "cst_agent.h"
|
||||||
|
#include "cst_sandbox.h"
|
||||||
#include "cst_util.h"
|
#include "cst_util.h"
|
||||||
#include "os_compat.h"
|
#include "os_compat.h"
|
||||||
#include "tools_internal.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,
|
int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
|
||||||
cst_tool_result_t *r) {
|
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");
|
const char *cmd = tool_arg(call, "cmd");
|
||||||
if (!cmd)
|
if (!cmd)
|
||||||
cmd = tool_arg(call, "command");
|
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);
|
return tool_set_result(r, "error: bad workspace", 1);
|
||||||
|
|
||||||
os_run_result_t rr = {0};
|
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);
|
os_run_result_free(&rr);
|
||||||
return tool_set_result(r, "error: shell exec failed", 1);
|
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