From 1c6f12235b2af1a9102a377d70e71474405040f3 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 19 Oct 2026 01:30:07 +0800 Subject: [PATCH 1/2] fix(O170): block shell-exec env assignments and tmux/screen spawn forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BASH_ENV / ENV / PROMPT_COMMAND / ZDOTDIR assignments (every shape: prefix, env, export, set, bare, pwsh $env:) and exported BASH_FUNC_* bodies make a POSIX shell run code; tmux new-session/new-window/ split-window/run-shell/send-keys and screen with a command spawn outside Moss's tree. None tripped the exec-capable gate on main — a BASH_ENV probe ran. The env forms fire only when a shell head runs on the same line (ENV=production npm start stays green); command-less tmux/screen forms are unaffected. --- CHANGELOG.md | 24 ++++ README.en.md | 6 + README.md | 1 + src/safety/exec-capable-read.ts | 96 ++++++++++++++- test/o170-indirect-exec-bypass.spec.mjs | 148 ++++++++++++++++++++++++ 5 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 test/o170-indirect-exec-bypass.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c3bdec1..28bedcac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## Unreleased +### O170 — shell-exec env assignments and tmux/screen spawn forms are blocked + +`BASH_ENV= bash -c ':'` sources the file, `ENV=` does the same for POSIX +sh/dash, `PROMPT_COMMAND=` runs before every prompt of an interactive bash, +`ZDOTDIR` moves zsh's `.zshenv`, an exported `BASH_FUNC_name%%=() { … }` body +is code, and `tmux new-session/run-shell/send-keys` / `screen -dmS` run a +command outside Moss's process tree — none of them tripped the exec-capable +gate on main; a probe planted in BASH_ENV ran. The env assignments are now +reported when a POSIX shell head runs on the same line (`ENV=production npm +start` is not flagged — the name collision is common), in every assignment +shape (prefix, `env`, `export`/`declare`, `set`, bare statement, pwsh +`$env:`), and the tmux/screen forms are reported when they carry a command +(`tmux ls`, `tmux attach`, `screen -ls` stay green). + +`BASH_ENV= bash -c ':'` 会让 bash source 该文件,`ENV=` 对 POSIX sh/dash +同理,`PROMPT_COMMAND=` 在交互 bash 每个提示符前运行,`ZDOTDIR` 改写 zsh 的 +`.zshenv` 位置,导出的 `BASH_FUNC_name%%=() { … }` 函数体本身就是代码; +`tmux new-session/run-shell/send-keys` 与 `screen -dmS` 在 Moss 进程树之外运行 +命令——这些在 main 上都不会触发「可执行读取」拦截,BASH_ENV 里放的探针真的 +跑了起来。现在:环境变量赋值在**同一行内有 POSIX shell 头**时报为可执行 +(`ENV=production npm start` 不误拦——这个名字常被应用配置占用),覆盖前缀、 +`env`、`export`/`declare`、`set`、裸赋值、pwsh `$env:` 各种形态;tmux/screen +仅在其携带命令时报(`tmux ls`、`tmux attach`、`screen -ls` 不受影响)。 + ### O157 — PreToolUse hook `ask` now asks the user; `-p` treats it as deny A PreToolUse hook that exits 0 with `ask` (Claude `hookSpecificOutput.permissionDecision: diff --git a/README.en.md b/README.en.md index a07cc872..2d938a44 100644 --- a/README.en.md +++ b/README.en.md @@ -464,6 +464,12 @@ one line: press `e` to switch to English. paths to system programs (such as the background job's `powershell.exe`) from the environment before `.env` was loaded. A child PATH left with no usable entry (only `.` or empty entries) becomes a directory that does not exist, never an empty string. +- **Shell-exec env assignments and tmux/screen spawn forms (O170)**: assignments of + `BASH_ENV`, `ENV`, `PROMPT_COMMAND`, `ZDOTDIR` and `BASH_FUNC_*` make a shell run code, and + `tmux new-session`/`run-shell`/`send-keys`, `screen` with a command spawn outside Moss's + process tree — all pre-blocked as exec-capable. The env forms fire only when a POSIX + shell head runs on the same line (`ENV=production npm start` is not flagged); command-less + `tmux ls` / `attach` forms are unaffected. - Credentials come only from `.env` or the environment: never hardcoded, never logged, never passed to child processes, never written to the device registry. No account, no cloud, no telemetry. diff --git a/README.md b/README.md index c22e837c..c406d9ed 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,7 @@ moss config set language auto # 默认:仅当系统区域以 zh 开 - **Moss 启动的程序不从工作区取(O158)**:启动探测、shell 解析、git 探测、hook 的启动命令、MCP 服务器等 Moss 自己启动的程序,一律解析成绝对路径,只查 PATH 里的绝对目录,跳过空项、`.`、相对路径和工作区内的目录;Windows 子进程设 `NoDefaultCurrentDirectoryInExePath=1`。**已知限制**:在 PATH 含工作区目录的环境里启动 moss(例如 `npx` / `npm run` 带来的 `node_modules/.bin`)时,模型 exec 的命令和 hook 脚本内部调用的程序沿用这个 PATH,会使用项目里的同名工具。 - **hook 启动命令的安全解析范围(O164)**:Moss 只对 hook 命令开头的那个程序名做上面的安全解析。用了环境变量前缀(`FOO=1 git`)、`exec` / `env` / `command`、引号、子 shell、分号或重定向时,按普通 shell 查找;若以 `npx` / `npm run` 启动,PATH 里的 `node_modules/.bin` 会被用到。用户级 hook 建议写成 `程序名 参数` 或绝对路径。项目 hook(`.moss/config.json`)只在你信任该文件夹后才运行;信任文件夹等于信任项目代码,包括项目 hook 和 `node_modules/.bin` 里的程序。 - **`.env` 不能改系统位置(O164)**:`SystemRoot`、`windir`、`SystemDrive`、`ProgramFiles`(含 `(x86)`、`ProgramW6432`)、`PSModulePath`、`NoDefaultCurrentDirectoryInExePath` 与 `PATH`、`PATHEXT`、`ComSpec`、`TEMP`/`TMP` 一样不能由 `.env` 设置;Moss 拼系统程序路径(如后台任务用的 `powershell.exe`)时只用加载 `.env` 之前的环境。子进程的 PATH 被清空(只剩 `.`、空项)时换成一个不存在的绝对目录,不会留空串。 +- **shell 执行类环境变量与 tmux/screen 拦截(O170)**:`BASH_ENV`、`ENV`、`PROMPT_COMMAND`、`ZDOTDIR`、`BASH_FUNC_*` 的赋值让 shell 执行代码,`tmux new-session/run-shell/send-keys`、`screen` 携带命令时在 Moss 进程树外执行——均按可执行形式在执行前拦截。环境变量仅在同行有 POSIX shell 头时报(`ENV=production npm start` 不误拦);`tmux ls`/`attach` 等无命令形态不受影响。 - 凭据只从 `.env` 或环境变量读,绝不硬编码、不进日志、不传子进程、不写设备清单。 - 无账号、无云服务、无遥测;provider 是普通 HTTP 端点。 diff --git a/src/safety/exec-capable-read.ts b/src/safety/exec-capable-read.ts index 81728b4a..5f4bfd9b 100644 --- a/src/safety/exec-capable-read.ts +++ b/src/safety/exec-capable-read.ts @@ -91,6 +91,54 @@ export function isExecEnvName(name: string): boolean { return n.startsWith('GIT_') && !GIT_ENV_ALLOWED.has(n); } +/** + * O170: assignments that make a SHELL execute code at startup / at the next + * prompt — `BASH_ENV=x bash -c ':'` sources x, `ENV=` for POSIX sh/dash, + * `PROMPT_COMMAND=` before every interactive prompt, `ZDOTDIR` moves zsh's + * .zshenv, and `BASH_FUNC_name%%=() { … }` injects a function. The payload + * needs a shell on the same line, so the form is reported only when one runs + * (an `ENV=production npm start` never trips it). + */ +export function isShellExecEnvName(name: string): boolean { + const n = name.toUpperCase(); + return n === 'BASH_ENV' || n === 'ENV' || n === 'PROMPT_COMMAND' || n === 'ZDOTDIR'; +} + +/** O170: `BASH_FUNC_name%%=…` — an exported function body is code itself. */ +export function isBashFuncWord(word: string): boolean { + return /^BASH_FUNC_[A-Za-z0-9_]+%%?=/.test(word); +} + +/** + * O170: `tmux` / `screen` forms that run a command outside Moss's process + * tree (detached sessions, run-shell, injected keys). + */ +export function shellSpawnForm(head: string, args: readonly string[]): string | undefined { + if (head === 'tmux') { + const stop = args.indexOf('--'); + const words = args.slice(0, stop === -1 ? args.length : stop); + const sub = words.find( + (w) => w === 'new-session' || w === 'new-window' || w === 'split-window' + ); + if (sub) return `tmux ${sub}`; + if (words.includes('run-shell')) return 'tmux run-shell'; + if (words.includes('send-keys')) return 'tmux send-keys'; + return undefined; + } + if (head === 'screen') { + if (args.includes('-X') && args.includes('stuff')) return 'screen stuff'; + const stop = args.indexOf('--'); + const words = args.slice(0, stop === -1 ? args.length : stop); + let i = 0; + while (i < words.length && words[i]!.startsWith('-')) i += 1; + // `screen -dmS name cmd …`: an -S option consumed the session name. + if (i < words.length && /^-[a-zA-Z]*S$/.test(words[i - 1] ?? '')) i += 1; + if (i < words.length) return 'screen command'; + return undefined; + } + return undefined; +} + export interface ExecCapableForm { /** Short label of the form, e.g. `rg --pre`, `git -c`, `GIT_EXTERNAL_DIFF=`. */ form: string; @@ -844,6 +892,11 @@ export function findExecCapableForms( // stays a plain git read. for (const sub of commandSubstitutions(command)) nested(sub, liveEnv, 0, false); let stdinShell = false; + // O170: shell-exec env assignments collected on the line; they run code only + // when a shell runs, so the form is reported after the loop (an + // `ENV=production npm start` never trips it). + const shellExecEnv = new Set(); + let sawShellHead = false; // O152 r5 N6/N8: pwsh env setters anywhere on the line (spaces allowed, // `$env:X = 'v'`), and [Environment]::SetEnvironmentVariable('X', …). @@ -854,6 +907,7 @@ export function findExecCapableForms( liveEnv.add(m[1]!.toUpperCase()); out.push({ form: `${m[1]!.toUpperCase()}=`, operands: [], gitC: [], index: 0 }); } + if (isShellExecEnvName(m[1]!)) shellExecEnv.add(m[1]!.toUpperCase()); } for (const m of command.matchAll( /\[(?:System\.)?Environment\]::SetEnvironmentVariable\s*\(\s*['"]?([A-Za-z_][A-Za-z0-9_]*)/gi @@ -864,8 +918,13 @@ export function findExecCapableForms( liveEnv.add(m[1]!.toUpperCase()); out.push({ form: `${m[1]!.toUpperCase()}=`, operands: [], gitC: [], index: 0 }); } + if (isShellExecEnvName(m[1]!)) shellExecEnv.add(m[1]!.toUpperCase()); } + // O170: an exported function body (`BASH_FUNC_x%%=() { … }`) is code — + // matched on the raw line because tokenizers split on the parens. + if (/\bBASH_FUNC_[A-Za-z0-9_]+%%?=/.test(command)) shellExecEnv.add('BASH_FUNC'); + for (let si = 0; si < simples.length; si += 1) { const words = simples[si]!; if (words.length === 0) continue; @@ -918,6 +977,8 @@ export function findExecCapableForms( while (i < words.length && ASSIGN.test(words[i]!)) { const name = ASSIGN.exec(words[i]!)![1]!; if (isExecEnvName(name)) localEnv.add(name.toUpperCase()); + if (isShellExecEnvName(name)) shellExecEnv.add(name.toUpperCase()); + if (isBashFuncWord(words[i]!)) shellExecEnv.add('BASH_FUNC'); if (name.toUpperCase() === 'PATH') localPath = true; i += 1; } @@ -933,6 +994,8 @@ export function findExecCapableForms( envChanged = true; liveEnv.add(name.toUpperCase()); } + if (name && isShellExecEnvName(name)) shellExecEnv.add(name.toUpperCase()); + if (isBashFuncWord(w)) shellExecEnv.add('BASH_FUNC'); } continue; } @@ -954,6 +1017,8 @@ export function findExecCapableForms( if (ASSIGN.test(w)) { const name = ASSIGN.exec(w)![1]!; if (isExecEnvName(name)) localEnv.add(name.toUpperCase()); + if (isShellExecEnvName(name)) shellExecEnv.add(name.toUpperCase()); + if (isBashFuncWord(w)) shellExecEnv.add('BASH_FUNC'); if (name.toUpperCase() === 'PATH') localPath = true; i += 1; continue; @@ -1022,6 +1087,8 @@ export function findExecCapableForms( liveEnv.add(name.toUpperCase()); localEnv.add(name.toUpperCase()); } + if (isShellExecEnvName(name)) shellExecEnv.add(name.toUpperCase()); + if (isBashFuncWord(tok)) shellExecEnv.add('BASH_FUNC'); i += 1; // `export VAR value` (no `=`) — skip the value. if ( @@ -1039,13 +1106,19 @@ export function findExecCapableForms( // Spec r3 C: a GIT_CONFIG_* assignment anywhere in this simple command // (prefix, env, export, sudo, or a later word) is exec-capable whatever // the head is — any child may spawn git with it. - const gitCfgWord = words.find((w) => { + // O170: a shell-exec env name (BASH_ENV / ENV / PROMPT_COMMAND / ZDOTDIR / + // BASH_FUNC_*) is the same — any later shell child runs its payload. + const envWord = words.find((w) => { const m = ASSIGN.exec(w) ?? w.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)=/i); return !!m && isGitConfigEnvName(m[1]!); }); - const gitCfgLive = [...localEnv].find((n) => isGitConfigEnvName(n)); - if (gitCfgWord || gitCfgLive) { - const name = gitCfgLive ?? ASSIGN.exec(gitCfgWord!)?.[1] ?? 'GIT_CONFIG'; + const envLive = [...localEnv].find((n) => isGitConfigEnvName(n)); + for (const w of words) { + const m = ASSIGN.exec(w) ?? w.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)=/i); + if (m && isShellExecEnvName(m[1]!)) shellExecEnv.add(m[1]!.toUpperCase()); + } + if (envWord || envLive) { + const name = envLive ?? ASSIGN.exec(envWord!)?.[1] ?? 'GIT_CONFIG'; const h = headOf(words[i]); const a = words.slice(i + 1); out.push({ @@ -1060,6 +1133,15 @@ export function findExecCapableForms( const head = headOf(words[i]); const args = words.slice(i + 1); + // O170: a POSIX shell head makes the collected shell-exec env assignments + // real (pwsh does not read BASH_ENV / ENV / PROMPT_COMMAND / ZDOTDIR). + if (SH_SHELLS.has(head)) sawShellHead = true; + const spawn = shellSpawnForm(head, args); + if (spawn) { + out.push({ form: spawn, operands: pathOperands(head, args), gitC: [], index: si }); + continue; + } + // O152 r6 C4: `PATH=:$PATH git status`, `export PATH=…; x` — the // command may resolve to a planted binary. // PM r7 O1: only for a segment that runs / names git. @@ -1210,6 +1292,12 @@ export function findExecCapableForms( } } + // O170: shell-exec env assignments (BASH_ENV / ENV / PROMPT_COMMAND / + // ZDOTDIR / BASH_FUNC_*) reported only when a shell runs on the line. + if (shellExecEnv.size > 0 && sawShellHead) { + for (const n of shellExecEnv) out.push({ form: `${n}=`, operands: [], gitC: [], index: 0 }); + } + // `echo 'git -c …' | sh`: the shell runs text another word produced. if (stdinShell) { for (let si = 0; si < simples.length; si += 1) { diff --git a/test/o170-indirect-exec-bypass.spec.mjs b/test/o170-indirect-exec-bypass.spec.mjs new file mode 100644 index 00000000..09e28709 --- /dev/null +++ b/test/o170-indirect-exec-bypass.spec.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** + * O170: indirect execution bypasses that already existed on main — + * assignments that make a POSIX shell run code (BASH_ENV, ENV, + * PROMPT_COMMAND, ZDOTDIR, exported BASH_FUNC_* bodies) and tmux/screen + * forms that spawn commands outside Moss's process tree. On main none of + * these tripped the exec-capable gate: `BASH_ENV= bash -c ':'` ran + * the probe. The env forms are reported when a POSIX shell head runs on the + * same line (`ENV=production npm start` is not flagged); the tmux/screen + * forms only when they carry a command. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { o152Scenario, IS_WIN } from './o152-harness.mjs'; +import { commandHasExecCapableRead } from '../dist/safety/exec-capable-read.js'; +import { findShellProtectedWrites } from '../dist/safety/protected-paths.js'; + +const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'o170-'))); +const ctx = { + cwd: base, + workspaceRoot: base, + configDirs: [], + env: process.env, + home: path.join(base, 'home'), +}; +fs.mkdirSync(ctx.home, { recursive: true }); + +function assertExecCapable(cmd) { + assert.equal(commandHasExecCapableRead(cmd), true, cmd); + const hits = findShellProtectedWrites(cmd, ctx).filter((h) => h.kind === 'exec-capable'); + assert.ok(hits.length > 0, `exec-capable hit; ${cmd}`); +} + +function assertNotExecCapable(cmd) { + assert.equal(commandHasExecCapableRead(cmd), false, cmd); + const hits = findShellProtectedWrites(cmd, ctx).filter((h) => h.kind === 'exec-capable'); + assert.equal(hits.length, 0, cmd); +} + +// ── shell-exec env assignments (shell head on the line) ──────────────────── + +const ENV_RED = [ + 'BASH_ENV=/tmp/e.sh bash -c ":"', + 'BASH_ENV=/tmp/e.sh bash script.sh', + 'export BASH_ENV=/tmp/e.sh; bash -c ":"', + 'export PROMPT_COMMAND="touch /tmp/pwn"; bash -ic ":"', + 'PROMPT_COMMAND=touch bash -i -c ":"', + 'env BASH_ENV=/tmp/e.sh bash -c ":"', + 'ZDOTDIR=/tmp/z zsh -c ":"', + 'export ZDOTDIR=/tmp/z; zsh -c ":"', + 'ENV=/tmp/e.sh dash -c ":"', + 'ENV=/tmp/e.sh sh -c ":"', + 'set BASH_ENV=/tmp/e.sh && bash -c ":"', + "BASH_FUNC_x%%=() { touch /tmp/pwn; }; bash -c 'x'", +]; + +for (const cmd of ENV_RED) test(`policy RED: ${cmd}`, () => assertExecCapable(cmd)); + +const ENV_GREEN = [ + // No shell on the line — the assignment is inert (also avoids the common + // app-config name collision). + 'ENV=production npm start', + 'ENV=prod node x.js', + 'BASH_ENV=/tmp/e.sh node x.js', + 'export ENV=production; npm test', + // A shell without any of the vars. + 'bash -c "ls"', + 'bash script.sh', + 'zsh -c "echo hi"', + 'sh -c ":"', + // Ordinary env stays green. + 'FOO=1 bash -c ":"', + 'PATH=/usr/bin:/bin ls', +]; + +for (const cmd of ENV_GREEN) test(`policy GREEN: ${cmd}`, () => assertNotExecCapable(cmd)); + +// ── tmux / screen ────────────────────────────────────────────────────────── + +const SPAWN_RED = [ + 'tmux new-session -d "touch /tmp/pwn"', + 'tmux new-session -d -s w "evil"', + 'tmux new-window "evil"', + 'tmux split-window "evil"', + 'tmux run-shell "evil"', + 'tmux run-shell -t w "evil"', + "tmux send-keys -t w 'evil' Enter", + 'screen -dmS w evil', + 'screen -S w -X stuff "evil\\n"', +]; + +for (const cmd of SPAWN_RED) test(`policy RED: ${cmd}`, () => assertExecCapable(cmd)); + +const SPAWN_GREEN = [ + 'tmux ls', + 'tmux list-sessions', + 'tmux kill-session -t w', + 'tmux attach -t w', + 'screen -ls', + 'screen -d -m', // no command follows +]; + +for (const cmd of SPAWN_GREEN) test(`policy GREEN: ${cmd}`, () => assertNotExecCapable(cmd)); + +// ── process: BASH_ENV never runs the probe ──────────────────────────────── + +const MARKER = 'o170-marker.touched'; + +test( + 'process BLOCK: BASH_ENV= bash -c never runs it (exit 9)', + { skip: IS_WIN ? 'bash-form e2e; policy covered above' : false }, + async () => { + const r = await o152Scenario({ + lang: 'en', + gitInit: false, + steps: (c) => { + const script = path.join(c.root, 'o170-probe.sh'); + fs.writeFileSync(script, `#!/bin/sh\necho touched > "$(dirname "$0")/${MARKER}"\n`, { + mode: 0o755, + }); + return [{ name: 'exec', args: { command: `BASH_ENV=${script} bash -c ':'; node -e 1` } }]; + }, + finalText: 'All done, everything is finished.', + inspect: (c) => ({ marker: fs.existsSync(path.join(c.root, MARKER)) }), + }); + const detail = `code=${r.code} last=${r.last}\n${(r.err ?? '').slice(-400)}`; + assert.equal(r.extra.marker, false, 'the probe must not run'); + assert.equal(r.code, 9, detail); + assert.match(r.last, /被拦|Blocked by: /, detail); + } +); + +test( + 'process GREEN: plain bash -c still runs (exit 0)', + { skip: IS_WIN ? 'bash-form e2e; policy covered above' : false }, + async () => { + const r = await o152Scenario({ + lang: 'en', + steps: () => [{ name: 'exec', args: { command: `bash -c ':'; node -e 1` } }], + finalText: 'All done, everything is finished.', + }); + assert.equal(r.code, 0, `code=${r.code} last=${r.last}`); + } +); From eec8b9628e5faaaf6a51c8811e94be700369a76d Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 19 Oct 2026 01:59:24 +0800 Subject: [PATCH 2/2] docs: restore the O169 changelog entry lost in the merge resolution --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c94fa79..d2cadee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### O169 — `git --upload-pack` / `--receive-pack` literal commands are blocked + +`git clone --upload-pack=''`, `git fetch --upload-pack=…`, `git push +--receive-pack=…` (glued, `--upload-pack cmd`, or clone's short `-u`) make git +run the given command to speak the pack protocol — arbitrary execution that +looks like a plain clone/fetch/push. The flags were not in the exec-capable +table, so the pre-exec block (O152) never fired. They now are, in the +subcommand position and the global one; `git push -u origin main` +(--set-upstream) is unaffected. + +`git clone --upload-pack=''`、`git fetch --upload-pack=…`、`git push +--receive-pack=…`(连写、空格分开、或 clone 的短旗标 `-u`)会让 git 执行给定命令来 +讲 pack 协议——表面是普通 clone/fetch/push,实际是任意执行。此前这些旗标不在 +「可执行读取」表里,O152 的执行前拦截从未触发;现已加入(子命令位与全局位都识别)。 +`git push -u origin main`(--set-upstream)不受影响。 + ### O170 — shell-exec env assignments and tmux/screen spawn forms are blocked `BASH_ENV= bash -c ':'` sources the file, `ENV=` does the same for POSIX