diff --git a/CMakeLists.txt b/CMakeLists.txt index 22165ab..e219d51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,7 @@ set(CST_CORE_SOURCES src/ctl_parse.c src/os_compat.c src/sandbox.c + src/sandbox_exec.c src/session.c src/tools/tools.c src/tools/patch.c @@ -166,7 +167,7 @@ endforeach() # Tests that link the whole core (no TUI). foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip test_shell test_diff test_tools_fs test_registry - test_undo test_config) + test_undo test_config test_models test_sandbox) add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) target_include_directories(${t} PRIVATE include "${CJSON_DIR}") target_compile_definitions(${t} PRIVATE _GNU_SOURCE) diff --git a/Makefile b/Makefile index 74ae8dc..45c638d 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ CORE_SRC = \ src/ctl_parse.c \ src/os_compat.c \ src/sandbox.c \ + src/sandbox_exec.c \ src/session.c \ src/tools/tools.c \ src/tools/patch.c \ @@ -96,7 +97,9 @@ TESTS = \ tests/test_tools_fs \ tests/test_registry \ tests/test_undo \ - tests/test_config + tests/test_config \ + tests/test_models \ + tests/test_sandbox TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS) diff --git a/README.md b/README.md index 5fd1583..37514e7 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,23 @@ See [docs/theory_tool_cst.md](docs/theory_tool_cst.md). ### 5 分钟试用 ```bash -# Linux 一键:克隆并编译 -git clone https://git.whcself.xyz/WHC/CST-agent.git && ./CST-agent/scripts/bootstrap_linux.sh ./CST-agent +# Linux / WSL / macOS 一键:克隆到 ~/CST-agent、编译、装到 ~/.local/bin +curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash -# 已有仓库目录内: +# 已有仓库目录内(装到别处:CST_AGENT_PREFIX=/usr/local) ./scripts/bootstrap_linux.sh . -# 装到 PATH(或 sudo make install) -make install PREFIX=$HOME/.local +# 以后升级:拉取最新代码、重新编译并安装(本地有未提交改动时跳过 pull) +cst-agent-update +cst-agent --version # 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存) cd ~/my-project && cst-agent ``` +一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装; +同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。 + 之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。 界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。 @@ -118,9 +122,12 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`( ### Termux ```bash -# 一键:装依赖、克隆、编译、安装到 $PREFIX/bin +# 一键:装依赖、克隆到 ~/CST-agent、编译、安装到 $PREFIX/bin curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_termux.sh | bash +# 以后升级(重跑上面那条也一样) +cst-agent-update + # 或手动 pkg install git clang make pkg-config binutils libcurl ncurses git clone https://git.whcself.xyz/WHC/CST-agent.git && cd CST-agent @@ -185,7 +192,8 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ | `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/.jsonl`) | | `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | -| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准、shell、旁路 busybox | +| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off` | 审批模式 / 关闭沙箱(回落到只读白名单规则) | +| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox | ## Run @@ -211,7 +219,8 @@ cst-agent --headless --prompt 'List files' | `-N` / `--new` | 开新会话 | | `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) | | `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 | -| `-y` / `--yolo` | 本次运行不再确认(等同 `--confirm none`) | +| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 | +| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 | | `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` | | `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` | | `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) | @@ -243,7 +252,28 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 ### 权限、撤销与项目说明 -- **确认**:默认 shell、写文件、联网三类工具需要确认。弹窗会显示命令或写入的 diff: +- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`): + | 模式 | 写文件 | run_shell | web_fetch | + |---|---|---|---| + | `ask` | 询问 | 询问 | 询问 | + | `auto`(默认) | 直接执行 | 有沙箱 → 在沙箱里直接执行;没有沙箱 → 只读白名单命令直接执行,其余询问 | 询问 | + | `yolo` | 直接执行 | 直接执行(不进沙箱) | 直接执行 | + + 已保存的规则(`a` / `p`)优先:匹配的命令不进沙箱直接运行。 + `ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。 +- **沙箱**(C 实现,`src/sandbox_exec.c`): + - Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`; + 读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。 + - macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。 + - Android / Termux、Windows、老内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 + - `CST_SANDBOX=off` 强制关闭沙箱。 + - 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问 + “Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。 +- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree` + 和 `git status/diff/log/show/branch`;可以用 `|` `&&` `||` `;` 串联,但每段都必须在白名单里。 + `find -exec/-delete`、`rg --pre`、`git branch NAME`、`>` 重定向(`2>&1`、`>/dev/null` 除外)、`$(…)`、反引号、 + `<(…)`、`sudo`、`rm` 一律不算安全。 +- **确认弹窗**会显示命令或写入的 diff: `y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。 `a` / `p` 写入 `/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。 `sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。 @@ -260,9 +290,12 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 | ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F | 移动光标(按字 / 按词,支持中文宽字符) | | Ctrl+W / Ctrl+U / Ctrl+K / Alt+Backspace / Delete | 删词 / 删到行首 / 删到行尾 | | ↑ / ↓ | 提示历史(多行输入时先在行间移动) | -| Tab | 补全 `/命令` 和 `@文件`;空输入时展开/折叠 Thought | +| Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) | +| Shift+Tab | 循环切换审批模式 ask → auto → yolo | +| Ctrl+P | 命令面板(输入过滤,↑↓ / 鼠标选择) | | Ctrl+O / Ctrl+T | 展开最近 tool 输出 / 开关任务面板 | | PgUp / PgDn / 鼠标滚轮 | 滚动对话 | +| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中(`/mouse` 关闭后可用终端原生选择复制) | | Esc | 取消当前 turn(不会退出) | | Ctrl+C 两次 / 空输入 Ctrl+D | 退出 | @@ -274,11 +307,13 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。 |---|---| | `/help` * | 命令与按键 | | `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 | -| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入)、模型、推理强度;都会记住 | +| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 | | `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) | | `/protocol [name]` | 切换工具调用协议 | | `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 | -| `/permissions [reset]` *、`/yolo` *、`/max-steps [N]` * | 权限规则、本次免确认、每轮步数 | +| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo | +| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 | +| `/sidebar` *、`/mouse` * | 开关右侧边栏(≥120 列时显示)、开关鼠标 | | `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 | | `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 | | `/diff [path]` * | `git status` + `git diff` | diff --git a/docs/compat.md b/docs/compat.md index dad72f4..a5222cf 100644 --- a/docs/compat.md +++ b/docs/compat.md @@ -32,11 +32,32 @@ the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below. | What | Where | |---|---| | API keys (one per provider, entered in the TUI or with `--ask-key`) | `$XDG_CONFIG_HOME/cst-agent/credentials` (default `~/.config/cst-agent/`), mode 0600 | -| Last provider, models and effort | `$XDG_CONFIG_HOME/cst-agent/settings.json` | +| Last provider, models, effort and approval mode | `$XDG_CONFIG_HOME/cst-agent/settings.json` | | Sessions, undo snapshots, exports | `/.agent/` (git-ignored automatically) | | Saved permission rules | `/.agent/permissions.json` | | Project notes injected into the prompt | `/AGENTS.md` (or `CST.md`) | +## Shell sandbox + +In the default `auto` approval mode, `run_shell` runs inside an OS sandbox +when one is available. Commands may read and execute anything but may only +write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`. + +| Platform | Sandbox | Notes | +|---|---|---| +| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind | +| macOS | `sandbox_init` profile | also denies network | +| Android / Termux | none | falls back to the read-only rules | +| WSL 2 | Landlock when the WSL kernel enables it | otherwise rules | +| Older kernels, Windows | none | falls back to the read-only rules | + +The status bar and sidebar show `auto · landlock`, `auto · seatbelt` or +`auto · rules`. `CST_SANDBOX=off` disables the sandbox. When a sandboxed +command looks blocked, the TUI asks whether to rerun it outside the sandbox. +The ruleset is built in the parent; the forked child only calls `prctl` and +`landlock_restrict_self` (async-signal-safe), so the agent itself is never +confined. + ## Terminal notes - UTF-8 locale gives the full UI. Under a non-UTF-8 locale, or with @@ -80,6 +101,12 @@ TUI: - [ ] Todo panel appears while tasks are open; Ctrl+T hides it - [ ] `/undo` restores files changed by the last turn; `/retry` re-asks - [ ] `/diff`, `/export`, `/status`, `/tools off grep`, `/yolo` work +- [ ] Mouse: click a tool card to expand it, click in the input to move the + cursor, click a picker row; `/mouse` turns it off +- [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette +- [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked +- [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the + sandbox?" (Linux 5.13+/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks - [ ] Resize the terminal during streaming: layout recovers - [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens diff --git a/docs/env_openai.md b/docs/env_openai.md index 518f927..2ffc6a2 100644 --- a/docs/env_openai.md +++ b/docs/env_openai.md @@ -76,7 +76,9 @@ export OPENAI_MODEL='your-model' | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL | | `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 | | `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 | -| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(headless 默认开启) | +| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(= `--approval yolo`;headless 默认开启) | +| `CST_APPROVAL` | 审批模式 `ask` / `auto`(默认)/ `yolo` | +| `CST_SANDBOX=off` | 关闭 run_shell 沙箱(Landlock / macOS sandbox),`auto` 回落到只读白名单 | | `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) | | `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) | diff --git a/include/cst_agent.h b/include/cst_agent.h index a0f8d48..2aa5f6b 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -73,10 +73,23 @@ typedef struct { int confirm_write; /* confirm file-changing tools */ int confirm_net; /* confirm web_fetch */ int auto_approve; /* headless / CST_AUTO_APPROVE=1 / --yolo */ + int approval; /* cst_approval_t (interactive sessions) */ + int plan_mode; /* read-only tools + planning note */ int tool_profile; /* cst_tool_profile_t (CST_TOOLS) */ unsigned long long tools_off; /* bit i = registry tool i disabled */ } cst_agent_config_t; +/* ask: confirm per tool; auto: sandbox/rules decide, ask on doubt; yolo: never + * ask (same as auto_approve). */ +typedef enum { + CST_APPROVAL_ASK = 0, + CST_APPROVAL_AUTO, + CST_APPROVAL_YOLO +} cst_approval_t; +#define CST_DEFAULT_APPROVAL CST_APPROVAL_AUTO +const char *cst_approval_name(int a); +int cst_approval_parse(const char *s, int *out); + typedef struct cst_session cst_session_t; typedef struct cst_agent cst_agent_t; @@ -143,6 +156,10 @@ int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call, cst_tool_result_t *r); int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call, cst_tool_result_t *r); +/* sandboxed: confine the command with the OS sandbox (cst_sandbox.h). */ +int cst_tool_run_shell_ex(const char *workspace_root, + const ctl_tool_call_t *call, cst_tool_result_t *r, + int sandboxed); int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call, cst_tool_result_t *r); int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call, @@ -206,6 +223,14 @@ typedef struct { } cst_llm_request_t; int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz); + +/* GET {api_base}/models (10 s timeout). Returns the number of model ids + * written, CST_MODELS_AUTH when the key is rejected (401/403), or -1 on + * network / format errors. */ +#define CST_MODELS_AUTH (-2) +int cst_llm_list_models(const char *api_base, const char *api_key, + char out[][128], int max_out, char *err, + size_t err_sz); /* JSON request body (malloc'd) exactly as sent on the wire. */ char *cst_llm_build_body(const cst_llm_request_t *req); /* Reset the CST_RECORD_SSE / CST_REPLAY_SSE sequence counter. */ @@ -216,6 +241,10 @@ void cst_llm_trace_reset(void); #define CST_PERM_ONCE 1 #define CST_PERM_ALWAYS_TOOL 2 /* saved to .agent/permissions.json */ #define CST_PERM_ALWAYS_PREFIX 3 /* run_shell: allow this command prefix */ +/* on_permission detail prefix when a sandboxed run_shell looked blocked and + * the question is whether to rerun it outside the sandbox. The rest of the + * detail is the tail of the sandboxed output. */ +#define CST_PERM_SANDBOX "[sandbox] " /* Live UI / observability hooks for a turn */ typedef struct { @@ -297,6 +326,7 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz); typedef struct { char provider[32]; char effort[16]; + char approval[8]; /* "" = not chosen yet */ int nmodels; struct { char provider[32]; @@ -308,6 +338,8 @@ int cst_settings_save(const cst_settings_t *s); const char *cst_settings_model(const cst_settings_t *s, const char *provider); /* Record cfg's provider, model and effort as the last used ones. */ int cst_settings_remember(const cst_agent_config_t *cfg); +/* Record an approval mode the user picked (CST_APPROVAL_*). */ +int cst_settings_remember_approval(int approval); /* JSON baseline parser for experiments (buffer until valid object). */ typedef struct { diff --git a/include/cst_perms.h b/include/cst_perms.h index 6fb3e50..09c2c3e 100644 --- a/include/cst_perms.h +++ b/include/cst_perms.h @@ -23,6 +23,9 @@ int cst_perms_reset(cst_perms_t *p); char *cst_perms_describe(const cst_perms_t *p); /* Reasonable prefix rule for a command ("" when it should not have one). */ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz); +/* 1 when every segment of cmd (split on | || && ;) is a read-only command + * from a fixed whitelist, with no output redirection or substitution. */ +int cst_shell_is_safe(const char *cmd); #ifdef __cplusplus } diff --git a/include/cst_sandbox.h b/include/cst_sandbox.h new file mode 100644 index 0000000..12fac80 --- /dev/null +++ b/include/cst_sandbox.h @@ -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 + +#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 diff --git a/include/cst_tools.h b/include/cst_tools.h index 3d0f0b6..3b47122 100644 --- a/include/cst_tools.h +++ b/include/cst_tools.h @@ -40,6 +40,7 @@ typedef struct cst_tool_ctx { /* todo_write: the list was replaced (text form, one item per line). */ void (*set_todo)(const char *text, void *ud); void *todo_ud; + int sandbox; /* run_shell: confine to the workspace (cst_sandbox.h) */ } cst_tool_ctx_t; typedef int (*cst_tool_run_fn)(const cst_tool_ctx_t *ctx, diff --git a/include/cst_version.h b/include/cst_version.h new file mode 100644 index 0000000..9c7e088 --- /dev/null +++ b/include/cst_version.h @@ -0,0 +1,6 @@ +#ifndef CST_VERSION_H +#define CST_VERSION_H + +#define CST_VERSION "0.4.0" + +#endif diff --git a/include/os_compat.h b/include/os_compat.h index b4e5ba1..3cd571c 100644 --- a/include/os_compat.h +++ b/include/os_compat.h @@ -57,6 +57,16 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out, int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out, os_run_result_t *r); +/* Same, with child_hook(ud) run in the child just before exec (Unix only; + * ignored on Windows). A non-zero return aborts the child with exit 126. */ +typedef int (*os_child_hook_fn)(void *ud); +int os_run_capture_ex(const char *cwd, const char *const argv[], + size_t max_out, os_run_result_t *r, + os_child_hook_fn child_hook, void *ud); +int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out, + os_run_result_t *r, os_child_hook_fn child_hook, + void *ud); + /* Child processes get stdin=/dev/null, their own process group, and are * killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when * *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */ diff --git a/scripts/bootstrap_linux.sh b/scripts/bootstrap_linux.sh index 60caa44..d29e9ff 100755 --- a/scripts/bootstrap_linux.sh +++ b/scripts/bootstrap_linux.sh @@ -1,46 +1,116 @@ #!/usr/bin/env bash -# One-shot: clone (optional) + build CST-Agent on Linux, WSL or macOS. +# Install or upgrade CST-Agent on Linux, WSL or macOS. Safe to rerun: an +# existing checkout is fast-forwarded, rebuilt and reinstalled. +# +# bootstrap_linux.sh [DIR] [--no-pull] +# cst-agent-update (installed by this script; same as rerunning it) +# +# Env: CST_AGENT_PREFIX (install prefix, default ~/.local), CST_AGENT_DIR +# (checkout, default: current checkout or ~/CST-agent), CST_AGENT_REPO. set -euo pipefail -REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}" -DIR="${1:-}" - -if [[ -z "$DIR" ]]; then - if [[ -f Makefile && -d src ]]; then - DIR="$(pwd)" - else - DIR="CST-agent" +deps_hint() { + echo "==> deps: gcc/clang make pkg-config libcurl ncursesw" + if [[ "$(uname -s)" == Darwin ]]; then + echo " macOS: xcode-select --install && brew install ncurses curl pkg-config" + elif command -v pacman >/dev/null 2>&1; then + echo " Arch: sudo pacman -S --needed base-devel curl ncurses" + elif command -v apt-get >/dev/null 2>&1; then + echo " Debian/Ubuntu/WSL: sudo apt install build-essential pkg-config libcurl4-openssl-dev libncurses-dev" + elif command -v dnf >/dev/null 2>&1; then + echo " Fedora: sudo dnf install gcc make pkgconf libcurl-devel ncurses-devel" + elif command -v apk >/dev/null 2>&1; then + echo " Alpine: sudo apk add build-base pkgconf curl-dev ncurses-dev" fi -fi +} -if [[ ! -f "$DIR/Makefile" ]]; then - echo "==> git clone $REPO_URL -> $DIR" - git clone "$REPO_URL" "$DIR" -fi -cd "$DIR" +# On NixOS (outside a nix shell) build inside shell.nix. +run_make() { + if [[ -z "${IN_NIX_SHELL:-}" ]] && command -v nix-shell >/dev/null 2>&1 && + [[ -f shell.nix ]]; then + nix-shell --run "make $*" + else + make "$@" + fi +} -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" -elif command -v pacman >/dev/null 2>&1; then - echo " Arch: sudo pacman -S --needed base-devel cmake curl ncurses" -elif command -v apt-get >/dev/null 2>&1; then - echo " Debian/Ubuntu/WSL: sudo apt install build-essential cmake pkg-config libcurl4-openssl-dev libncurses-dev" -elif command -v dnf >/dev/null 2>&1; then - echo " Fedora: sudo dnf install gcc make cmake pkgconf libcurl-devel ncurses-devel" -elif command -v apk >/dev/null 2>&1; then - echo " Alpine: sudo apk add build-base pkgconf curl-dev ncurses-dev" -elif command -v nix-shell >/dev/null 2>&1; then - echo " Nix: nix-shell (uses shell.nix), then rerun this script" -fi +main() { + local repo_url="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}" + local prefix="${CST_AGENT_PREFIX:-$HOME/.local}" + local dir="" pull=1 changed=0 arg + for arg in "$@"; do + case "$arg" in + --no-pull) pull=0 ;; + --rebuilt) changed=1 ;; # internal: re-exec after a pull + -h | --help) + sed -n '2,9p' "$0" + return 0 + ;; + *) dir="$arg" ;; + esac + done + if [[ -z "$dir" ]]; then + if [[ -n "${CST_AGENT_DIR:-}" ]]; then + dir="$CST_AGENT_DIR" + elif [[ -f Makefile && -f src/main.c && -d .git ]]; then + dir="$(pwd)" + else + dir="$HOME/CST-agent" + fi + fi -JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" -echo "==> make" -make -j"$JOBS" -make check || true + local old="" new="" + if [[ ! -f "$dir/Makefile" ]]; then + echo "==> git clone $repo_url -> $dir" + git clone "$repo_url" "$dir" + changed=1 + elif ((pull)); then + old="$(git -C "$dir" rev-parse HEAD)" + if git -C "$dir" diff --quiet && git -C "$dir" diff --cached --quiet; then + echo "==> git pull ($dir)" + git -C "$dir" pull --ff-only + else + echo "warning: $dir has local changes; skipping git pull" >&2 + fi + new="$(git -C "$dir" rev-parse HEAD)" + if [[ "$old" != "$new" ]]; then + echo " $(git -C "$dir" log --oneline "$old..$new" | wc -l) new commit(s)" + # The script itself may have changed: continue with the new version. + exec bash "$dir/scripts/bootstrap_linux.sh" "$dir" --no-pull --rebuilt + fi + fi + cd "$dir" + dir="$(pwd)" -echo -echo "OK: ./cst-agent" -echo "Install: make install PREFIX=\$HOME/.local" -echo "Run it inside a project directory: cst-agent" -echo " (the first start asks for the provider and its API key and saves them)" + 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" < Termux packages" -pkg install -y git clang make pkg-config binutils libcurl ncurses + echo "==> Termux packages" + 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 - echo "==> git clone $REPO_URL -> $DIR" - git clone "$REPO_URL" "$DIR" -fi -cd "$DIR" + local old="" new="" + if [[ ! -f "$dir/Makefile" ]]; then + echo "==> git clone $repo_url -> $dir" + git clone "$repo_url" "$dir" + changed=1 + elif ((pull)); then + old="$(git -C "$dir" rev-parse HEAD)" + if git -C "$dir" diff --quiet && git -C "$dir" diff --cached --quiet; then + echo "==> git pull ($dir)" + git -C "$dir" pull --ff-only + else + echo "warning: $dir has local changes; skipping git pull" >&2 + fi + new="$(git -C "$dir" rev-parse HEAD)" + if [[ "$old" != "$new" ]]; then + echo " $(git -C "$dir" log --oneline "$old..$new" | wc -l) new commit(s)" + # The script itself may have changed: continue with the new version. + exec bash "$dir/scripts/bootstrap_termux.sh" "$dir" --no-pull --rebuilt + fi + fi + cd "$dir" + dir="$(pwd)" -echo "==> build" -make -j"$(nproc 2>/dev/null || echo 4)" -make check || echo "warning: some tests failed (see above)" + echo "==> build" + local jobs + jobs="$(nproc 2>/dev/null || echo 4)" + if ((changed)); then + make clean >/dev/null 2>&1 || true + fi + make -j"$jobs" + make check >/dev/null 2>&1 || echo "warning: some tests failed (run 'make check')" -echo "==> install to $PREFIX/bin" -make install + echo "==> install to $PREFIX/bin" + make install + cat >"$PREFIX/bin/cst-agent-update" <plan_mode) + cst_buf_puts(&b, "\nPLAN MODE: you may only read and research (no file " + "changes, no shell). Investigate, then answer with a " + "concrete step-by-step plan: files to touch, what to " + "change, how to verify. The user switches to Build mode " + "to carry it out.\n"); if (project_notes && project_notes[0]) { size_t n = strlen(project_notes); if (n > PROJECT_NOTES_MAX) @@ -550,6 +557,62 @@ static int needs_confirm(const cst_agent_config_t *cfg, cst_tool_perm_t p) { } } +/* A sandboxed run_shell looked blocked: ask whether to rerun it outside the + * sandbox. On yes, *tr is replaced by the unsandboxed result; on no, a note + * tells the model why it failed. */ +static void sandbox_escalate(turn_t *t, cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, const char *cmd, + cst_tool_result_t *tr) { + cst_agent_t *a = t->a; + int ans = CST_PERM_DENY; + if (t->hooks && t->hooks->on_permission && !a->cancel) { + size_t tail = tr->out_len > 1500 ? 1500 : tr->out_len; + cst_buf_t d = {0}; + cst_buf_puts(&d, CST_PERM_SANDBOX); + if (tr->out) + cst_buf_append(&d, tr->out + tr->out_len - tail, tail); + char *detail = cst_buf_steal(&d); + hook_status(t, "blocked by the sandbox"); + ans = t->hooks->on_permission(c->name, detail ? detail : CST_PERM_SANDBOX, + cmd, t->hooks->userdata); + free(detail); + } + if (ans == CST_PERM_DENY) { + const char *note = "\n[cst: blocked by the sandbox (writes are limited to " + "the workspace and temp dir, no network); the user did " + "not allow running it outside]\n"; + cst_buf_t b = {0}; + if (tr->out) + cst_buf_append(&b, tr->out, tr->out_len); + cst_buf_puts(&b, note); + free(tr->out); + tr->out_len = b.len; + tr->out = cst_buf_steal(&b); + return; + } + if (ans == CST_PERM_ALWAYS_PREFIX) { + char pre[128]; + cst_perms_suggest_prefix(cmd, pre, sizeof(pre)); + if (pre[0]) + cst_perms_allow_prefix(a->perms, pre); + } else if (ans == CST_PERM_ALWAYS_TOOL) { + cst_perms_allow_tool(a->perms, c->name); + } + hook_status(t, "rerunning outside the sandbox"); + cst_tool_result_free(tr); + memset(tr, 0, sizeof(*tr)); + ctx->sandbox = 0; + cst_tool_run(ctx, c, tr); + cst_buf_t b = {0}; + cst_buf_puts(&b, "[cst: the sandbox blocked this command; the user allowed " + "rerunning it outside the sandbox. Output of that rerun:]\n"); + if (tr->out) + cst_buf_append(&b, tr->out, tr->out_len); + free(tr->out); + tr->out_len = b.len; + tr->out = cst_buf_steal(&b); +} + /* Execute (or refuse) one confirmed call. Irreversible once started. */ static void exec_call(turn_t *t, pcall_t *pc) { if (pc->done) @@ -602,11 +665,26 @@ static void exec_call(turn_t *t, pcall_t *pc) { } } } - int allow = 1; + int allow = 1, ask = 0; const char *cmd = def->perm == CST_TP_SHELL ? tool_cmd(c) : NULL; if (!dup && needs_confirm(&a->cfg, def->perm) && !a->cfg.auto_approve && !cst_perms_tool_allowed(a->perms, c->name) && !(cmd && cst_perms_shell_allowed(a->perms, cmd))) { + ask = 1; + if (a->cfg.approval == CST_APPROVAL_AUTO) { + if (def->perm == CST_TP_WRITE) { + ask = 0; + } else if (def->perm == CST_TP_SHELL) { + if (cst_sandbox_probe() != CST_SANDBOX_NONE) { + ctx.sandbox = 1; + ask = 0; + } else if (cmd && cst_shell_is_safe(cmd)) { + ask = 0; + } + } + } + } + if (ask) { if (t->hooks && t->hooks->on_permission) { char *preview = NULL; if (def->preview) @@ -650,6 +728,9 @@ static void exec_call(turn_t *t, pcall_t *pc) { undo_snapshot(a, c); cst_tool_result_t tr = {0}; cst_tool_run(&ctx, c, &tr); + if (ctx.sandbox && cmd && + cst_sandbox_blocked(tr.out, tr.out_len, tr.exit_code)) + sandbox_escalate(t, &ctx, c, cmd, &tr); set_output(pc, tr.out ? tr.out : "", tr.out_len, tr.exit_code); cst_tool_result_free(&tr); if (mutating) diff --git a/src/llm_openai.c b/src/llm_openai.c index 2bbe578..ff56d24 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -698,3 +698,115 @@ finish: free(body); return ret; } + +/* --- model listing -------------------------------------------------------- */ + +#define MODELS_BODY_MAX (2 * 1024 * 1024) + +static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { + cst_buf_t *b = ud; + size_t n = size * nmemb; + if (b->len + n > MODELS_BODY_MAX || cst_buf_append(b, ptr, n) != 0) + return 0; + return n; +} + +static int add_model(char out[][128], int n, int max_out, const char *id) { + if (!id || !id[0] || n >= max_out || strlen(id) >= 128) + return n; + for (int i = 0; i < n; i++) + if (strcmp(out[i], id) == 0) + return n; + snprintf(out[n], 128, "%s", id); + return n + 1; +} + +/* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */ +static int parse_models(const char *json, char out[][128], int max_out) { + cJSON *root = cJSON_Parse(json); + if (!root) + return -1; + const cJSON *arr = root; + if (!cJSON_IsArray(arr)) + arr = cJSON_GetObjectItemCaseSensitive(root, "data"); + if (!cJSON_IsArray(arr)) + arr = cJSON_GetObjectItemCaseSensitive(root, "models"); + int n = -1; + if (cJSON_IsArray(arr)) { + n = 0; + const cJSON *it; + cJSON_ArrayForEach(it, arr) { + const cJSON *id = cJSON_GetObjectItemCaseSensitive(it, "id"); + if (!cJSON_IsString(id)) + id = cJSON_GetObjectItemCaseSensitive(it, "name"); + if (cJSON_IsString(id)) + n = add_model(out, n, max_out, id->valuestring); + else if (cJSON_IsString(it)) + n = add_model(out, n, max_out, it->valuestring); + } + } + cJSON_Delete(root); + return n; +} + +int cst_llm_list_models(const char *api_base, const char *api_key, + char out[][128], int max_out, char *err, + size_t err_sz) { + char dummy[8]; + if (!err || !err_sz) { + err = dummy; + err_sz = sizeof(dummy); + } + err[0] = 0; + if (!api_base || !api_base[0] || !out || max_out <= 0) { + snprintf(err, err_sz, "bad request"); + return -1; + } + CURL *curl = curl_easy_init(); + if (!curl) { + snprintf(err, err_sz, "curl init failed"); + return -1; + } + char url[1024]; + size_t bl = strlen(api_base); + while (bl && api_base[bl - 1] == '/') + bl--; + snprintf(url, sizeof(url), "%.*s/models", (int)bl, api_base); + struct curl_slist *hdrs = curl_slist_append(NULL, "Accept: application/json"); + char auth[700]; + if (api_key && api_key[0]) { + snprintf(auth, sizeof(auth), "Authorization: Bearer %s", api_key); + hdrs = curl_slist_append(hdrs, auth); + } + cst_buf_t body = {0}; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, models_write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + CURLcode rc = curl_easy_perform(curl); + long http = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http); + curl_slist_free_all(hdrs); + curl_easy_cleanup(curl); + memset(auth, 0, sizeof(auth)); + + int n = -1; + if (rc != CURLE_OK) { + snprintf(err, err_sz, "curl: %s", curl_easy_strerror(rc)); + } else if (http == 401 || http == 403) { + snprintf(err, err_sz, "HTTP %ld: the API key was rejected", http); + n = CST_MODELS_AUTH; + } else if (http >= 400) { + snprintf(err, err_sz, "HTTP %ld from %s", http, url); + } else { + n = parse_models(body.buf ? body.buf : "", out, max_out); + if (n < 0) + snprintf(err, err_sz, "unexpected /models response"); + } + cst_buf_free(&body); + return n; +} diff --git a/src/main.c b/src/main.c index 3658412..7b036e6 100644 --- a/src/main.c +++ b/src/main.c @@ -1,5 +1,6 @@ #include "cst_agent.h" #include "cst_tools.h" +#include "cst_version.h" #include "os_compat.h" #include @@ -26,7 +27,9 @@ static void usage(const char *argv0) { " -N, --new start a new session\n" " -R, --resume ID open a session by id or unique prefix\n" " -L, --list-sessions list sessions and exit\n" - " -y, --yolo never ask before shell / writes / web\n" + " -A, --approval MODE ask | auto (default: edits run, shell in\n" + " the sandbox or by safe rules) | yolo\n" + " -y, --yolo same as --approval yolo (this run only)\n" " -C, --confirm LIST shell,write,net (default) | none\n" " -r, --protocol P native-eos (default) | native-inc | ctl |\n" " ctl-heredoc | xml\n" @@ -38,6 +41,7 @@ static void usage(const char *argv0) { " -b, --api-base URL OpenAI-compatible base URL (…/v1)\n" " -s, --session FILE explicit session file\n" " -d, --demo-ctl local CTL parser demo (no API)\n" + " -V, --version print the version and exit\n" "\n" "API keys: the TUI asks for a missing key and saves it per provider\n" " in the config dir (credentials, mode 0600); /key changes it.\n" @@ -48,7 +52,8 @@ static void usage(const char *argv0) { " CST_CONFIRM CST_MAX_STEPS CST_TOOL_OUT_CAP CST_CTX_WINDOW\n" " CST_CTX_TOKENS CST_AUTO_COMPACT=0 CST_SHELL_TIMEOUT\n" " CST_METRICS=0 CST_RECORD_SSE CST_REPLAY_SSE CST_MODELS\n" - " CST_AUTO_APPROVE=1 CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n" + " CST_APPROVAL=ask|auto|yolo CST_AUTO_APPROVE=1 (=yolo)\n" + " CST_SANDBOX=off CST_SHELL CST_NO_PROJECT_NOTES=1 NO_COLOR\n" "TUI: type /help inside for commands and keys\n", argv0); } @@ -235,6 +240,15 @@ int main(int argc, char **argv) { return 1; } + int approval = -1; + const char *env_appr = getenv("CST_APPROVAL"); + if (env_appr && env_appr[0] && cst_approval_parse(env_appr, &approval) != 0) { + fprintf(stderr, "bad CST_APPROVAL=%s (ask | auto | yolo)\n", env_appr); + return 1; + } + if (cfg.auto_approve) + approval = CST_APPROVAL_YOLO; + int do_demo = 0; int force_ask_key = 0; int session_explicit = 0; @@ -261,12 +275,14 @@ int main(int argc, char **argv) { {"resume", required_argument, 0, 'R'}, {"list-sessions", no_argument, 0, 'L'}, {"yolo", no_argument, 0, 'y'}, + {"approval", required_argument, 0, 'A'}, {"confirm", required_argument, 0, 'C'}, {"effort", required_argument, 0, 'e'}, {"help", no_argument, 0, 'h'}, + {"version", no_argument, 0, 'V'}, {0, 0, 0, 0}}; int c; - while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahNcR:LyC:e:", opts, NULL)) != + while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) != -1) { switch (c) { case 'w': @@ -334,7 +350,14 @@ int main(int argc, char **argv) { do_list = 1; break; case 'y': - cfg.auto_approve = 1; + approval = CST_APPROVAL_YOLO; + break; + case 'A': + if (cst_approval_parse(optarg, &approval) != 0) { + fprintf(stderr, "bad --approval %s (ask | auto | yolo)\n", optarg); + curl_global_cleanup(); + return 1; + } break; case 'C': if (parse_confirm(optarg, &cfg) != 0) { @@ -356,6 +379,10 @@ int main(int argc, char **argv) { usage(argv[0]); curl_global_cleanup(); return 0; + case 'V': + printf("cst-agent %s\n", CST_VERSION); + curl_global_cleanup(); + return 0; default: usage(argv[0]); curl_global_cleanup(); @@ -425,6 +452,12 @@ int main(int argc, char **argv) { while (alen > 0 && cfg.api_base[alen - 1] == '/') cfg.api_base[--alen] = 0; + /* Option > environment > last picked (settings.json) > auto. */ + if (approval < 0 && cst_approval_parse(saved.approval, &approval) != 0) + approval = CST_DEFAULT_APPROVAL; + cfg.approval = approval; + cfg.auto_approve = approval == CST_APPROVAL_YOLO; + /* Headless defaults to auto-approve shell/write (no interactive confirm). */ if (cfg.headless) cfg.auto_approve = 1; diff --git a/src/os_compat.c b/src/os_compat.c index 7f94528..7026934 100644 --- a/src/os_compat.c +++ b/src/os_compat.c @@ -588,9 +588,23 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out, r->exit_code = (int)code; return 0; } + +int os_run_capture_ex(const char *cwd, const char *const argv[], + size_t max_out, os_run_result_t *r, + os_child_hook_fn child_hook, void *ud) { + (void)child_hook; + (void)ud; + return os_run_capture(cwd, argv, max_out, r); +} #else int os_run_capture(const char *cwd, const char *const argv[], size_t max_out, os_run_result_t *r) { + return os_run_capture_ex(cwd, argv, max_out, r, NULL, NULL); +} + +int os_run_capture_ex(const char *cwd, const char *const argv[], + size_t max_out, os_run_result_t *r, + os_child_hook_fn child_hook, void *ud) { if (!argv || !argv[0] || !r) return -1; if (max_out == 0) @@ -619,6 +633,13 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out, close(pipefd[1]); if (cwd && cwd[0] && chdir(cwd) != 0) _exit(127); + if (child_hook && child_hook(ud) != 0) { + static const char msg[] = "[cst: sandbox setup failed; command not " + "run]\n"; + ssize_t w = write(STDERR_FILENO, msg, sizeof(msg) - 1); + (void)w; + _exit(126); + } execvp(argv[0], (char *const *)argv); _exit(127); } @@ -707,6 +728,12 @@ int os_run_capture(const char *cwd, const char *const argv[], size_t max_out, int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out, os_run_result_t *r) { + return os_run_shell_ex(cwd, cmd, max_out, r, NULL, NULL); +} + +int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out, + os_run_result_t *r, os_child_hook_fn child_hook, + void *ud) { if (!cmd || !r) return -1; char shell[OS_PATH_MAX]; @@ -731,10 +758,10 @@ int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out, if (is_busybox) { const char *argv[] = {shell, "sh", "-c", cmd, NULL}; - return os_run_capture(cwd, argv, max_out, r); + return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud); } else { const char *argv[] = {shell, "-c", cmd, NULL}; - return os_run_capture(cwd, argv, max_out, r); + return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud); } } diff --git a/src/permissions.c b/src/permissions.c index 4a92fec..56acf03 100644 --- a/src/permissions.c +++ b/src/permissions.c @@ -221,3 +221,229 @@ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz) { if (has_shell_meta(out)) out[0] = 0; } + +/* --- read-only command rules ---------------------------------------------- */ + +#define SAFE_MAX_WORDS 64 +#define SAFE_WORD_MAX 256 + +typedef struct { + char w[SAFE_MAX_WORDS][SAFE_WORD_MAX]; + int n; +} seg_t; + +static int word_in(const char *w, const char *const *list, size_t n) { + for (size_t i = 0; i < n; i++) + if (strcmp(w, list[i]) == 0) + return 1; + return 0; +} + +static int has_prefix(const char *s, const char *p) { + return strncmp(s, p, strlen(p)) == 0; +} + +static int git_safe(const seg_t *s) { + static const char *const SUB[] = {"status", "diff", "log", "show", "branch"}; + static const char *const BRANCH_OK[] = { + "-a", "-r", "-v", "-vv", "-l", + "--list", "--all", "--remotes", "--verbose", "--show-current", + "--merged", "--no-merged", "--contains", "--no-contains", + "--no-color", "--color"}; + int i = 1; + while (i < s->n && strcmp(s->w[i], "--no-pager") == 0) + i++; + if (i >= s->n || !word_in(s->w[i], SUB, sizeof(SUB) / sizeof(SUB[0]))) + return 0; + int branch = strcmp(s->w[i], "branch") == 0, listing = 0; + for (int j = i + 1; j < s->n; j++) { + const char *a = s->w[j]; + if (has_prefix(a, "--output") || has_prefix(a, "--ext-diff") || + has_prefix(a, "--textconv")) + return 0; + if (!branch) + continue; + if (a[0] == '-') { + if (!word_in(a, BRANCH_OK, sizeof(BRANCH_OK) / sizeof(BRANCH_OK[0])) && + !has_prefix(a, "--sort=") && !has_prefix(a, "--format=")) + return 0; + if (strcmp(a, "-l") == 0 || strcmp(a, "--list") == 0 || + strcmp(a, "-a") == 0 || strcmp(a, "-r") == 0 || + strcmp(a, "--all") == 0 || strcmp(a, "--remotes") == 0 || + has_prefix(a, "--contains") || has_prefix(a, "--merged") || + has_prefix(a, "--no-merged") || has_prefix(a, "--no-contains")) + listing = 1; + } else if (!listing) { + return 0; /* "git branch NAME" creates a branch */ + } + } + return 1; +} + +static int seg_safe(const seg_t *s) { + static const char *const CMDS[] = { + "ls", "cat", "head", "tail", "wc", "grep", "rg", + "find", "git", "pwd", "echo", "which", "file", "stat", + "du", "tree", "true", "false"}; + static const char *const FIND_BAD[] = {"-exec", "-execdir", "-delete", + "-ok", "-okdir", "-fprint", + "-fprint0", "-fprintf", "-fls"}; + if (s->n == 0) + return 0; + const char *c = s->w[0]; + if (!word_in(c, CMDS, sizeof(CMDS) / sizeof(CMDS[0]))) + return 0; + if (strcmp(c, "git") == 0) + return git_safe(s); + for (int i = 1; i < s->n; i++) { + const char *a = s->w[i]; + if (strcmp(c, "find") == 0 && + word_in(a, FIND_BAD, sizeof(FIND_BAD) / sizeof(FIND_BAD[0]))) + return 0; + if (strcmp(c, "rg") == 0 && has_prefix(a, "--pre")) + return 0; + if (strcmp(c, "tree") == 0 && strcmp(a, "-o") == 0) + return 0; + if (strcmp(c, "file") == 0 && (strcmp(a, "-C") == 0 || + strcmp(a, "--compile") == 0)) + return 0; + } + return 1; +} + +/* Accepts "2>&1", ">&2", ">/dev/null", "2>/dev/null", "&>/dev/null". + * *pp points at '>'; on success it is advanced past the target. */ +static int redirect_ok(const char **pp) { + const char *p = *pp + 1; + if (*p == '>') + return 0; /* append */ + while (*p == ' ' || *p == '\t') + p++; + if (*p == '&' && (p[1] == '1' || p[1] == '2')) { + p += 2; + } else if (strncmp(p, "/dev/null", 9) == 0) { + p += 9; + } else { + return 0; + } + if (*p && !strchr(" \t;|&", *p)) + return 0; + *pp = p; + return 1; +} + +static int seg_push_word(seg_t *s, char *word, size_t *wl, int *in_word) { + if (!*in_word) + return 1; + if (s->n >= SAFE_MAX_WORDS) + return 0; + word[*wl] = 0; + memcpy(s->w[s->n++], word, *wl + 1); + *wl = 0; + *in_word = 0; + return 1; +} + +int cst_shell_is_safe(const char *cmd) { + if (!cmd) + return 0; + if (strstr(cmd, "$(") || strchr(cmd, '`') || strstr(cmd, "<(") || + strstr(cmd, ">(") || strchr(cmd, '\n') || strchr(cmd, '\r')) + return 0; + seg_t *s = calloc(1, sizeof(*s)); + if (!s) + return 0; + char word[SAFE_WORD_MAX]; + size_t wl = 0; + int in_word = 0, nseg = 0, ok = 1; + char q = 0; + for (const char *p = cmd;; p++) { + char ch = *p; + if (q) { + if (ch == 0) { + ok = 0; + break; + } + if (ch == q) { + q = 0; + continue; + } + if (q == '"' && ch == '\\' && p[1]) { + ch = *++p; + } + if (wl + 1 >= sizeof(word)) { + ok = 0; + break; + } + word[wl++] = ch; + continue; + } + if (ch == '\'' || ch == '"') { + q = ch; + in_word = 1; + continue; + } + if (ch == '\\' && p[1]) { + if (wl + 1 >= sizeof(word)) { + ok = 0; + break; + } + word[wl++] = *++p; + in_word = 1; + continue; + } + int sep = ch == 0 || ch == ';' || ch == '|' || + (ch == '&' && p[1] == '&'); + if (ch == '>' || (ch == '&' && p[1] == '>') || + ((ch >= '0' && ch <= '9') && !in_word && p[1] == '>')) { + if (ch != '>') + p++; + if (!redirect_ok(&p)) { + ok = 0; + break; + } + p--; + continue; + } + if (ch == '&' && !sep) { + ok = 0; /* background job */ + break; + } + if (ch == '<' && p[1] == '<') { + ok = 0; /* heredoc */ + break; + } + if (sep || ch == ' ' || ch == '\t' || ch == '<') { + if (!seg_push_word(s, word, &wl, &in_word)) { + ok = 0; + break; + } + if (sep) { + if (s->n > 0) { + if (!seg_safe(s)) { + ok = 0; + break; + } + nseg++; + } else if (ch == '|' || ch == '&') { + ok = 0; /* "| x" or "a && && b" */ + break; + } + s->n = 0; + if (ch == 0) + break; + if ((ch == '|' && p[1] == '|') || ch == '&') + p++; + } + continue; + } + if (wl + 1 >= sizeof(word)) { + ok = 0; + break; + } + word[wl++] = ch; + in_word = 1; + } + free(s); + return ok && nseg > 0; +} diff --git a/src/sandbox_exec.c b/src/sandbox_exec.c new file mode 100644 index 0000000..4107415 --- /dev/null +++ b/src/sandbox_exec.c @@ -0,0 +1,319 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include "cst_sandbox.h" + +#include +#include +#include +#include + +#if defined(__linux__) && !defined(__ANDROID__) +#define CST_HAVE_LANDLOCK 1 +#include +#include +#include +#include +#include +#elif defined(__APPLE__) +#define CST_HAVE_SEATBELT 1 +#include +#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; +} diff --git a/src/settings.c b/src/settings.c index c765cff..ab0639a 100644 --- a/src/settings.c +++ b/src/settings.c @@ -40,6 +40,8 @@ int cst_settings_load(cst_settings_t *s) { cJSON_GetObjectItemCaseSensitive(root, "provider")); copy_str(s->effort, sizeof(s->effort), cJSON_GetObjectItemCaseSensitive(root, "effort")); + copy_str(s->approval, sizeof(s->approval), + cJSON_GetObjectItemCaseSensitive(root, "approval")); const cJSON *models = cJSON_GetObjectItemCaseSensitive(root, "models"); const cJSON *m; cJSON_ArrayForEach(m, models) { @@ -69,6 +71,8 @@ int cst_settings_save(const cst_settings_t *s) { cJSON_AddStringToObject(root, "provider", s->provider); if (s->effort[0]) cJSON_AddStringToObject(root, "effort", s->effort); + if (s->approval[0]) + cJSON_AddStringToObject(root, "approval", s->approval); cJSON *models = cJSON_AddObjectToObject(root, "models"); for (int i = 0; i < s->nmodels; i++) cJSON_AddStringToObject(models, s->models[i].provider, s->models[i].model); @@ -93,6 +97,22 @@ int cst_settings_save(const cst_settings_t *s) { return rc; } +static const char *const APPROVAL_NAMES[] = {"ask", "auto", "yolo"}; + +const char *cst_approval_name(int a) { + return a >= 0 && a <= CST_APPROVAL_YOLO ? APPROVAL_NAMES[a] : "auto"; +} + +int cst_approval_parse(const char *s, int *out) { + for (int i = 0; s && i <= CST_APPROVAL_YOLO; i++) { + if (strcmp(s, APPROVAL_NAMES[i]) == 0) { + *out = i; + return 0; + } + } + return -1; +} + const char *cst_settings_model(const cst_settings_t *s, const char *provider) { for (int i = 0; provider && i < s->nmodels; i++) { if (strcmp(s->models[i].provider, provider) == 0 && s->models[i].model[0]) @@ -119,3 +139,10 @@ int cst_settings_remember(const cst_agent_config_t *cfg) { snprintf(s.models[i].model, sizeof(s.models[i].model), "%s", cfg->model); return cst_settings_save(&s); } + +int cst_settings_remember_approval(int approval) { + cst_settings_t s; + cst_settings_load(&s); + snprintf(s.approval, sizeof(s.approval), "%s", cst_approval_name(approval)); + return cst_settings_save(&s); +} diff --git a/src/tools/registry.c b/src/tools/registry.c index f09b7c8..d8c0894 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -25,7 +25,7 @@ static int run_apply_patch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, } static int run_shell(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r) { - return cst_tool_run_shell(ctx->workspace_root, c, r); + return cst_tool_run_shell_ex(ctx->workspace_root, c, r, ctx->sandbox); } #define S "string" @@ -193,6 +193,8 @@ int cst_tool_active(const cst_agent_config_t *cfg, const cst_tool_def_t *t) { return 1; if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal) return 0; + if (cfg->plan_mode && (t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL)) + return 0; int i = (int)(t - TOOLS); return !(cfg->tools_off & (1ULL << i)); } diff --git a/src/tools/tools.c b/src/tools/tools.c index f8d1ad1..d8737d3 100644 --- a/src/tools/tools.c +++ b/src/tools/tools.c @@ -10,6 +10,7 @@ #include "cJSON.h" #include "cst_agent.h" +#include "cst_sandbox.h" #include "cst_util.h" #include "os_compat.h" #include "tools_internal.h" @@ -288,6 +289,12 @@ int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call, int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call, cst_tool_result_t *r) { + return cst_tool_run_shell_ex(workspace_root, call, r, 0); +} + +int cst_tool_run_shell_ex(const char *workspace_root, + const ctl_tool_call_t *call, cst_tool_result_t *r, + int sandboxed) { const char *cmd = tool_arg(call, "cmd"); if (!cmd) cmd = tool_arg(call, "command"); @@ -299,7 +306,10 @@ int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call, return tool_set_result(r, "error: bad workspace", 1); os_run_result_t rr = {0}; - if (os_run_shell_c(root_real, cmd, SHELL_OUT_MAX, &rr) != 0) { + int rc = sandboxed + ? cst_sandbox_run_shell(root_real, cmd, SHELL_OUT_MAX, &rr) + : os_run_shell_c(root_real, cmd, SHELL_OUT_MAX, &rr); + if (rc != 0) { os_run_result_free(&rr); return tool_set_result(r, "error: shell exec failed", 1); } diff --git a/src/tui.c b/src/tui.c index 9bdc458..3be938b 100644 --- a/src/tui.c +++ b/src/tui.c @@ -5,8 +5,10 @@ #include "cJSON.h" #include "cst_agent.h" #include "cst_perms.h" +#include "cst_sandbox.h" #include "cst_tools.h" #include "cst_util.h" +#include "cst_version.h" #include "os_compat.h" #include "sashidown.h" @@ -142,7 +144,22 @@ enum { /* OpenCode-style structured diff (bg distinguishes add/del/context) */ CP_DIFF_ADD, CP_DIFF_DEL, - CP_DIFF_CTX + CP_DIFF_CTX, + /* raised surfaces: input box / user message, sidebar */ + CP_BOX, + CP_BOX_MUTED, + CP_BOX_ACCENT, + CP_BOX_PRIMARY, + CP_BOX_BRIGHT, + CP_SIDE, + CP_SIDE_MUTED, + CP_SIDE_BRIGHT, + CP_SIDE_SUCCESS, + CP_SIDE_PRIMARY, + CP_ACCENT, /* blue on panel */ + CP_BRIGHT, /* near-white on panel */ + CP_LOGO_DIM, + CP_SEL /* selected list row */ }; typedef enum { @@ -151,7 +168,8 @@ typedef enum { BLK_THINK, BLK_TOOL, BLK_STATUS, - BLK_SYSTEM + BLK_SYSTEM, + BLK_FOOTER /* "▣ Build · model · 4.7s" after a turn */ } block_kind_t; typedef struct { @@ -183,9 +201,22 @@ typedef struct { WINDOW *todow; WINDOW *status; WINDOW *input; + WINDOW *side; int rows, cols; int input_rows; /* 1..INPUT_MAX_ROWS */ int todo_rows; + int home; /* empty transcript: centered start screen */ + int show_side; /* /sidebar */ + int side_w; + int in_x, in_y, in_w, in_h; /* input box, screen coords */ + int log_x, log_y; /* logw origin, screen coords */ + int *row_blk; /* logw row → hist block (-1 none) */ + int row_blk_cap; + int cmd_x0, cmd_x1; /* "ctrl+p commands" in the status row */ + int mouse_on; + const char *tip; + const char *sandbox_name; /* "landlock" / "seatbelt" / "rules" */ + char where[300]; /* ~/path:branch */ blocks_t hist; /* sealed blocks */ block_t live; /* streaming assistant (kind set when as_live) */ @@ -246,6 +277,7 @@ typedef struct { char perm_detail[512]; char *perm_preview; char perm_prefix[128]; + int perm_sandbox; /* asking to rerun a blocked command outside the sandbox */ int perm_scroll; /* ask_user handshake (worker → main). */ @@ -388,8 +420,15 @@ static void theme_init(void) { short err = COLOR_RED; short assist = COLOR_CYAN; short text = COLOR_WHITE; + short box = COLOR_BLACK, side = COLOR_BLACK; + short accent = COLOR_BLUE, bright = COLOR_WHITE, logo_dim = COLOR_CYAN; if (COLORS >= 256) { + box = 235; + side = 234; + accent = 75; + bright = 255; + logo_dim = 242; /* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors. * panel #121212→233, primary #fab283→216, muted #808080→244 */ primary = 216; @@ -408,6 +447,9 @@ static void theme_init(void) { init_color(114, 400, 750, 450); init_color(167, 850, 320, 320); init_color(74, 350, 700, 750); + init_color(235, 118, 118, 118); /* #1e1e1e raised box */ + init_color(234, 90, 90, 90); /* #171717 sidebar */ + init_color(75, 361, 612, 961); /* #5c9cf5 accent */ } } else if (can_change_color() && COLORS >= 8) { init_color(COLOR_BLACK, 70, 70, 70); /* panel #121212 */ @@ -457,6 +499,35 @@ static void theme_init(void) { init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK); init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK); } + if (COLORS < 256) { + box = side = panel; + accent = COLORS >= 8 && !can_change_color() ? COLOR_BLUE : assist; + } + init_pair(CP_BOX, text, box); + init_pair(CP_BOX_MUTED, muted, box); + init_pair(CP_BOX_ACCENT, accent, box); + init_pair(CP_BOX_PRIMARY, primary, box); + init_pair(CP_BOX_BRIGHT, bright, box); + init_pair(CP_SIDE, text, side); + init_pair(CP_SIDE_MUTED, muted, side); + init_pair(CP_SIDE_BRIGHT, bright, side); + init_pair(CP_SIDE_SUCCESS, success, side); + init_pair(CP_SIDE_PRIMARY, primary, side); + init_pair(CP_ACCENT, accent, panel); + init_pair(CP_BRIGHT, bright, panel); + init_pair(CP_LOGO_DIM, logo_dim, panel); + init_pair(CP_SEL, panel, accent); +} + +/* Blank the window into the virtual screen before deleting it: refreshing + * stdscr doesn't reliably cover the cells a deleted window leaves behind. */ +static void drop_win(WINDOW **w) { + if (*w) { + werase(*w); + wnoutrefresh(*w); + delwin(*w); + } + *w = NULL; } static void theme_paint_root(void) { @@ -670,14 +741,13 @@ static void set_status(tui_t *t, const char *msg) { tui_unlock(t); } +/* Empty status_msg means "show ~/path:branch" in the status row. */ static void status_idle(tui_t *t) { if (t->queue_n > 0) snprintf(t->status_msg, sizeof(t->status_msg), - "ready · %d queued · empty Enter sends the next one", t->queue_n); + "%d queued · empty Enter sends the next one", t->queue_n); else - snprintf(t->status_msg, sizeof(t->status_msg), - "ready · / commands · @ files · Tab thought · PgUp scroll · " - "Ctrl+C twice quits"); + t->status_msg[0] = 0; } /* --- live assistant ------------------------------------------------------- */ @@ -758,9 +828,9 @@ static void collapse_open_think(tui_t *t) { if (sec < 0) sec = 0; if (sec < 1.0) - snprintf(title, sizeof(title), "Thought · %.1fs", sec); + snprintf(title, sizeof(title), "Thought: %dms", (int)(sec * 1000)); else - snprintf(title, sizeof(title), "Thought · %.1fs", sec); + snprintf(title, sizeof(title), "Thought: %.1fs", sec); t->think_timing = 0; } else { snprintf(title, sizeof(title), "Thought"); @@ -895,6 +965,16 @@ static void sgr_apply(sty_t *st, int code) { case 95: st->pair = CP_SGR_MAGENTA; break; + case 34: + case 94: + st->pair = CP_ACCENT; + break; + case 90: + st->pair = CP_MUTED; + break; + case 97: + st->pair = CP_BRIGHT; + break; case 39: st->pair = 0; break; @@ -954,6 +1034,11 @@ static wchar_t ascii_glyph(wchar_t wc) { return L'x'; case 0x25cb: /* ○ */ return L'o'; + case 0x25cf: /* ● */ + case 0x2022: /* • */ + return L'*'; + case 0x25a3: /* ▣ */ + return L'#'; default: return g_utf8 ? wc : L'?'; } @@ -982,9 +1067,14 @@ typedef struct { int pair; /* base color pair for whole line (0 = default) */ int bold; int dim; - int indent; /* leading columns already accounted in text */ + int indent; /* columns left blank before text */ + int fill; /* pair painted across the whole row (0 = none) */ + int bar; /* pair for a "┃" in column 0 (0 = none) */ + int blk; /* hist block index, -1 for live / none */ } dline_t; +static int g_dl_blk = -1; /* block being flattened, stamped into dline_t */ + typedef struct { dline_t *lines; int n; @@ -1017,9 +1107,21 @@ static int dlines_push(dlines_t *dl, const char *text, int pair, int bold, L->bold = bold; L->dim = dim; L->indent = 0; + L->fill = 0; + L->bar = 0; + L->blk = g_dl_blk; return 0; } +/* Raised row: bar in column 0, the rest painted with `fill`. */ +static void dlines_push_box(dlines_t *dl, const char *text, int pair, + int fill, int bar) { + if (dlines_push(dl, text, pair, 0, 0) == 0) { + dl->lines[dl->n - 1].fill = fill; + dl->lines[dl->n - 1].bar = bar; + } +} + static void push_wrapped(dlines_t *dl, const char *prefix, const char *body, int width, int pair, int bold, int dim, int ansi) { (void)ansi; @@ -1392,48 +1494,43 @@ static void push_diff_or_body(dlines_t *dl, const char *body, int width, static void block_to_lines(const block_t *b, int width, int details_all, dlines_t *dl) { - char prefix[128]; char head[512]; switch (b->kind) { - case BLK_USER: - snprintf(prefix, sizeof(prefix), "┃ "); - dlines_push(dl, "┃ You", CP_PRIMARY, 1, 0); - push_wrapped(dl, prefix, b->body ? b->body : "", width, CP_PRIMARY, 0, 0, - 0); + case BLK_USER: { + int n0 = dl->n; + dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT); + push_wrapped(dl, " ", b->body ? b->body : "", width - 1, CP_BOX, 0, 0, 0); + for (int i = n0 + 1; i < dl->n; i++) { + dl->lines[i].fill = CP_BOX; + dl->lines[i].bar = CP_BOX_ACCENT; + } + dlines_push_box(dl, "", CP_BOX, CP_BOX, CP_BOX_ACCENT); dlines_push(dl, "", 0, 0, 0); break; + } case BLK_ASSISTANT: { - dlines_push(dl, "┃ Assistant", CP_ASSIST, 1, 0); const char *src = b->render ? b->render : (b->body ? b->body : ""); - push_wrapped(dl, "┃ ", src, width, CP_ASSIST, 0, 0, 1); + push_wrapped(dl, "", src, width, CP_PANEL, 0, 0, 1); dlines_push(dl, "", 0, 0, 0); break; } case BLK_THINK: - if (b->expanded) { - snprintf(head, sizeof(head), "▼ %s", b->title ? b->title : "Thought"); - dlines_push(dl, head, CP_MUTED, 0, 1); + snprintf(head, sizeof(head), "%s %s", b->expanded ? "-" : "+", + b->title ? b->title : "Thought"); + dlines_push(dl, head, CP_PRIMARY, 0, 0); + if (b->expanded) push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0); - } else { - /* One-liner: title + truncated body hint */ - const char *body = b->body ? b->body : ""; - size_t bl = strlen(body); - while (bl > 0 && (body[bl - 1] == '\n' || body[bl - 1] == ' ')) - bl--; - char one[200]; - size_t take = bl > 60 ? 60 : bl; - /* skip to first non-space for preview */ - size_t off = 0; - while (off < take && (body[off] == ' ' || body[off] == '\n')) - off++; - snprintf(one, sizeof(one), "▶ %s %.*s%s", - b->title ? b->title : "Thought", (int)(take > off ? take - off : 0), - body + off, bl > 60 ? "…" : ""); - dlines_push(dl, one, CP_MUTED, 0, 1); - } + dlines_push(dl, "", 0, 0, 0); + break; + + case BLK_FOOTER: + snprintf(head, sizeof(head), "\033[34m%s\033[0m %s", + g_ascii ? "#" : "▣", b->body ? b->body : ""); + dlines_push(dl, head, CP_MUTED, 0, 0); + dlines_push(dl, "", 0, 0, 0); break; case BLK_TOOL: { @@ -1504,23 +1601,43 @@ static void block_to_lines(const block_t *b, int width, int details_all, snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "", (b->title && b->body && b->body[0]) ? " " : "", b->body ? b->body : ""); - dlines_push(dl, head, CP_MUTED, 0, 1); + push_wrapped(dl, "", head, width, CP_MUTED, 0, 1, 0); break; } } +/* Non-box lines sit two columns right of the user-message bar. */ +#define LOG_INDENT 2 + +static void block_to_lines_indented(const block_t *b, int width, + int details_all, dlines_t *dl) { + int n0 = dl->n; + if (b->kind == BLK_USER) { + block_to_lines(b, width, details_all, dl); + return; + } + block_to_lines(b, width - LOG_INDENT, details_all, dl); + for (int i = n0; i < dl->n; i++) + if (!dl->lines[i].fill) + dl->lines[i].indent = LOG_INDENT; +} + static void build_display(tui_t *t, dlines_t *dl, int width) { dlines_free(dl); dl->lines = NULL; dl->n = dl->cap = 0; - for (int i = 0; i < t->hist.n; i++) - block_to_lines(&t->hist.items[i], width, t->details_all, dl); + for (int i = 0; i < t->hist.n; i++) { + g_dl_blk = i; + block_to_lines_indented(&t->hist.items[i], width, t->details_all, dl); + } + g_dl_blk = -1; if (t->as_live) - block_to_lines(&t->live, width, t->details_all, dl); + block_to_lines_indented(&t->live, width, t->details_all, dl); } -static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s, - int base_pair, int base_bold, int base_dim) { +/* Draws s at (y, x0) within maxw columns; returns the columns used. */ +static int draw_ansi_at(WINDOW *win, int y, int x0, int maxw, const char *s, + int base_pair, int base_bold, int base_dim) { sty_t sty = {0}; sty.pair = base_pair; if (base_bold) @@ -1553,10 +1670,16 @@ static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s, attrs |= COLOR_PAIR(sty.pair); else if (base_pair && HAS_COLOR()) attrs |= COLOR_PAIR(base_pair); - put_wc(win, y, col, wc, attrs); + put_wc(win, y, x0 + col, wc, attrs); col += cw; i += eaten; } + return col; +} + +static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s, + int base_pair, int base_bold, int base_dim) { + draw_ansi_at(win, y, 0, maxw, s, base_pair, base_bold, base_dim); } static double since_ms(const struct timespec *t0) { @@ -1566,77 +1689,223 @@ static double since_ms(const struct timespec *t0) { (double)(now.tv_nsec - t0->tv_nsec) / 1e6; } -static void draw_header(tui_t *t) { - if (!t->header) - return; - const cst_agent_config_t *cfg = cst_agent_config(t->agent); - werase(t->header); - if (HAS_COLOR()) - wbkgd(t->header, COLOR_PAIR(CP_HEADER)); - if (!cfg) { - wrefresh(t->header); +static const char *dot_sep(void) { return g_ascii ? " - " : " · "; } + +/* Branch from the nearest .git/HEAD at or above dir (worktree files too). */ +static void git_branch(const char *dir, char *out, size_t sz) { + out[0] = 0; + char d[OS_PATH_MAX], p[OS_PATH_MAX + 64], line[OS_PATH_MAX]; + snprintf(d, sizeof(d), "%s", dir); + FILE *f = NULL; + for (int depth = 0; depth < 64 && d[0]; depth++) { + snprintf(p, sizeof(p), "%s/.git/HEAD", d); + if ((f = fopen(p, "r")) != NULL) + break; + snprintf(p, sizeof(p), "%s/.git", d); + FILE *g = fopen(p, "r"); + if (g) { + int ok = fgets(line, sizeof(line), g) && strncmp(line, "gitdir: ", 8) == 0; + fclose(g); + if (ok) { + line[strcspn(line, "\r\n")] = 0; + int n = line[8] == '/' + ? snprintf(p, sizeof(p), "%s/HEAD", line + 8) + : snprintf(p, sizeof(p), "%s/%s/HEAD", d, line + 8); + if (n > 0 && (size_t)n < sizeof(p)) + f = fopen(p, "r"); + break; + } + } + char *slash = strrchr(d, '/'); + if (!slash || slash == d) + break; + *slash = 0; + } + if (!f) return; + if (fgets(line, sizeof(line), f)) { + line[strcspn(line, "\r\n")] = 0; + const char *ref = strncmp(line, "ref: refs/heads/", 16) == 0 ? line + 16 + : line; + size_t n = ref == line ? 7 : strlen(ref); + if (n >= sz) + n = sz - 1; + memcpy(out, ref, n); + out[n] = 0; } - char line[640], right[96] = ""; - const char *dot = g_ascii ? " | " : " · "; - long win = cst_model_context_tokens(cfg->model); - long used = cst_agent_last_prompt_tokens(t->agent); - int pct = win > 0 ? (int)(used * 100 / win) : 0; - const char *pc = pct >= 80 ? "\033[31m" : pct >= 60 ? "\033[33m" : ""; - const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort) - ? cfg->effort - : NULL; - int k = snprintf(line, sizeof(line), - " CST-Agent%s%s/%s%s%s%s%s%sctx %s%d%%\033[39m", dot, - cfg->provider, cfg->model, eff ? dot : "", eff ? eff : "", - dot, cst_protocol_name(cfg->protocol), dot, pc, pct); - if (k > 0 && (size_t)k < sizeof(line) && t->title[0]) - k += snprintf(line + k, sizeof(line) - (size_t)k, "%s%.40s", dot, t->title); - if (k > 0 && (size_t)k < sizeof(line) && cfg->auto_approve) - snprintf(line + k, sizeof(line) - (size_t)k, "%s\033[31;1mYOLO\033[0m", dot); - if (t->busy) { - static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼", - "⠴", "⠦", "⠧", "⠇", "⠏"}; - static const char *const asc[] = {"|", "/", "-", "\\"}; - const char *f = g_ascii ? asc[t->spin % 4] : uni[t->spin % 10]; - snprintf(right, sizeof(right), "%s working %.0fs ", f, - since_ms(&t->turn_t0) / 1000.0); - } - int rw = utf8_cols(right, strlen(right)); - draw_ansi_line(t->header, 0, t->cols - rw - 1, line, CP_HEADER, 1, 0); - if (rw) { - if (HAS_COLOR()) - wattron(t->header, COLOR_PAIR(CP_PRIMARY)); - mvwaddstr(t->header, 0, t->cols - rw - 1, right); - if (HAS_COLOR()) - wattroff(t->header, COLOR_PAIR(CP_PRIMARY)); - } - wrefresh(t->header); + fclose(f); } +static void where_label(const char *ws, char *out, size_t sz) { + const char *home = getenv("HOME"); + size_t hl = home ? strlen(home) : 0; + char br[128]; + git_branch(ws, br, sizeof(br)); + int tilde = hl > 1 && strncmp(ws, home, hl) == 0 && (ws[hl] == '/' || !ws[hl]); + int n = snprintf(out, sz, "%s%s%s%s", tilde ? "~" : "", tilde ? ws + hl : ws, + br[0] ? ":" : "", br); + if (n < 0) + out[0] = 0; +} + +/* Display columns of s, ignoring escape sequences. */ +static int vis_cols(const char *s) { + int cols = 0; + size_t n = strlen(s), i = 0; + while (i < n) { + size_t sk = ansi_skip(s + i, n - i); + if (sk) { + i += sk; + continue; + } + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + cols += utf8_width(wc); + i += eaten; + } + return cols; +} + +static const char *spinner_frame(const tui_t *t) { + static const char *const uni[] = {"⠋", "⠙", "⠹", "⠸", "⠼", + "⠴", "⠦", "⠧", "⠇", "⠏"}; + static const char *const asc[] = {"|", "/", "-", "\\"}; + return g_ascii ? asc[t->spin % 4] : uni[t->spin % 10]; +} + +static void fmt_tokens(long n, char *out, size_t sz) { + if (n >= 1000000) + snprintf(out, sz, "%.1fM", (double)n / 1e6); + else if (n >= 1000) + snprintf(out, sz, "%.1fK", (double)n / 1e3); + else + snprintf(out, sz, "%ld", n); +} + +static int ctx_percent(const tui_t *t, long *used) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + long win = cfg ? cst_model_context_tokens(cfg->model) : 0; + *used = cst_agent_last_prompt_tokens(t->agent); + return win > 0 ? (int)(*used * 100 / win) : 0; +} + +static int approval_mode(const tui_t *t) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + if (!cfg) + return CST_APPROVAL_ASK; + return cfg->auto_approve ? CST_APPROVAL_YOLO : cfg->approval; +} + +static void approval_label(const tui_t *t, char *out, size_t sz) { + int a = approval_mode(t); + if (a == CST_APPROVAL_YOLO) + snprintf(out, sz, "YOLO"); + else if (a == CST_APPROVAL_AUTO) + snprintf(out, sz, "auto%s%s", dot_sep(), t->sandbox_name); + else + snprintf(out, sz, "ask"); +} + +/* Bottom row: where/status on the left, context + palette hint on the right. */ static void draw_status(tui_t *t) { if (!t->status) return; werase(t->status); if (HAS_COLOR()) - wbkgd(t->status, COLOR_PAIR(CP_STATUS)); - const char *msg = t->status_msg[0] ? t->status_msg : "ready"; - char line[600]; - snprintf(line, sizeof(line), " %s", msg); - draw_ansi_line(t->status, 0, t->cols - 1, line, CP_STATUS, 0, 0); + wbkgd(t->status, COLOR_PAIR(CP_PANEL)); + int w = getmaxx(t->status); + int sx = getbegx(t->status); + char right[160]; + int rw; + t->cmd_x0 = t->cmd_x1 = -1; + if (t->home) { + snprintf(right, sizeof(right), "%s", CST_VERSION); + rw = utf8_cols(right, strlen(right)); + draw_ansi_at(t->status, 0, w - rw, rw, right, CP_MUTED, 0, 0); + } else { + long used; + int pct = ctx_percent(t, &used); + char tok[32]; + fmt_tokens(used, tok, sizeof(tok)); + snprintf(right, sizeof(right), "%s (%d%%)%s\033[97mctrl+p\033[39m commands", + tok, pct, dot_sep()); + rw = vis_cols(right); + if (rw > w) + rw = w; + draw_ansi_at(t->status, 0, w - rw, rw, right, + pct >= 80 ? CP_ERROR : CP_MUTED, 0, 0); + t->cmd_x1 = sx + w - 1; + t->cmd_x0 = t->cmd_x1 - (int)strlen("ctrl+p commands") + 1; + } + int lw = w - rw - 2; + if (lw < 1) + return (void)wrefresh(t->status); + if (t->busy) { + char line[600]; + snprintf(line, sizeof(line), "\033[33m%s\033[39m %s", spinner_frame(t), + t->status_msg[0] ? t->status_msg : "working"); + draw_ansi_at(t->status, 0, 0, lw, line, CP_MUTED, 0, 0); + } else if (t->status_msg[0]) { + draw_ansi_at(t->status, 0, 0, lw, t->status_msg, CP_MUTED, 0, 0); + } else { + draw_ansi_at(t->status, 0, 0, lw, t->where, CP_MUTED, 0, 0); + } wrefresh(t->status); } +/* "Build · model provider · effort" on the input box's last row. */ +static void draw_mode_line(tui_t *t, WINDOW *w, int y, int maxw) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + if (!cfg) + return; + const char *eff = cst_effort_wire(cfg->provider, cfg->model, cfg->effort) + ? cfg->effort + : NULL; + char right[160]; + int rpair = CP_BOX_MUTED; + if (t->busy) { + snprintf(right, sizeof(right), "%s %.0fs esc interrupt", spinner_frame(t), + since_ms(&t->turn_t0) / 1000.0); + rpair = CP_BOX_PRIMARY; + } else { + approval_label(t, right, sizeof(right)); + if (approval_mode(t) == CST_APPROVAL_YOLO) + rpair = CP_BOX_PRIMARY; + } + int rw = utf8_cols(right, strlen(right)); + int x = 2, room = maxw - x - rw - 2; + if (room < 8) { + rw = 0; + room = maxw - x - 1; + } + x += draw_ansi_at(w, y, x, room, cfg->plan_mode ? "Plan" : "Build", + cfg->plan_mode ? CP_BOX_PRIMARY : CP_BOX_ACCENT, 1, 0); + x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0); + x += draw_ansi_at(w, y, x, room - x + 2, cfg->model, CP_BOX_BRIGHT, 0, 0); + x += draw_ansi_at(w, y, x, room - x + 2, " ", CP_BOX, 0, 0); + x += draw_ansi_at(w, y, x, room - x + 2, cfg->provider, CP_BOX_MUTED, 0, 0); + if (eff) { + x += draw_ansi_at(w, y, x, room - x + 2, dot_sep(), CP_BOX_MUTED, 0, 0); + draw_ansi_at(w, y, x, room - x + 2, eff, CP_BOX_PRIMARY, 0, 0); + } + if (rw) + draw_ansi_at(w, y, maxw - rw - 2, rw, right, rpair, 0, 0); +} + /* Visual position of every byte offset: soft wrap at the window edge plus * hard newlines. Calls fn(ud, offset, row, col, wc, width) per character and * returns the row count; cursor row/col are reported for t->cur. */ typedef void (*input_cell_fn)(void *ud, int row, int col, wchar_t wc, int cw); +#define IN_COL 2 /* text column inside the input box (bar at 0) */ + static int input_layout(const tui_t *t, input_cell_fn fn, void *ud, int *cur_row, int *cur_col) { - int row = 0, col = 3, right = t->cols - 1; + int row = 0, col = IN_COL; + int right = (t->in_w > 0 ? t->in_w : t->cols) - 2; *cur_row = 0; - *cur_col = 3; + *cur_col = IN_COL; size_t i = 0; while (i < t->in_len) { if (i == t->cur) { @@ -1645,7 +1914,7 @@ static int input_layout(const tui_t *t, input_cell_fn fn, void *ud, } if (t->inbuf[i] == '\n') { row++; - col = 3; + col = IN_COL; i++; continue; } @@ -1655,9 +1924,9 @@ static int input_layout(const tui_t *t, input_cell_fn fn, void *ud, if (wc == L'\t') wc = L' '; int cw = utf8_width(wc); - if (col + cw > right && col > 3) { + if (col + cw > right && col > IN_COL) { row++; - col = 3; + col = IN_COL; } if (fn) fn(ud, row, col, wc, cw); @@ -1667,7 +1936,7 @@ static int input_layout(const tui_t *t, input_cell_fn fn, void *ud, if (t->cur >= t->in_len) { if (col >= right) { row++; - col = 3; + col = IN_COL; } *cur_row = row; *cur_col = col; @@ -1678,6 +1947,7 @@ static int input_layout(const tui_t *t, input_cell_fn fn, void *ud, typedef struct { WINDOW *win; int top, h, attrs; + int row0; /* window row of the first visible input line */ } input_draw_t; static void input_draw_cell(void *ud, int row, int col, wchar_t wc, int cw) { @@ -1685,79 +1955,78 @@ static void input_draw_cell(void *ud, int row, int col, wchar_t wc, int cw) { input_draw_t *d = ud; if (row < d->top || row >= d->top + d->h) return; - put_wc(d->win, row - d->top, col, wc, d->attrs); + put_wc(d->win, d->row0 + row - d->top, col, wc, d->attrs); } static void draw_input(tui_t *t) { if (!t->input) return; - werase(t->input); + WINDOW *w = t->input; if (HAS_COLOR()) - wbkgd(t->input, COLOR_PAIR(CP_INPUT)); + wbkgd(w, COLOR_PAIR(CP_BOX)); + werase(w); + int bh, bw; + getmaxyx(w, bh, bw); int h = t->input_rows > 0 ? t->input_rows : 1; + int pad = (bh - h - 1) / 2; /* rows above the text */ + if (pad < 0) + pad = 0; + int bar = HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_ACCENT) : 0; + for (int r = 0; r < bh; r++) + put_wc(w, r, 0, L'┃', bar); + draw_mode_line(t, w, pad + h, bw); int cr, cc; input_layout(t, NULL, NULL, &cr, &cc); int top = cr >= h ? cr - h + 1 : 0; - for (int r = 0; r < h; r++) - mvwaddstr(t->input, r, 0, - r + top == 0 ? (t->busy ? (g_ascii ? " * " : " … ") : " > ") - : " "); if (top > 0) - mvwaddstr(t->input, 0, 0, g_ascii ? " ^ " : " ↑ "); + put_wc(w, pad, 1, g_ascii ? L'^' : L'↑', + HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX_MUTED) : 0); if (t->in_len == 0) { const char *hint = - t->busy ? "working… Enter queues a message · Esc cancels" - : "Ask anything · / commands · @ files · Ctrl+N newline"; - int attrs = A_DIM | (HAS_COLOR() ? (int)COLOR_PAIR(CP_MUTED) : 0); - wattron(t->input, attrs); - if (g_ascii) - hint = t->busy ? "working... Enter queues a message - Esc cancels" - : "Ask anything - / commands - @ files - Ctrl+N newline"; - size_t fit = utf8_fit_cols(hint, strlen(hint), t->cols - 5); - mvwaddnstr(t->input, 0, 3, hint, (int)fit); - wattroff(t->input, attrs); - wmove(t->input, 0, 3); - wrefresh(t->input); + t->busy ? "Enter queues a message" + : (g_ascii ? "Ask anything... \"Fix a TODO in this repo\"" + : "Ask anything… \"Fix a TODO in this repo\""); + draw_ansi_at(w, pad, IN_COL, bw - IN_COL - 1, hint, CP_BOX_MUTED, 0, 0); + wmove(w, pad, IN_COL); + wrefresh(w); return; } - input_draw_t d = {t->input, top, h, - HAS_COLOR() ? (int)COLOR_PAIR(CP_INPUT) : 0}; + input_draw_t d = {w, top, h, HAS_COLOR() ? (int)COLOR_PAIR(CP_BOX) : 0, pad}; input_layout(t, input_draw_cell, &d, &cr, &cc); - if (cc >= t->cols) - cc = t->cols - 1; - wmove(t->input, cr - top, cc); - wrefresh(t->input); + if (cc >= bw) + cc = bw - 1; + wmove(w, pad + cr - top, cc); + wrefresh(w); } -/* Popup rows at the bottom of the transcript, just above the input. */ -static void draw_completion(tui_t *t, int h, int w) { +/* Popup of up to `room` rows at logw (y, x): growing upward from y when + * up, else downward. Rows are recorded for mouse picks. */ +static int g_comp_y0 = -1, g_comp_x0, g_comp_w; + +static void draw_completion(tui_t *t, int y, int x, int bw, int room, int up) { t->comp_drawn = t->comp_n; - if (t->comp_n <= 0 || h < 2) + g_comp_y0 = -1; + if (t->comp_n <= 0 || room < 1 || bw < 4) return; - int n = t->comp_n < h - 1 ? t->comp_n : h - 1; - int bw = w - 2 < 90 ? w - 2 : 90; + int n = t->comp_n < room ? t->comp_n : room; + int y0 = up ? y - n + 1 : y; + g_comp_y0 = t->log_y + y0; + g_comp_x0 = t->log_x + x; + g_comp_w = bw; for (int i = 0; i < n; i++) { - int y = h - n + i; const comp_item_t *c = &t->comp[i]; - char line[420]; - snprintf(line, sizeof(line), " %-22s %s", c->text, c->desc); int sel = i == t->comp_sel; - wmove(t->logw, y, 1); - for (int x = 0; x < bw; x++) - waddch(t->logw, ' ' | (sel ? A_REVERSE : 0) | - (HAS_COLOR() ? COLOR_PAIR(CP_HEADER) : 0)); - if (sel) - wattron(t->logw, A_REVERSE); - size_t fit = utf8_fit_cols(line, strlen(line), bw); - char cut[420]; - snprintf(cut, sizeof(cut), "%.*s", (int)fit, line); - if (HAS_COLOR()) - wattron(t->logw, COLOR_PAIR(sel ? CP_PRIMARY : CP_HEADER)); - mvwaddstr(t->logw, y, 1, cut); - if (HAS_COLOR()) - wattroff(t->logw, COLOR_PAIR(sel ? CP_PRIMARY : CP_HEADER)); - if (sel) - wattroff(t->logw, A_REVERSE); + int attrs = HAS_COLOR() ? (int)COLOR_PAIR(sel ? CP_SEL : CP_BOX) + : (sel ? A_REVERSE : 0); + for (int k = 0; k < bw; k++) + put_wc(t->logw, y0 + i, x + k, L' ', attrs); + char line[420]; + snprintf(line, sizeof(line), "%-22s ", c->text); + int used = draw_ansi_at(t->logw, y0 + i, x + 1, bw - 2, line, + sel ? CP_SEL : CP_BOX_BRIGHT, sel, 0); + if (bw - 2 - used > 0) + draw_ansi_at(t->logw, y0 + i, x + 1 + used, bw - 2 - used, c->desc, + sel ? CP_SEL : CP_BOX_MUTED, 0, 0); } } @@ -1790,7 +2059,7 @@ static void draw_todo(tui_t *t) { snprintf(head, sizeof(head), g_ascii ? " Tasks %d/%d done (Ctrl+T hides)" : " Tasks %d/%d done · Ctrl+T hides", total - open, total); - draw_ansi_line(t->todow, 0, t->cols - 1, head, CP_PRIMARY, 1, 0); + draw_ansi_line(t->todow, 0, getmaxx(t->todow) - 1, head, CP_PRIMARY, 1, 0); int row = 1, skipped = 0; int room = t->todo_rows - 1; /* keep the first unfinished item visible */ @@ -1824,7 +2093,7 @@ static void draw_todo(tui_t *t) { char line[400]; if (row == room && total - idx > 0) { snprintf(line, sizeof(line), " … %d more", total - idx + 1); - draw_ansi_line(t->todow, row, t->cols - 1, line, CP_MUTED, 0, 1); + draw_ansi_line(t->todow, row, getmaxx(t->todow) - 1, line, CP_MUTED, 0, 1); break; } const char *txt = p + 3; @@ -1832,7 +2101,7 @@ static void draw_todo(tui_t *t) { txt++; snprintf(line, sizeof(line), " %s %.*s", mark, (int)(p + len - txt), txt); - draw_ansi_line(t->todow, row++, t->cols - 1, line, pair, 0, dim); + draw_ansi_line(t->todow, row++, getmaxx(t->todow) - 1, line, pair, 0, dim); } else if (len >= 3) { skipped++; } @@ -1842,6 +2111,207 @@ static void draw_todo(tui_t *t) { wrefresh(t->todow); } +/* --- start screen ---------------------------------------------------------- */ + +/* 3-row block font for "cstagent"; each glyph is 4 columns wide. */ +static const char *const LOGO[8][3] = { + {"█▀▀▀", "█ ", "▀▀▀▀"}, /* c */ + {"█▀▀▀", "▀▀▀█", "▀▀▀▀"}, /* s */ + {"▀█▀▀", " █ ", " ▀▀▀"}, /* t */ + {"█▀▀█", "█▀▀█", "▀ ▀"}, /* a */ + {"█▀▀▀", "█ ▀█", "▀▀▀▀"}, /* g */ + {"█▀▀▀", "█▀▀ ", "▀▀▀▀"}, /* e */ + {"█▀▀▄", "█ █", "▀ ▀"}, /* n */ + {"▀█▀▀", " █ ", " ▀▀▀"}, /* t */ +}; +#define LOGO_W (8 * 5 - 1) +#define LOGO_H 3 + +static const char *const TIPS[] = { + "Tab switches between Build and Plan (read-only) mode", + "Shift+Tab cycles approval: ask, auto, yolo", + "Ctrl+P opens the command palette", + "Type @ to mention a file in your message", + "/effort sets reasoning effort: low, medium, high, max", + "/model lists the models your API key can use", + "/undo reverts the file changes of the last turn", + "/provider switches provider; keys are saved per provider", + "Click a tool card or Thought to expand it", + "Shift+drag selects text while mouse support is on", +}; + +static void draw_home(tui_t *t) { + int h, w; + getmaxyx(t->logw, h, w); + int box_top = t->in_y - t->log_y; + int box_bot = box_top + t->in_h; + if (g_ascii || !g_utf8) { + int x = (w - 8) / 2, y = box_top - 2; + if (y >= 0) { + draw_ansi_at(t->logw, y, x, 3, "cst", CP_LOGO_DIM, 1, 0); + draw_ansi_at(t->logw, y, x + 3, 5, "agent", CP_BRIGHT, 1, 0); + } + } else if (box_top - LOGO_H - 1 >= 0 && w >= LOGO_W) { + int x0 = (w - LOGO_W) / 2, y0 = box_top - LOGO_H - 1; + for (int g = 0; g < 8; g++) + for (int r = 0; r < LOGO_H; r++) + draw_ansi_at(t->logw, y0 + r, x0 + g * 5, 4, LOGO[g][r], + g < 3 ? CP_LOGO_DIM : CP_BRIGHT, 0, 0); + } + int right = t->in_x - t->log_x + t->in_w; + if (box_bot + 1 < h) { + char hint[96]; + snprintf(hint, sizeof(hint), + "\033[97mtab\033[39m modes \033[97mctrl+p\033[39m commands"); + int hw = vis_cols(hint); + if (right - hw >= 0) + draw_ansi_at(t->logw, box_bot + 1, right - hw, hw, hint, CP_MUTED, 0, 0); + } + if (t->tip && box_bot + 3 < h) { + char tip[256]; + snprintf(tip, sizeof(tip), "\033[33m%s Tip\033[39m %s", + g_ascii ? "*" : "●", t->tip); + int tw = vis_cols(tip); + if (tw > w - 2) + tw = w - 2; + draw_ansi_at(t->logw, box_bot + 3, (w - tw) / 2, tw, tip, CP_MUTED, 0, 0); + } + int room = h - box_bot; + draw_completion(t, box_bot, t->in_x - t->log_x, t->in_w, room, 0); +} + +/* --- sidebar --------------------------------------------------------------- */ + +typedef struct { + char path[200]; + int add, del; +} mod_file_t; + +/* Files changed by write tools this session, newest stats merged per path. */ +static int collect_modified(const tui_t *t, mod_file_t *out, int max) { + int n = 0; + for (int i = 0; i < t->hist.n; i++) { + const block_t *b = &t->hist.items[i]; + if (b->kind != BLK_TOOL || !cst_tool_is_write(b->tool_name) || + strncmp(b->meta, "denied", 6) == 0 || !b->body || !b->body[0]) + continue; + char path[256]; + diff_extract_path(b->body, b->title, path, sizeof(path)); + if (!path[0]) + continue; + diff_stats_t st; + diff_count_stats(b->body, &st); + int k = 0; + while (k < n && strcmp(out[k].path, path) != 0) + k++; + if (k == n) { + if (n >= max) + continue; + snprintf(out[n].path, sizeof(out[n].path), "%.199s", path); + out[n].add = out[n].del = 0; + n++; + } + out[k].add += st.additions; + out[k].del += st.deletions; + } + return n; +} + +static void draw_side(tui_t *t) { + if (!t->side) + return; + WINDOW *s = t->side; + if (HAS_COLOR()) + wbkgd(s, COLOR_PAIR(CP_SIDE)); + werase(s); + int h, w; + getmaxyx(s, h, w); + int x = 2, mw = w - 4, y = 1; + char line[512]; + + const char *title = t->title[0] ? t->title : "New session"; + size_t fit = utf8_fit_cols(title, strlen(title), mw); + draw_ansi_at(s, y++, x, mw, title, CP_SIDE_BRIGHT, 1, 0); + if (fit < strlen(title) && y < h) + draw_ansi_at(s, y++, x, mw, title + fit, CP_SIDE_BRIGHT, 1, 0); + y++; + + long used; + int pct = ctx_percent(t, &used); + draw_ansi_at(s, y++, x, mw, "Context", CP_SIDE_BRIGHT, 1, 0); + snprintf(line, sizeof(line), "%ld tokens", used); + draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0); + snprintf(line, sizeof(line), "%d%% used", pct); + draw_ansi_at(s, y++, x, mw, line, CP_SIDE_MUTED, 0, 0); + y++; + + int bottom = h - 3; /* keep two rows for where + version */ + char appr[64]; + approval_label(t, appr, sizeof(appr)); + if (y + 2 < bottom) { + draw_ansi_at(s, y++, x, mw, "Approval", CP_SIDE_BRIGHT, 1, 0); + draw_ansi_at(s, y++, x, mw, appr, + approval_mode(t) == CST_APPROVAL_YOLO ? CP_SIDE_PRIMARY + : CP_SIDE_MUTED, + 0, 0); + y++; + } + + int total = 0; + if (t->show_todo && t->todo && todo_open_items(t->todo, &total) >= 0 && + total > 0 && y + 2 < bottom) { + draw_ansi_at(s, y++, x, mw, "Todo", CP_SIDE_BRIGHT, 1, 0); + for (const char *p = t->todo; *p && y < bottom - 1;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len >= 3) { + int pair = CP_SIDE; + const char *mark = "[ ]"; + if (strncmp(p, "[x]", 3) == 0) + pair = CP_SIDE_MUTED, mark = "[✓]"; + else if (strncmp(p, "[~]", 3) == 0) + pair = CP_SIDE_PRIMARY, mark = "[•]"; + const char *txt = p + 3; + while (txt < p + len && *txt == ' ') + txt++; + snprintf(line, sizeof(line), "%s %.*s", mark, (int)(p + len - txt), + txt); + draw_ansi_at(s, y++, x, mw, line, pair, 0, 0); + } + p = nl ? nl + 1 : p + len; + } + y++; + } + + mod_file_t mods[16]; + int nm = collect_modified(t, mods, 16); + if (nm > 0 && y + 2 < bottom) { + draw_ansi_at(s, y++, x, mw, "Modified Files", CP_SIDE_BRIGHT, 1, 0); + for (int i = 0; i < nm && y < bottom; i++) { + char stat[48]; + snprintf(stat, sizeof(stat), "+%d -%d", mods[i].add, mods[i].del); + int sw = (int)strlen(stat); + draw_ansi_at(s, y, x, mw - sw - 1, mods[i].path, CP_SIDE_MUTED, 0, 0); + char pl[16], mi[16]; + snprintf(pl, sizeof(pl), "+%d", mods[i].add); + snprintf(mi, sizeof(mi), "-%d", mods[i].del); + int px = x + mw - sw; + px += draw_ansi_at(s, y, px, (int)strlen(pl), pl, CP_SIDE_SUCCESS, 0, 0); + draw_ansi_at(s, y, px + 1, (int)strlen(mi), mi, CP_SIDE_PRIMARY, 0, 0); + y++; + } + } + + if (h >= 4) { + draw_ansi_at(s, h - 3, x, mw, t->where, CP_SIDE_MUTED, 0, 0); + int dx = x + draw_ansi_at(s, h - 2, x, 2, g_ascii ? "*" : "●", + CP_SIDE_SUCCESS, 0, 0); + dx += draw_ansi_at(s, h - 2, dx + 1, mw, "CST-Agent", CP_SIDE_BRIGHT, 1, 0); + draw_ansi_at(s, h - 2, dx + 2, mw - dx, CST_VERSION, CP_SIDE_MUTED, 0, 0); + } + wrefresh(s); +} + static void draw_log(tui_t *t) { if (!t->logw) return; @@ -1852,6 +2322,15 @@ static void draw_log(tui_t *t) { getmaxyx(t->logw, h, w); if (h < 1 || w < 1) return; + if (t->home) { + draw_home(t); + wrefresh(t->logw); + if (t->input) { + touchwin(t->input); + wrefresh(t->input); + } + return; + } dlines_t dl = {0}; build_display(t, &dl, w); @@ -1867,12 +2346,27 @@ static void draw_log(tui_t *t) { if (start < 0) start = 0; + if (t->row_blk_cap < h) { + int *nb = realloc(t->row_blk, (size_t)h * sizeof(int)); + if (nb) { + t->row_blk = nb; + t->row_blk_cap = h; + } + } for (int row = 0; row < h; row++) { int li = start + row; + if (row < t->row_blk_cap) + t->row_blk[row] = li >= 0 && li < dl.n ? dl.lines[li].blk : -1; if (li < 0 || li >= dl.n) continue; dline_t *L = &dl.lines[li]; - draw_ansi_line(t->logw, row, w, L->text, L->pair, L->bold, L->dim); + if (L->fill && HAS_COLOR()) + for (int x = 0; x < w; x++) + put_wc(t->logw, row, x, L' ', (int)COLOR_PAIR(L->fill)); + draw_ansi_at(t->logw, row, L->indent, w - L->indent, L->text, L->pair, + L->bold, L->dim); + if (L->bar) + put_wc(t->logw, row, 0, L'┃', HAS_COLOR() ? (int)COLOR_PAIR(L->bar) : 0); } if (t->log_scroll > 0 && h > 2) { char more[128]; @@ -1887,15 +2381,23 @@ static void draw_log(tui_t *t) { wattroff(t->logw, A_REVERSE); } dlines_free(&dl); - draw_completion(t, h, w); + draw_completion(t, h - 1, 0, w < 90 ? w : 90, h - 1, 1); wrefresh(t->logw); } +static int is_home(const tui_t *t) { + return t->hist.n == 0 && !t->as_live && !t->busy; +} + +static void layout(tui_t *t); + static void redraw_all(tui_t *t) { + if (is_home(t) != t->home) + layout(t); theme_paint_root(); - draw_header(t); draw_log(t); draw_todo(t); + draw_side(t); draw_status(t); draw_input(t); } @@ -1915,20 +2417,6 @@ static void push_system_line(tui_t *t, const char *fmt, ...) { block_set_body(b, tmp, strlen(tmp)); } -static void print_welcome(tui_t *t) { - const cst_agent_config_t *cfg = cst_agent_config(t->agent); - push_system_line(t, "Welcome to CST-Agent"); - push_system_line(t, "/ for commands (/help lists them) · @ to mention a file " - "· Esc cancels a turn · Ctrl+C twice quits"); - if (cfg) - push_system_line(t, "Model %s @ %s · protocol %s · tools %s", cfg->model, - cfg->api_base, cst_protocol_name(cfg->protocol), - cst_tool_profile_name(cfg->tool_profile)); - if (cfg && cst_agent_notes(t->agent) && cst_agent_notes(t->agent)[0]) - push_system_line(t, "Project notes loaded from AGENTS.md"); - push_system_line(t, ""); -} - typedef struct { char id[CTL_MAX_ID]; char title[160]; @@ -1968,8 +2456,6 @@ static void reload_hist_from_session(tui_t *t) { t->last_tool_idx = -1; t->focus_think_idx = -1; - print_welcome(t); - cst_session_t *s = cst_agent_session(t->agent); if (!s) return; @@ -2054,50 +2540,96 @@ static int input_visual_rows(const tui_t *t) { return rows; } +static void apply_mouse(const tui_t *t) { + mousemask(t->mouse_on ? (mmask_t)ALL_MOUSE_EVENTS : 0, NULL); + mouseinterval(0); +} + +static void free_windows(tui_t *t) { + WINDOW **ws[] = {&t->header, &t->logw, &t->status, + &t->input, &t->todow, &t->side}; + for (size_t i = 0; i < sizeof(ws) / sizeof(ws[0]); i++) + drop_win(ws[i]); +} + +static int clampi(int v, int lo, int hi) { + return v < lo ? lo : v > hi ? hi : v; +} + +/* Start screen: centered logo + box. Session: transcript column, input box + * and status row at the bottom, optional sidebar on the right. */ static void layout(tui_t *t) { getmaxyx(stdscr, t->rows, t->cols); + if (t->rows < 4) + t->rows = 4; + if (t->cols < 20) + t->cols = 20; theme_paint_root(); - if (t->header) - delwin(t->header); - if (t->logw) - delwin(t->logw); - if (t->status) - delwin(t->status); - if (t->input) - delwin(t->input); - if (t->todow) - delwin(t->todow); - t->todow = NULL; + free_windows(t); + t->home = is_home(t); + t->side_w = 0; + t->todo_rows = 0; + int pad = t->rows >= 20 ? 1 : 0; + if (t->home) { + t->in_w = t->cols - 8 < 72 ? t->cols - 8 : 72; + if (t->in_w < 24) + t->in_w = t->cols - 2; + t->in_x = (t->cols - t->in_w) / 2; + } else { + if (t->show_side && t->cols >= 120) + t->side_w = clampi(t->cols / 4, 32, 42); + int mw = t->cols - t->side_w; + t->in_x = 1; + t->in_w = mw - 2; + } t->input_rows = input_visual_rows(t); - int total = 0, open = t->show_todo ? todo_open_items(t->todo, &total) : 0; - t->todo_rows = open ? 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX) - : 0; - if (t->rows < 16) - t->todo_rows = 0; - int chrome = 1 + 1 + t->input_rows + t->todo_rows; - if (chrome >= t->rows) - chrome = t->rows - 1; - int log_h = t->rows - chrome; - if (log_h < 1) - log_h = 1; + t->in_h = t->input_rows + 1 + 2 * pad; + if (t->in_h > t->rows - 2) { + t->input_rows = clampi(t->rows - 3, 1, INPUT_MAX_ROWS); + t->in_h = t->input_rows + 1; + } - t->header = newwin(1, t->cols, 0, 0); - t->logw = newwin(log_h, t->cols, 1, 0); - if (t->todo_rows) - t->todow = newwin(t->todo_rows, t->cols, 1 + log_h, 0); - t->status = newwin(1, t->cols, 1 + log_h + t->todo_rows, 0); - t->input = newwin(t->input_rows, t->cols, 1 + log_h + t->todo_rows + 1, 0); + if (t->home) { + int block = LOGO_H + 1 + t->in_h + 4; + int top = (t->rows - 1 - block) / 2; + t->in_y = clampi(top + LOGO_H + 1, 0, t->rows - 1 - t->in_h); + t->log_x = t->log_y = 0; + t->logw = newwin(t->rows - 1, t->cols, 0, 0); + t->status = newwin(1, t->cols - 2, t->rows - 1, 1); + } else { + int mw = t->cols - t->side_w; + t->in_y = t->rows - 1 - t->in_h; + int total = 0; + int open = t->show_todo && !t->side_w ? todo_open_items(t->todo, &total) : 0; + if (open && t->rows >= 16) + t->todo_rows = 1 + (total < TODO_PANEL_MAX ? total : TODO_PANEL_MAX); + t->log_x = 1; + t->log_y = t->rows >= 12 ? 1 : 0; + int log_h = t->in_y - 1 - t->todo_rows - t->log_y; + if (log_h < 1) { + t->todo_rows = 0; + log_h = t->in_y - t->log_y > 0 ? t->in_y - t->log_y : 1; + } + t->logw = newwin(log_h, mw - 2, t->log_y, 1); + if (t->todo_rows) + t->todow = newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1); + t->status = newwin(1, mw - 2, t->rows - 1, 1); + if (t->side_w) + t->side = newwin(t->rows, t->side_w, 0, mw); + } + t->input = newwin(t->in_h, t->in_w, t->in_y, t->in_x); if (HAS_COLOR()) { - wbkgdset(t->header, COLOR_PAIR(CP_HEADER)); wbkgdset(t->logw, COLOR_PAIR(CP_PANEL)); - wbkgdset(t->status, COLOR_PAIR(CP_STATUS)); - wbkgdset(t->input, COLOR_PAIR(CP_INPUT)); + wbkgdset(t->status, COLOR_PAIR(CP_PANEL)); + wbkgdset(t->input, COLOR_PAIR(CP_BOX)); + if (t->side) + wbkgdset(t->side, COLOR_PAIR(CP_SIDE)); } scrollok(t->logw, FALSE); keypad(t->input, TRUE); - mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); - mouseinterval(0); + wtimeout(t->input, 80); + apply_mouse(t); } static void redraw_all(tui_t *t); @@ -2420,7 +2952,18 @@ static int on_permission(const char *tool_name, const char *detail, tui_lock(t); snprintf(t->perm_tool, sizeof(t->perm_tool), "%s", tool_name ? tool_name : "tool"); - snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", detail ? detail : ""); + size_t sbl = strlen(CST_PERM_SANDBOX); + t->perm_sandbox = detail && strncmp(detail, CST_PERM_SANDBOX, sbl) == 0; + const char *dsrc = !detail ? "" : t->perm_sandbox ? detail + sbl : detail; + if (t->perm_sandbox) { + size_t dl = strlen(dsrc); + if (dl >= sizeof(t->perm_detail)) { + dsrc += dl - (sizeof(t->perm_detail) - 1); + while ((*dsrc & 0xC0) == 0x80) + dsrc++; + } + } + snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", dsrc); free(t->perm_preview); t->perm_preview = preview ? xstrdup(preview) : NULL; t->perm_prefix[0] = 0; @@ -2517,10 +3060,20 @@ static void draw_perm_modal(tui_t *t) { wbkgd(t->logw, COLOR_PAIR(CP_PANEL)); dlines_t dl = {0}; char head[700]; - snprintf(head, sizeof(head), "Allow %s?", t->perm_tool); + if (t->perm_sandbox) + snprintf(head, sizeof(head), + "Blocked by the sandbox. Rerun outside the sandbox?"); + else + snprintf(head, sizeof(head), "Allow %s?", t->perm_tool); dlines_push(&dl, head, CP_PRIMARY, 1, 0); int is_shell = strcmp(t->perm_tool, "run_shell") == 0; - if (is_shell && t->perm_preview) { + if (t->perm_sandbox) { + push_wrapped(&dl, " $ ", t->perm_preview ? t->perm_preview : "", w, + CP_TOOL, 1, 0, 0); + dlines_push(&dl, "", 0, 0, 0); + push_wrapped(&dl, " ", "sandboxed output (tail):", w, CP_MUTED, 0, 0, 0); + push_diff_or_body(&dl, t->perm_detail, w, CP_MUTED, 1, 100000, 0); + } else if (is_shell && t->perm_preview) { push_wrapped(&dl, " $ ", t->perm_preview, w, CP_TOOL, 1, 0, 0); } else { push_wrapped(&dl, " ", t->perm_detail, w, CP_MUTED, 0, 0, 0); @@ -2540,12 +3093,15 @@ static void draw_perm_modal(tui_t *t) { draw_ansi_line(t->logw, r, w, L->text, L->pair, L->bold, L->dim); } char keys[400]; - int k = snprintf(keys, sizeof(keys), " [y] allow once [a] always allow %s", - t->perm_tool); + int k = t->perm_sandbox + ? snprintf(keys, sizeof(keys), " [y] rerun once outside") + : snprintf(keys, sizeof(keys), + " [y] allow once [a] always allow %s", t->perm_tool); if (t->perm_prefix[0]) k += snprintf(keys + k, sizeof(keys) - (size_t)k, " [p] always allow `%.60s …`", t->perm_prefix); - snprintf(keys + k, sizeof(keys) - (size_t)k, " [n/Esc] deny%s", + snprintf(keys + k, sizeof(keys) - (size_t)k, " [n/Esc] %s%s", + t->perm_sandbox ? "keep result" : "deny", max_scroll ? " ↑↓ scroll" : ""); modal_footer(t, h, w, keys); dlines_free(&dl); @@ -2590,99 +3146,201 @@ static void draw_ask_modal(tui_t *t) { /* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */ -/* items: n rows of labels (each ≤127 chars). Returns index or -1. */ +static int ci_contains(const char *hay, const char *needle) { + if (!needle[0]) + return 1; + for (const char *h = hay; *h; h++) { + size_t i = 0; + while (needle[i] && h[i] && + tolower((unsigned char)h[i]) == tolower((unsigned char)needle[i])) + i++; + if (!needle[i]) + return 1; + } + return 0; +} + +/* Centered dialog over the current screen: type to filter, ↑↓ / wheel to + * move, Enter or click to pick. items: n labels (each ≤127 chars). Returns + * the original index or -1. */ static int picker_run(tui_t *t, const char *title, char items[][128], int n, int cur_idx) { if (n <= 0) return -1; - if (cur_idx < 0 || cur_idx >= n) - cur_idx = 0; - - int sel = cur_idx; - int top = 0; - wtimeout(stdscr, -1); - keypad(stdscr, TRUE); + if (cur_idx >= n) + cur_idx = -1; /* -1: no current item */ + int *vis = malloc((size_t)n * sizeof(int)); + if (!vis) + return -1; + char query[64] = ""; + size_t qlen = 0; + int sel = 0, top = 0, result = -1, nv = 0; + int first = 1, last_dh = -1, last_dw = -1; + WINDOW *dlg = NULL; curs_set(0); for (;;) { + nv = 0; + for (int i = 0; i < n; i++) + if (ci_contains(items[i], query)) + vis[nv++] = i; + if (first) { + for (int k = 0; k < nv; k++) + if (vis[k] == cur_idx) + sel = k; + first = 0; + } + if (sel >= nv) + sel = nv > 0 ? nv - 1 : 0; + int rows, cols; getmaxyx(stdscr, rows, cols); - int box_h = rows > 4 ? rows - 2 : rows; - if (box_h > n + 2) - box_h = n + 2; - if (box_h < 3) - box_h = rows > 0 ? rows : 3; - int visible = box_h - 2; - if (visible < 1) - visible = 1; + int dw = cols - 4 < 80 ? cols - 4 : 80; + int list_h = rows - 10; + if (list_h > n) + list_h = n; + if (list_h < 1) + list_h = 1; + int dh = list_h + 5; + if (dh > rows) + dh = rows; + if (dw < 10) + dw = cols; + int dy = (rows - dh) / 2, dx = (cols - dw) / 2; if (sel < top) top = sel; - if (sel >= top + visible) - top = sel - visible + 1; + if (sel >= top + list_h) + top = sel - list_h + 1; + if (top > 0 && top + list_h > nv) + top = nv > list_h ? nv - list_h : 0; - werase(stdscr); + drop_win(&dlg); + if (last_dh >= 0 && (dh != last_dh || dw != last_dw)) + redraw_all(t); /* uncover what a larger dialog hid */ + last_dh = dh; + last_dw = dw; + dlg = newwin(dh, dw, dy, dx); + if (!dlg) + break; + keypad(dlg, TRUE); + wtimeout(dlg, -1); if (HAS_COLOR()) - wbkgd(stdscr, COLOR_PAIR(CP_PANEL)); - mvprintw(0, 1, "%s (↑↓ Enter · Esc cancel)", title ? title : "Select"); - for (int i = 0; i < visible && top + i < n; i++) { - int idx = top + i; - int y = 1 + i; - if (idx == sel) { - if (HAS_COLOR()) - attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD); - else - attron(A_REVERSE); - } else if (HAS_COLOR()) { - attron(COLOR_PAIR(CP_MUTED)); - } - mvprintw(y, 2, "%s %.*s", idx == cur_idx ? "*" : " ", cols - 6, - items[idx]); - if (idx == sel) { - if (HAS_COLOR()) - attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD); - else - attroff(A_REVERSE); - } else if (HAS_COLOR()) { - attroff(COLOR_PAIR(CP_MUTED)); - } + wbkgd(dlg, COLOR_PAIR(CP_BOX)); + werase(dlg); + draw_ansi_at(dlg, 1, 2, dw - 8, title ? title : "Select", CP_BOX_BRIGHT, 1, + 0); + draw_ansi_at(dlg, 1, dw - 5, 3, "esc", CP_BOX_MUTED, 0, 0); + if (qlen) + draw_ansi_at(dlg, 2, 2, dw - 4, query, CP_BOX, 0, 0); + else + draw_ansi_at(dlg, 2, 2, dw - 4, "Search", CP_BOX_MUTED, 0, 0); + for (int i = 0; i < list_h && top + i < nv && 4 + i < dh; i++) { + int k = top + i, idx = vis[k], y = 3 + i + 1; + int on = k == sel; + int pair = on ? CP_SEL : CP_BOX; + int attrs = HAS_COLOR() ? (int)COLOR_PAIR(pair) : (on ? A_REVERSE : 0); + for (int x = 1; x < dw - 1; x++) + put_wc(dlg, y, x, L' ', attrs); + int used = draw_ansi_at(dlg, y, 2, 2, + idx == cur_idx ? (g_ascii ? "*" : "●") : " ", + on ? CP_SEL : CP_BOX_ACCENT, 0, 0); + draw_ansi_at(dlg, y, 2 + used + 1, dw - 6, items[idx], + on ? CP_SEL : CP_BOX, on, 0); } - refresh(); + if (nv == 0) + draw_ansi_at(dlg, 4, 2, dw - 4, "No matches", CP_BOX_MUTED, 0, 0); + wrefresh(dlg); wint_t wch = 0; - int rc = get_wch(&wch); + int rc = wget_wch(dlg, &wch); if (rc == ERR) continue; if (rc == KEY_CODE_YES) { - if (wch == KEY_UP) { - if (sel > 0) - sel--; - continue; - } - if (wch == KEY_DOWN) { - if (sel + 1 < n) - sel++; - continue; - } - if (wch == KEY_RESIZE) - continue; - if (wch == KEY_ENTER) + if (wch == KEY_UP && sel > 0) + sel--; + else if (wch == KEY_DOWN && sel + 1 < nv) + sel++; + else if (wch == KEY_PPAGE) + sel = sel > list_h ? sel - list_h : 0; + else if (wch == KEY_NPAGE) + sel = sel + list_h < nv ? sel + list_h : (nv > 0 ? nv - 1 : 0); + else if (wch == KEY_BACKSPACE && qlen) + query[qlen -= utf8_prev_len(query, qlen)] = 0, sel = 0, top = 0; + else if (wch == KEY_ENTER && nv > 0) { + result = vis[sel]; break; + } else if (wch == KEY_RESIZE) { + layout(t); + redraw_all(t); + } else if (wch == KEY_MOUSE) { + MEVENT ev; + if (getmouse(&ev) != OK) + continue; + if (ev.bstate & BUTTON4_PRESSED) { + if (sel > 0) + sel--; + } else if (ev.bstate & BUTTON5_PRESSED) { + if (sel + 1 < nv) + sel++; + } else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) { + int k = top + (ev.y - dy - 4); + if (ev.y < dy || ev.y >= dy + dh || ev.x < dx || ev.x >= dx + dw) + break; /* click outside cancels */ + if (ev.y >= dy + 4 && k >= 0 && k < nv) { + result = vis[k]; + break; + } + } + } continue; } - if (wch == 10 || wch == 13) /* LF/CR */ - break; - if (wch == 27) { /* Esc */ - sel = -1; + if (wch == 10 || wch == 13) { + if (nv > 0) { + result = vis[sel]; + break; + } + continue; + } + if (wch == 27 || wch == 3) /* Esc / Ctrl+C */ break; + if (wch == 127 || wch == 8) { + if (qlen) + query[qlen -= utf8_prev_len(query, qlen)] = 0; + sel = top = 0; + continue; + } + if (wch == 21) { /* Ctrl+U */ + qlen = 0; + query[0] = 0; + continue; + } + if (wch == 14 || wch == 16) { /* Ctrl+N / Ctrl+P */ + if (wch == 14 && sel + 1 < nv) + sel++; + else if (wch == 16 && sel > 0) + sel--; + continue; + } + if (iswprint(wch)) { + char mb[8]; + mbstate_t st; + memset(&st, 0, sizeof(st)); + size_t k = wcrtomb(mb, (wchar_t)wch, &st); + if (k != (size_t)-1 && qlen + k < sizeof(query)) { + memcpy(query + qlen, mb, k); + qlen += k; + query[qlen] = 0; + sel = top = 0; + } } } + drop_win(&dlg); + free(vis); curs_set(1); layout(t); - wtimeout(t->input, 80); - keypad(t->input, TRUE); redraw_all(t); - return sel; + return result; } /* Full-screen hidden input (API keys). Returns 0 with out filled, -1 on @@ -2771,25 +3429,176 @@ static int secret_input_run(tui_t *t, const char *title, const char *hint, } /* Saved / env key for the provider, else ask for one and save it. */ +/* --- /models probe --------------------------------------------------------- + * File-scope so a probe still running when the TUI exits writes nowhere + * stale. */ + +#define AM_MAX 64 +enum { AM_NONE = 0, AM_RUNNING, AM_OK, AM_FAILED, AM_AUTH }; + +static pthread_mutex_t g_am_mu = PTHREAD_MUTEX_INITIALIZER; +static struct { + char provider[32]; + char ids[AM_MAX][128]; + int n; + int state; + char err[256]; +} g_am; +static volatile int g_am_changed; +static volatile int g_am_threads; + +typedef struct { + char provider[32]; + char base[512]; + char key[512]; +} probe_arg_t; + +static void am_store(const char *provider, int r, char ids[][128], + const char *err) { + pthread_mutex_lock(&g_am_mu); + if (strcmp(g_am.provider, provider) == 0) { + g_am.state = r >= 0 ? AM_OK : r == CST_MODELS_AUTH ? AM_AUTH : AM_FAILED; + g_am.n = r > 0 ? r : 0; + for (int i = 0; i < g_am.n; i++) + memcpy(g_am.ids[i], ids[i], 128); + snprintf(g_am.err, sizeof(g_am.err), "%s", err ? err : ""); + g_am_changed = 1; + } + pthread_mutex_unlock(&g_am_mu); +} + +static void *probe_thread(void *arg) { + probe_arg_t *a = arg; + static char ids[AM_MAX][128]; /* one probe runs per provider switch */ + char err[256]; + int r = cst_llm_list_models(a->base, a->key, ids, AM_MAX, err, sizeof(err)); + am_store(a->provider, r, ids, err); + memset(a->key, 0, sizeof(a->key)); + free(a); + pthread_mutex_lock(&g_am_mu); + g_am_threads--; + pthread_mutex_unlock(&g_am_mu); + return NULL; +} + +/* Base URL for a provider: the live config for the current one (it may be + * overridden), else the preset. */ +static const char *provider_base(const tui_t *t, const char *provider) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + if (cfg && strcmp(cfg->provider, provider) == 0) + return cfg->api_base; + const cst_provider_t *p = cst_provider_find(provider); + return p ? p->api_base : ""; +} + +static void start_probe(tui_t *t) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + if (!cfg || !cfg->api_key[0] || g_am_threads > 0) + return; + probe_arg_t *a = calloc(1, sizeof(*a)); + if (!a) + return; + snprintf(a->provider, sizeof(a->provider), "%s", cfg->provider); + snprintf(a->base, sizeof(a->base), "%s", cfg->api_base); + snprintf(a->key, sizeof(a->key), "%s", cfg->api_key); + pthread_mutex_lock(&g_am_mu); + snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); + g_am.state = AM_RUNNING; + g_am.n = 0; + g_am_threads++; + pthread_mutex_unlock(&g_am_mu); + pthread_t th; + if (pthread_create(&th, NULL, probe_thread, a) != 0) { + pthread_mutex_lock(&g_am_mu); + g_am_threads--; + pthread_mutex_unlock(&g_am_mu); + free(a); + return; + } + pthread_detach(th); +} + +/* Copies the API model list for provider; returns the count, or -1 if none + * is known yet. */ +static int am_models(const char *provider, char out[][128], int max) { + int n = -1; + pthread_mutex_lock(&g_am_mu); + if (strcmp(g_am.provider, provider) == 0 && g_am.state == AM_OK) { + n = g_am.n < max ? g_am.n : max; + for (int i = 0; i < n; i++) + memcpy(out[i], g_am.ids[i], 128); + } + pthread_mutex_unlock(&g_am_mu); + return n; +} + +/* After a probe: flag a model the API does not list. */ +static void am_check_model(tui_t *t) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + char msg[400] = ""; + pthread_mutex_lock(&g_am_mu); + if (cfg && strcmp(g_am.provider, cfg->provider) == 0) { + if (g_am.state == AM_AUTH) { + snprintf(msg, sizeof(msg), "the %s API key was rejected — /key to " + "replace it", cfg->provider); + } else if (g_am.state == AM_OK && g_am.n > 0) { + int found = 0; + for (int i = 0; i < g_am.n && !found; i++) + found = strcmp(g_am.ids[i], cfg->model) == 0; + if (!found) + snprintf(msg, sizeof(msg), "%s is not in %s's model list — /model " + "to pick one", cfg->model, cfg->provider); + } + } + pthread_mutex_unlock(&g_am_mu); + if (msg[0]) + set_status(t, msg); +} + static int obtain_key(tui_t *t, const char *provider, char *out, size_t n, int force_ask) { if (!force_ask && cst_creds_resolve(provider, out, n, NULL) == 0) return 0; - const cst_provider_t *p = cst_provider_find(provider); char dir[1024] = "the config directory"; os_config_dir(dir, sizeof(dir)); + const char *base = provider_base(t, provider); char title[128], hint[1300]; snprintf(title, sizeof(title), "API key for %s", provider); - snprintf(hint, sizeof(hint), "%s · saved to %s/credentials (only you can " - "read it)", - p ? p->api_base : "", dir); - if (secret_input_run(t, title, hint, out, n) != 0) - return -1; + snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you " + "can read it)", + base, dir); + char note[300] = ""; + for (;;) { + if (secret_input_run(t, title, hint, out, n) != 0) + return -1; + set_status(t, "checking the key…"); + draw_status(t); + static char ids[AM_MAX][128]; + char err[256]; + int r = cst_llm_list_models(base, out, ids, AM_MAX, err, sizeof(err)); + if (r == CST_MODELS_AUTH) { + snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste " + "another one", + base, err); + continue; + } + pthread_mutex_lock(&g_am_mu); + snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider); + pthread_mutex_unlock(&g_am_mu); + am_store(provider, r, ids, err); + if (r < 0) + snprintf(note, sizeof(note), "could not verify the key (%.200s); " + "saved anyway", + err); + break; + } if (cst_creds_save(provider, out) != 0) - push_system_line(t, "warning: could not save the key; it is used for " - "this run only"); + set_status(t, "warning: could not save the key; it is used for this run " + "only"); + else if (note[0]) + set_status(t, note); else - push_system_line(t, "(saved the %s API key)", provider); + set_status(t, "API key verified and saved"); return 0; } @@ -2870,10 +3679,14 @@ static void cmd_help(tui_t *t, char *arg, int *quit) { "newlines"); push_system_line(t, " ←→ Home End Ctrl+A/E move · Ctrl+W/U/K delete word/" "to start/to end · Delete"); - push_system_line(t, " ↑↓ history (or lines) · Tab complete / toggle thought " - "· Ctrl+O last tool · Ctrl+T tasks"); - push_system_line(t, " PgUp/PgDn wheel scroll · Esc cancel turn · Ctrl+C " - "twice or Ctrl+D quit"); + push_system_line(t, " ↑↓ history (or lines) · Tab Build/Plan (completes / " + "and @) · Shift+Tab approval mode"); + push_system_line(t, " Ctrl+P command palette · Ctrl+O toggle thought · " + "Ctrl+T tasks · /sidebar"); + push_system_line(t, " Mouse: click cards / Thought to expand, wheel scrolls, " + "Shift+drag selects · /mouse"); + push_system_line(t, " PgUp/PgDn scroll · Esc cancel turn · Ctrl+C twice or " + "Ctrl+D quit"); push_system_line(t, ""); } @@ -2885,9 +3698,7 @@ static void cmd_new(tui_t *t, char *arg, int *quit) { push_system_line(t, "Error: failed to create session"); return; } - char note[192]; - snprintf(note, sizeof(note), "(new session %s)", id); - after_session_change(t, note); + after_session_change(t, NULL); /* empty transcript → start screen */ } static int session_labels(tui_t *t, char ids[][128], char labels[][128], @@ -3003,17 +3814,48 @@ static void cmd_model(tui_t *t, char *arg, int *quit) { (void)quit; cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); if (!arg || !arg[0]) { - char models[32][128]; - int n = cst_models_list(cfg->model, models, 32); - int cur_idx = 0; + static char names[AM_MAX + 32][128], labels[AM_MAX + 32][128]; + int na = am_models(cfg->provider, names, AM_MAX); + if (na < 0 && cfg->api_key[0]) { + set_status(t, "fetching the model list…"); + draw_status(t); + char err[256]; + na = cst_llm_list_models(cfg->api_base, cfg->api_key, names, AM_MAX, err, + sizeof(err)); + pthread_mutex_lock(&g_am_mu); + snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); + pthread_mutex_unlock(&g_am_mu); + am_store(cfg->provider, na, names, err); + if (na < 0) + set_status(t, err); + else + status_idle(t); + } + if (na < 0) + na = 0; + for (int i = 0; i < na; i++) + snprintf(labels[i], 128, "%.100s (api)", names[i]); + char builtin[32][128]; + int nb = cst_models_list(cfg->model, builtin, 32); + int n = na; + for (int i = 0; i < nb && n < AM_MAX + 32; i++) { + int dup = 0; + for (int k = 0; k < na && !dup; k++) + dup = strcmp(names[k], builtin[i]) == 0; + if (dup) + continue; + memcpy(names[n], builtin[i], 128); + memcpy(labels[n], builtin[i], 128); + n++; + } + int cur_idx = -1; for (int i = 0; i < n; i++) - if (strcmp(models[i], cfg->model) == 0) + if (strcmp(names[i], cfg->model) == 0) cur_idx = i; - int pick = picker_run(t, "Model", models, n, cur_idx); + int pick = picker_run(t, "Model", labels, n, cur_idx); if (pick < 0) return; - arg = models[pick]; - snprintf(cfg->model, sizeof(cfg->model), "%s", arg); + snprintf(cfg->model, sizeof(cfg->model), "%s", names[pick]); } else { snprintf(cfg->model, sizeof(cfg->model), "%.127s", arg); } @@ -3115,6 +3957,10 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) { cst_settings_remember(cfg); push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base, cfg->model); + if (am_models(p->name, NULL, 0) < 0) + start_probe(t); + else + am_check_model(t); } static void cmd_protocol(tui_t *t, char *arg, int *quit) { @@ -3210,14 +4056,67 @@ static void cmd_permissions(tui_t *t, char *arg, int *quit) { push_system_line(t, " saved in .agent/permissions.json · /permissions reset"); } +static void set_approval(tui_t *t, int a) { + cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); + cfg->approval = a; + cfg->auto_approve = a == CST_APPROVAL_YOLO; + if (a != CST_APPROVAL_YOLO) /* YOLO lasts for this session only */ + cst_settings_remember_approval(a); +} + +static const char *approval_help(int a) { + switch (a) { + case CST_APPROVAL_YOLO: + return "shell, writes and web run without asking"; + case CST_APPROVAL_AUTO: + return "edits run, shell runs in the sandbox or by safe rules, else asks"; + default: + return "shell, writes and web ask first"; + } +} + static void cmd_yolo(tui_t *t, char *arg, int *quit) { (void)arg; (void)quit; - cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); - cfg->auto_approve = !cfg->auto_approve; - push_system_line(t, cfg->auto_approve - ? "(YOLO on: shell, writes and web run without asking)" - : "(YOLO off: risky tools ask again)"); + int on = approval_mode(t) != CST_APPROVAL_YOLO; + set_approval(t, on ? CST_APPROVAL_YOLO : CST_DEFAULT_APPROVAL); + push_system_line(t, "(approval %s: %s)", cst_approval_name(approval_mode(t)), + approval_help(approval_mode(t))); +} + +static void cmd_approval(tui_t *t, char *arg, int *quit) { + (void)quit; + int a; + if (arg && arg[0]) { + if (cst_approval_parse(arg, &a) != 0) { + push_system_line(t, "Usage: /approval ask|auto|yolo"); + return; + } + set_approval(t, a); + } + a = approval_mode(t); + char label[64]; + approval_label(t, label, sizeof(label)); + push_system_line(t, "(approval %s: %s)", label, approval_help(a)); +} + +static void cmd_sidebar(tui_t *t, char *arg, int *quit) { + (void)arg; + (void)quit; + t->show_side = !t->show_side; + t->need_layout = 1; + if (t->show_side && t->cols < 120) + push_system_line(t, "(the sidebar shows when the terminal is at least 120 " + "columns wide)"); +} + +static void cmd_mouse(tui_t *t, char *arg, int *quit) { + (void)arg; + (void)quit; + t->mouse_on = !t->mouse_on; + apply_mouse(t); + push_system_line(t, t->mouse_on ? "(mouse on · Shift+drag selects text)" + : "(mouse off · the terminal selects text)"); } static void cmd_max_steps(tui_t *t, char *arg, int *quit) { @@ -3266,9 +4165,12 @@ static void cmd_status(tui_t *t, char *arg, int *quit) { push_system_line(t, " protocol %s · tools %s · max steps %d", cst_protocol_name(cfg->protocol), cst_tool_profile_name(cfg->tool_profile), cfg->max_steps); - push_system_line(t, " approval %s", - cfg->auto_approve ? "YOLO (no confirmation)" - : "asks for shell / writes / web"); + char appr[64]; + approval_label(t, appr, sizeof(appr)); + push_system_line(t, " approval %s — %s", appr, + approval_help(approval_mode(t))); + push_system_line(t, " mode %s", + cfg->plan_mode ? "Plan (read-only tools)" : "Build"); const char *notes = cst_agent_notes(t->agent); push_system_line(t, " notes %s", notes && notes[0] ? "AGENTS.md loaded" : "(no AGENTS.md)"); @@ -3503,7 +4405,12 @@ static const slash_cmd_t SLASH[] = { cmd_tools}, {"permissions", "perms", "[reset]", "show saved always-allow rules", 1, cmd_permissions}, - {"yolo", "", "", "toggle auto-approve for this run", 1, cmd_yolo}, + {"approval", "", "[ask|auto|yolo]", "how tools get approved (Shift+Tab)", + 1, cmd_approval}, + {"yolo", "", "", "toggle approving everything", 1, cmd_yolo}, + {"sidebar", "", "", "show or hide the session sidebar", 1, cmd_sidebar}, + {"mouse", "", "", "toggle mouse support (Shift+drag selects)", 1, + cmd_mouse}, {"max-steps", "", "[N]", "tool steps allowed per turn", 1, cmd_max_steps}, {"status", "", "", "session, model, context and settings", 1, cmd_status}, {"todo", "tasks", "", "show the agent's task list", 1, cmd_todo}, @@ -3589,8 +4496,11 @@ static int handle_slash(tui_t *t, const char *line, int *should_quit) { c->name); return 1; } + char before[sizeof(t->status_msg)]; + memcpy(before, t->status_msg, sizeof(before)); c->fn(t, arg, should_quit); - status_idle(t); + if (strcmp(before, t->status_msg) == 0) + status_idle(t); return 1; } @@ -3762,6 +4672,19 @@ static void *turn_thread(void *arg) { if (t->hist.items[i].kind == BLK_TOOL) attach_tool_output_from_session(t, &t->hist.items[i]); } + if (rc == 0) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + block_t *fb = blocks_push(&t->hist); + if (fb && cfg) { + char line[256]; + int n = snprintf(line, sizeof(line), "\033[97m%s\033[39m %s %s %s %.1fs", + cfg->plan_mode ? "Plan" : "Build", g_ascii ? "-" : "·", + cfg->model, g_ascii ? "-" : "·", + since_ms(&t->turn_t0) / 1000.0); + fb->kind = BLK_FOOTER; + block_set_body(fb, line, n > 0 ? strlen(line) : 0); + } + } t->turn_done = 1; t->busy = 0; tui_mark_dirty(t); @@ -3903,6 +4826,189 @@ static int submit_input(tui_t *t, int busy) { return start_turn(t, prompt); } +/* Byte offset nearest to visual (row, col) of the input, matching + * input_layout's wrapping. */ +static size_t input_hit(const tui_t *t, int want_row, int want_col) { + int row = 0, col = IN_COL; + int right = (t->in_w > 0 ? t->in_w : t->cols) - 2; + size_t i = 0, best = t->in_len; + while (i < t->in_len) { + if (row == want_row && col >= want_col) + return i; + if (row > want_row) + return best; + if (t->inbuf[i] == '\n') { + if (row == want_row) + return i; + row++; + col = IN_COL; + i++; + continue; + } + wchar_t wc; + size_t eaten; + utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten); + int cw = utf8_width(wc == L'\t' ? L' ' : wc); + if (col + cw > right && col > IN_COL) { + if (row == want_row) + return i; + row++; + col = IN_COL; + } + best = i + eaten; + col += cw; + i += eaten; + } + return t->in_len; +} + +static void toggle_plan(tui_t *t); +static int run_palette(tui_t *t, int busy, int *quit); +static void after_edit(tui_t *t); + +/* Returns -2 to quit (palette → /quit), else 0. */ +static int handle_click(tui_t *t, int y, int x, int busy) { + /* completion rows */ + if (g_comp_y0 >= 0 && t->comp_n > 0 && y >= g_comp_y0 && + y < g_comp_y0 + t->comp_n && x >= g_comp_x0 && x < g_comp_x0 + g_comp_w) { + t->comp_sel = y - g_comp_y0; + accept_completion(t); + after_edit(t); + redraw_all(t); + return 0; + } + /* input box */ + if (y >= t->in_y && y < t->in_y + t->in_h && x >= t->in_x && + x < t->in_x + t->in_w) { + int h = t->input_rows > 0 ? t->input_rows : 1; + int pad = (t->in_h - h - 1) / 2; + int ry = y - t->in_y; + if (ry == pad + h) { + if (x - t->in_x < IN_COL + 6) + toggle_plan(t); + return 0; + } + int cr, cc; + input_layout(t, NULL, NULL, &cr, &cc); + int top = cr >= h ? cr - h + 1 : 0; + int row = ry - pad + top; + if (row < 0) + row = 0; + t->cur = input_hit(t, row, x - t->in_x); + draw_input(t); + return 0; + } + /* status row: "ctrl+p commands" */ + if (y == t->rows - 1 && t->cmd_x0 >= 0 && x >= t->cmd_x0 && x <= t->cmd_x1) { + int quit = 0; + return run_palette(t, busy, &quit) == -2 || quit ? -2 : 0; + } + /* transcript blocks */ + if (!t->home && t->logw) { + int lh, lw; + getmaxyx(t->logw, lh, lw); + int ry = y - t->log_y, rx = x - t->log_x; + if (ry < 0 || ry >= lh || rx < 0 || rx >= lw || ry >= t->row_blk_cap) + return 0; + int bi = t->row_blk[ry]; + if (bi < 0 || bi >= t->hist.n) + return 0; + block_t *b = &t->hist.items[bi]; + if (b->kind == BLK_THINK) { + t->focus_think_idx = bi; + b->expanded = !b->expanded; + } else if (b->kind == BLK_TOOL) { + t->last_tool_idx = bi; + b->expanded = !b->expanded; + } else { + return 0; + } + draw_log(t); + } + return 0; +} + +/* Tab completes after "/" at the start or an "@word" under the cursor. */ +static int wants_completion(const tui_t *t) { + if (t->in_len && t->inbuf[0] == '/') + return 1; + size_t i = t->cur; + while (i > 0 && t->inbuf[i - 1] != ' ' && t->inbuf[i - 1] != '\n') + i--; + return i < t->cur && t->inbuf[i] == '@'; +} + +static void toggle_plan(tui_t *t) { + cst_agent_config_t *cfg = cst_agent_config_mut(t->agent); + cfg->plan_mode = !cfg->plan_mode; + set_status(t, cfg->plan_mode ? "Plan mode: read-only tools, answers with a " + "plan" + : "Build mode: all tools"); + draw_status(t); + draw_input(t); +} + +/* Ctrl+P: every slash command plus a few key actions, filterable. */ +static int run_palette(tui_t *t, int busy, int *quit) { + enum { PAL_MODE = -1, PAL_APPROVAL = -2, PAL_THOUGHT = -3 }; + static const struct { + int id; + const char *label; + } extra[] = { + {PAL_MODE, "Switch Build / Plan mode tab"}, + {PAL_APPROVAL, "Cycle approval ask/auto/yolo shift+tab"}, + {PAL_THOUGHT, "Toggle the last thought ctrl+o"}, + }; + int ne = (int)(sizeof(extra) / sizeof(extra[0])); + int nc; + const slash_cmd_t *tab = slash_table(&nc); + static char items[96][128]; + int map[96]; + int n = 0; + for (int i = 0; i < ne && n < 96; i++, n++) { + snprintf(items[n], sizeof(items[n]), "%s", extra[i].label); + map[n] = extra[i].id; + } + for (int i = 0; i < nc && n < 96; i++, n++) { + snprintf(items[n], sizeof(items[n]), "/%-12s %.100s", tab[i].name, + tab[i].desc); + map[n] = i; + } + int pick = picker_run(t, "Commands", items, n, -1); + if (pick < 0) + return 0; + switch (map[pick]) { + case PAL_MODE: + toggle_plan(t); + return 0; + case PAL_APPROVAL: + set_approval(t, (approval_mode(t) + 1) % 3); + redraw_all(t); + return 0; + case PAL_THOUGHT: + toggle_think(t); + return 0; + default: + break; + } + const slash_cmd_t *c = &tab[map[pick]]; + char line[64]; + snprintf(line, sizeof(line), "/%s", c->name); + if (c->args[0] == '<') { /* needs an argument: let the user type it */ + snprintf(line, sizeof(line), "/%s ", c->name); + input_set(t, line); + redraw_all(t); + return 0; + } + if (busy) { + handle_slash(t, line, quit); + redraw_all(t); + return *quit ? -2 : 0; + } + char *p = xstrdup(line); + return p ? start_turn(t, p) : 0; +} + /* --- modal key loops ------------------------------------------------------ */ static void perm_answer(tui_t *t, int ans) { @@ -3952,7 +5058,7 @@ static void run_perm_modal(tui_t *t) { request_cancel_ui(t); break; } - if (wch == L'a' || wch == L'A') { + if ((wch == L'a' || wch == L'A') && !t->perm_sandbox) { perm_answer(t, CST_PERM_ALWAYS_TOOL); break; } @@ -4052,16 +5158,8 @@ static void tui_free(tui_t *t) { free(t->pending_prompt); free(t->perm_preview); live_end_cleanup(t); - if (t->header) - delwin(t->header); - if (t->logw) - delwin(t->logw); - if (t->todow) - delwin(t->todow); - if (t->status) - delwin(t->status); - if (t->input) - delwin(t->input); + free_windows(t); + free(t->row_blk); blocks_free(&t->hist); pthread_mutex_destroy(&t->mu); } @@ -4129,6 +5227,15 @@ int cst_tui_run(cst_agent_t *agent) { t.focus_think_idx = -1; t.hist_browse = -1; t.show_todo = 1; + t.show_side = 1; + t.mouse_on = 1; + { + int sk = cst_sandbox_probe(); + t.sandbox_name = sk == CST_SANDBOX_NONE ? "rules" : cst_sandbox_name(sk); + } + t.tip = TIPS[(unsigned)time(NULL) % (sizeof(TIPS) / sizeof(TIPS[0]))]; + where_label(cst_agent_config(agent)->workspace_root, t.where, + sizeof(t.where)); pthread_mutex_init(&t.mu, NULL); blocks_init(&t.hist); if (!t.hist.items) { @@ -4162,9 +5269,16 @@ int cst_tui_run(cst_agent_t *agent) { memset(key, 0, sizeof(key)); } cst_settings_remember(cfg); + if (am_models(cfg->provider, NULL, 0) < 0) + start_probe(&t); redraw_all(&t); for (;;) { + if (g_am_changed) { + g_am_changed = 0; + am_check_model(&t); + draw_status(&t); + } tui_lock(&t); int dirty = t.dirty; int turn_done = t.turn_done; @@ -4190,7 +5304,7 @@ int cst_tui_run(cst_agent_t *agent) { redraw_all(&t); if (busy) { t.spin++; - draw_header(&t); + draw_status(&t); draw_input(&t); } @@ -4253,7 +5367,11 @@ int cst_tui_run(cst_agent_t *agent) { if (t.log_scroll < 0) t.log_scroll = 0; draw_log(&t); + } else if (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) { + if (handle_click(&t, ev.y, ev.x, busy) == -2) + goto out; } + draw_input(&t); /* keep the terminal cursor in the box */ } continue; } @@ -4338,6 +5456,15 @@ int cst_tui_run(cst_agent_t *agent) { if (t.comp_n > 0) { t.comp_sel = (t.comp_sel + t.comp_n - 1) % t.comp_n; draw_log(&t); + draw_input(&t); + } else { + set_approval(&t, (approval_mode(&t) + 1) % 3); + char label[64]; + approval_label(&t, label, sizeof(label)); + char msg[160]; + snprintf(msg, sizeof(msg), "approval: %s", label); + set_status(&t, msg); + redraw_all(&t); } continue; case KEY_ENTER: @@ -4459,8 +5586,14 @@ int cst_tui_run(cst_agent_t *agent) { insert_newline(&t); continue; case 15: /* Ctrl+O */ - toggle_last_tool(&t); + toggle_think(&t); continue; + case 16: { /* Ctrl+P */ + int quit = 0; + if (run_palette(&t, busy, &quit) == -2 || quit) + goto out; + continue; + } case 20: /* Ctrl+T */ t.show_todo = !t.show_todo; layout(&t); @@ -4477,13 +5610,13 @@ int cst_tui_run(cst_agent_t *agent) { if (t.comp_n > 0) { accept_completion(&t); after_edit(&t); - } else if (t.in_len == 0) { - toggle_think(&t); - } else { + } else if (wants_completion(&t)) { t.comp_off = 0; update_completion(&t); draw_log(&t); draw_input(&t); + } else { + toggle_plan(&t); } continue; case 127: @@ -4500,12 +5633,14 @@ int cst_tui_run(cst_agent_t *agent) { after_edit(&t); } } - +out: if (t.busy) { cst_agent_request_cancel(t.agent); for (int i = 0; i < 50 && t.busy; i++) usleep(100000); } + for (int i = 0; i < 20 && g_am_threads > 0; i++) + usleep(100000); /* let a /models probe finish before curl cleanup */ tui_free(&t); #ifdef NCURSES_VERSION diff --git a/tests/test_models.c b/tests/test_models.c new file mode 100644 index 0000000..11246d6 --- /dev/null +++ b/tests/test_models.c @@ -0,0 +1,130 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE +#endif + +#include "cst_agent.h" + +#include +#include +#include + +#ifdef _WIN32 +int main(void) { + puts("test_models: skipped on Windows"); + return 0; +} +#else + +#include +#include +#include +#include +#include + +#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", "not json"); + 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 diff --git a/tests/test_sandbox.c b/tests/test_sandbox.c new file mode 100644 index 0000000..c4e9bdb --- /dev/null +++ b/tests/test_sandbox.c @@ -0,0 +1,200 @@ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include "cst_perms.h" +#include "cst_sandbox.h" + +#include +#include +#include + +#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 +#include +#include +#include +#include +#include +#include + +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; +}