Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0

Build
  * cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
    winpthreads and ncurses 6.6 are linked statically, so the release is
    cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
  * windres embeds a manifest with activeCodePage=UTF-8 and a version resource
  * ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
    wide-char declarations): scripts/patches/
  * scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
    UCRT64; PR #1's MINGW64 notes now point there
  * vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW

UTF-8
  * paths cross the system call boundary as UTF-16 (*W APIs); console code
    pages set to 65001 and restored on exit; files are read and written in
    binary mode
  * child output that is not valid UTF-8 is decoded from the console, OEM or
    ANSI system code page
  * read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
    endings and a UTF-8 BOM

OS layer and processes
  * os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
    subagent, plugins, skills, undo and fs_tools; realpath / path_under and
    symlink rejection work on Windows
  * every child goes through CreateProcessW with its own environment block
    inside a Job Object, so timeouts and Esc end the whole process tree

Sandbox (auto mode, "auto · lowil")
  * run_shell runs with a restricted Low integrity token; the workspace gets
    an inheritable Low label once, the sandbox TEMP is labeled Low, and the
    credentials file gets a no-read-up label
  * drive roots, the profile and system folders are never labeled
  * network commands still ask, since Low IL does not block the network;
    Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
  * /sandbox lists labeled folders, /sandbox reset-labels restores them

TUI
  * raster console fonts are switched to Consolas / NSimSun while running
  * the home logo falls back to text when the console font lacks U+2580
  * mouse drag copy goes to the Windows clipboard

Distribution
  * scripts/install_windows.ps1 installs a Gitea release into
    %LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
    cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
    zip with the token taken from the environment
  * scripts/run_windows.bat is gone; docs/README-windows.md is rewritten

Tests
  * new test_lowil, test_win_utf8 and test_os_dir; tests made portable
    (cst_test_util.h)
  * scripts/win_test.sh and scripts/winvm/ run the suite on English and
    Chinese Windows Server VMs as Administrator, a standard user and a
    Chinese user name: 29/29 on all six
This commit is contained in:
WHC
2026-10-06 23:08:14 +08:00
parent 9862c29f58
commit 2a43ae6622
84 changed files with 9784 additions and 1083 deletions
+377
View File
@@ -19,6 +19,9 @@
#elif defined(__APPLE__)
#define CST_HAVE_SEATBELT 1
#include <stdint.h>
#elif defined(_WIN32)
#include <windows.h>
#include <sddl.h>
#endif
#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__))
@@ -864,6 +867,341 @@ static int sc_probe(void) {
#endif /* CST_HAVE_SECCOMP */
/* --- Windows: Low integrity level ----------------------------------------- */
#ifdef _WIN32
#define LW_LABEL "S:(ML;OICI;NW;;;LW)"
#define LW_RECORD "sandbox-labels"
/* Same user, no admin privileges, Low IL. No LUA_TOKEN: a deny-only
* Administrators group cannot open an admin's (SSH) window station and the
* child fails in DLL init; the integrity check does not need it. */
static HANDLE lw_token(void) {
HANDLE self = NULL, tok = NULL;
if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ADJUST_DEFAULT |
TOKEN_ASSIGN_PRIMARY,
&self))
return NULL;
BOOL ok = CreateRestrictedToken(self, DISABLE_MAX_PRIVILEGE, 0, NULL, 0,
NULL, 0, NULL, &tok);
CloseHandle(self);
if (!ok)
return NULL;
PSID low = NULL;
if (!ConvertStringSidToSidW(L"S-1-16-4096", &low)) {
CloseHandle(tok);
return NULL;
}
TOKEN_MANDATORY_LABEL tml;
memset(&tml, 0, sizeof(tml));
tml.Label.Sid = low;
tml.Label.Attributes = SE_GROUP_INTEGRITY;
ok = SetTokenInformation(tok, TokenIntegrityLevel, &tml,
(DWORD)(sizeof(tml) + GetLengthSid(low)));
LocalFree(low);
if (!ok) {
CloseHandle(tok);
return NULL;
}
return tok;
}
static int lw_record_path(char *out, size_t n) {
char dir[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) != 0)
return -1;
return snprintf(out, n, "%s/%s", dir, LW_RECORD) < (int)n ? 0 : -1;
}
static char *lw_read_record(void) {
char path[OS_PATH_MAX];
if (lw_record_path(path, sizeof(path)) != 0)
return NULL;
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
size_t cap = 4096, len = 0;
char *s = malloc(cap);
size_t k;
while (s && (k = fread(s + len, 1, cap - len - 1, f)) > 0) {
len += k;
if (cap - len < 2) {
char *g = realloc(s, cap * 2);
if (!g) {
free(s);
s = NULL;
break;
}
s = g;
cap *= 2;
}
}
fclose(f);
if (s)
s[len] = 0;
return s;
}
/* dir is a recorded directory or inside one (the label is inherited). */
static int lw_recorded(const char *dir) {
char *s = lw_read_record();
int found = 0;
for (char *line = s; line && *line && !found;) {
char *nl = strchr(line, '\n');
if (nl)
*nl = 0;
char *tab = strchr(line, '\t');
if (tab)
*tab = 0;
found = line[0] && os_path_under(line, dir);
line = nl ? nl + 1 : NULL;
}
free(s);
return found;
}
static int lw_record_add(const char *dir, const char *old_sddl) {
char path[OS_PATH_MAX];
if (lw_record_path(path, sizeof(path)) != 0)
return -1;
FILE *f = fopen(path, "ab");
if (!f)
return -1;
fprintf(f, "%s\t%s\n", dir, old_sddl);
return fclose(f) == 0 ? 0 : -1;
}
static int lw_same_dir(const char *a, const char *b) {
return a && b && a[0] && b[0] && os_path_under(a, b) && os_path_under(b, a);
}
/* An environment directory in the form os_realpath gives the workspace
* (long names, '/'), or NULL. */
static const char *lw_env_dir(const char *name, char *buf, size_t n) {
const char *v = getenv(name);
if (!v || !v[0] || os_realpath(v, buf, n) != 0)
return NULL;
os_path_norm_slashes(buf);
return buf;
}
/* Labeling a whole drive, the profile or system folders would open far more
* than a project to every sandboxed command. */
static int lw_refused(const char *dir) {
size_t n = strlen(dir);
while (n > 0 && (dir[n - 1] == '/' || dir[n - 1] == '\\'))
n--;
if (n <= 2)
return 1;
static const char *const SELF[] = {"USERPROFILE", "APPDATA",
"LOCALAPPDATA", "TEMP"};
static const char *const TREE[] = {"SystemRoot", "ProgramFiles",
"ProgramFiles(x86)", "ProgramData"};
char buf[OS_PATH_MAX];
for (size_t i = 0; i < sizeof(SELF) / sizeof(SELF[0]); i++)
if (lw_same_dir(lw_env_dir(SELF[i], buf, sizeof(buf)), dir))
return 1;
for (size_t i = 0; i < sizeof(TREE) / sizeof(TREE[0]); i++) {
const char *v = lw_env_dir(TREE[i], buf, sizeof(buf));
if (v && os_path_under(v, dir))
return 1;
}
return 0;
}
static int lw_prepare(const char *ws, cst_sandbox_plan_t *p) {
static int creds_done, tmp_done;
char buf[OS_PATH_MAX + 256];
if (!ws || !ws[0])
return -1;
if (!creds_done) {
/* files saved by older versions have no no-read-up label yet */
char dir[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) == 0 &&
snprintf(buf, sizeof(buf), "%s/credentials", dir) < (int)sizeof(buf)) {
os_stat_t st;
if (os_lstat(buf, &st) != 0 || os_chmod_private(buf) == 0)
creds_done = 1;
}
}
char tmp[OS_PATH_MAX];
if (os_temp_dir(tmp, sizeof(tmp)) != 0 ||
snprintf(buf, sizeof(buf), "%s/cst-agent-sandbox", tmp) >=
(int)sizeof(tmp))
return -1;
memcpy(tmp, buf, strlen(buf) + 1);
if (!tmp_done) {
os_mkdir_p(tmp, 0700);
if (os_win_set_label(tmp, LW_LABEL) != 0)
return -1;
tmp_done = 1;
}
if (!lw_recorded(ws)) {
char old[512];
if (lw_refused(ws) || os_win_get_label(ws, old, sizeof(old)) != 0 ||
os_win_set_label(ws, LW_LABEL) != 0)
return -1;
lw_record_add(ws, old);
snprintf(buf, sizeof(buf),
"[cst: sandbox: labeled %s Low integrity so sandboxed commands "
"can write there; /sandbox reset-labels undoes it]\n",
ws);
p->note = strdup(buf);
}
HANDLE tok = lw_token();
if (!tok) {
free(p->note);
p->note = NULL;
return -1;
}
p->token = tok;
for (char *c = tmp; *c; c++)
if (*c == '/')
*c = '\\';
p->tmpdir = strdup(tmp);
return p->tmpdir ? 0 : -1;
}
static int lw_run(const char *ws, const char *cmd, size_t max_out,
const cst_sandbox_plan_t *p, os_run_result_t *r) {
size_t tl = strlen(p->tmpdir) + 8;
char *te = malloc(tl), *tm = malloc(tl);
int rc = -1;
if (te && tm) {
snprintf(te, tl, "TEMP=%s", p->tmpdir);
snprintf(tm, tl, "TMP=%s", p->tmpdir);
const char *env[] = {te, tm, NULL};
rc = os_run_shell_token(ws, cmd, max_out, r, p->token, env);
}
free(te);
free(tm);
if (rc == 0 && p->note) {
size_t nl = strlen(p->note);
char *o = malloc(nl + r->out_len + 1);
if (o) {
memcpy(o, p->note, nl);
if (r->out)
memcpy(o + nl, r->out, r->out_len);
o[nl + r->out_len] = 0;
free(r->out);
r->out = o;
r->out_len += nl;
}
}
return rc;
}
#endif /* _WIN32 */
char *cst_sandbox_labels(void) {
#ifdef _WIN32
char *s = lw_read_record();
if (!s)
return NULL;
size_t o = 0;
for (char *line = s; *line;) {
char *nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
size_t path = strcspn(line, "\t\n");
if (path) {
memmove(s + o, line, path);
o += path;
s[o++] = '\n';
}
line += len + (nl ? 1 : 0);
}
s[o] = 0;
if (!o) {
free(s);
return NULL;
}
return s;
#else
return NULL;
#endif
}
int cst_sandbox_reset_labels(char **report) {
if (report)
*report = NULL;
#ifdef _WIN32
char *s = lw_read_record();
size_t cap = 256, len = 0;
char *rep = malloc(cap), *keep = NULL;
size_t kcap = 0, klen = 0;
int ok = 0, bad = 0;
if (rep)
rep[0] = 0;
for (char *line = s; line && *line;) {
char *nl = strchr(line, '\n');
if (nl)
*nl = 0;
char *tab = strchr(line, '\t');
const char *old = tab ? tab + 1 : "S:";
if (tab)
*tab = 0;
if (line[0]) {
os_stat_t st;
int gone = os_lstat(line, &st) != 0;
int done = gone || os_win_set_label(line, old[0] ? old : "S:") == 0;
char msg[OS_PATH_MAX + 64];
int m = snprintf(msg, sizeof(msg), "%s %s\n",
gone ? "gone " : done ? "restored" : "FAILED ",
line);
if (m > 0 && rep) {
while (len + (size_t)m + 1 > cap) {
char *g = realloc(rep, cap *= 2);
if (!g)
break;
rep = g;
}
if (len + (size_t)m + 1 <= cap) {
memcpy(rep + len, msg, (size_t)m + 1);
len += (size_t)m;
}
}
if (done) {
ok++;
} else {
bad++;
size_t need = strlen(line) + strlen(old) + 3;
if (klen + need + 1 > kcap) {
char *g = realloc(keep, kcap = (klen + need + 1) * 2);
if (g)
keep = g;
}
if (keep && klen + need + 1 <= kcap)
klen += (size_t)snprintf(keep + klen, kcap - klen, "%s\t%s\n",
line, old);
}
}
line = nl ? nl + 1 : NULL;
}
free(s);
char path[OS_PATH_MAX];
if (lw_record_path(path, sizeof(path)) == 0) {
if (klen) {
FILE *f = fopen(path, "wb");
if (f) {
fwrite(keep, 1, klen, f);
fclose(f);
}
} else {
remove(path);
}
}
free(keep);
if (report)
*report = rep;
else
free(rep);
return bad ? -1 : ok;
#else
return 0;
#endif
}
/* --- common --------------------------------------------------------------- */
int cst_sandbox_probe(void) {
@@ -891,6 +1229,15 @@ int cst_sandbox_probe(void) {
#endif
#ifdef CST_HAVE_SEATBELT
kind = CST_SANDBOX_SEATBELT;
#endif
#ifdef _WIN32
if (!e || strcmp(e, "lowil") == 0) {
HANDLE tok = lw_token();
if (tok) {
CloseHandle(tok);
kind = CST_SANDBOX_LOWIL;
}
}
#endif
(void)want_ll;
(void)want_sc;
@@ -917,6 +1264,8 @@ const char *cst_sandbox_name(int kind) {
return "seatbelt";
case CST_SANDBOX_SECCOMP:
return "seccomp";
case CST_SANDBOX_LOWIL:
return "lowil";
default:
return "none";
}
@@ -939,6 +1288,10 @@ int cst_sandbox_prepare(const char *workspace, const char *tmpdir,
#ifdef CST_HAVE_SECCOMP
if (kind == CST_SANDBOX_SECCOMP)
rc = sc_prepare(workspace, tmpdir, plan);
#endif
#ifdef _WIN32
if (kind == CST_SANDBOX_LOWIL && (rc = lw_prepare(workspace, plan)) != 0)
cst_sandbox_release(plan);
#endif
(void)workspace;
(void)tmpdir;
@@ -988,6 +1341,15 @@ void cst_sandbox_release(cst_sandbox_plan_t *plan) {
#ifdef CST_HAVE_SECCOMP
sc_free(plan->sc);
#endif
#ifdef _WIN32
if (plan->token)
CloseHandle(plan->token);
#endif
plan->token = NULL;
free(plan->tmpdir);
plan->tmpdir = NULL;
free(plan->note);
plan->note = NULL;
plan->sc = NULL;
free(plan->profile);
plan->profile = NULL;
@@ -1004,6 +1366,12 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
if (cst_sandbox_prepare(workspace, tmp, &plan) != 0) {
memset(r, 0, sizeof(*r));
const char *msg = "[cst: sandbox unavailable; command not run]\n";
#ifdef _WIN32
if (workspace && !lw_recorded(workspace) && lw_refused(workspace))
msg = "[cst: sandbox: will not label a drive root, the profile or a "
"system folder writable; command not run. Start cst-agent in a "
"project folder]\n";
#endif
r->out = malloc(strlen(msg) + 1);
if (r->out) {
memcpy(r->out, msg, strlen(msg) + 1);
@@ -1012,8 +1380,12 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
r->exit_code = 126;
return 0;
}
#ifdef _WIN32
int rc = lw_run(workspace, cmd, max_out, &plan, r);
#else
int rc = os_run_shell_ex(workspace, cmd, max_out, r, cst_sandbox_enter,
&plan);
#endif
cst_sandbox_release(&plan);
return rc;
}
@@ -1039,6 +1411,11 @@ int cst_sandbox_blocked(const char *out, size_t n, int exit_code) {
"权限不够", "不允许的操作", "只读文件系统", "權限不足",
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
"Permission non accord", "Permiso denegado", "Отказано в доступе",
/* Windows ERROR_ACCESS_DENIED from cmd, PowerShell and Win32 tools */
"Access is denied", "Access to the path", "UnauthorizedAccess",
"拒绝访问", "存取被拒", "Zugriff verweigert", "Accès refusé",
/* Cygwin/MSYS2 programs cannot initialize at Low IL */
"DLL initialization routine failed", "0xc0000142",
};
for (size_t i = 0; i < sizeof(SIGNS) / sizeof(SIGNS[0]); i++)
if (has(out, n, SIGNS[i]))