From f7d4e65a0299845b91b73359896f7d26be88081d Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 19 Oct 2026 01:27:18 +0800 Subject: [PATCH 1/2] fix(O168): script-arg exemption skips the delete-ancestor rule An interpreter run is uncertain, so every absolute mention went through the ancestor rule: python3 x.py $HOME, node x.js "$PWD", . venv/bin/ activate && python -V were reported as deleting home / the workspace root, and a non-git $PWD was labeled git internals. The exemption applies only while the path is named nowhere else (cd-exemption semantics; source ./evil.sh; ls still hits), across both shell forms. o127 P2 splits accordingly (P2a documents the new contract). --- CHANGELOG.md | 29 +++++ README.en.md | 7 + README.md | 1 + src/safety/protected-paths.ts | 127 ++++++++++++++++++- test/o127-linked-worktree-exec-keys.spec.mjs | 14 +- 5 files changed, 176 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df559765..683455d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ ## Unreleased +### O168 — a script's arguments are not deletions + +The write-target rules blocked normal commands: an interpreter run is +"uncertain" (a script can write anything), and every absolute mention then +went through the delete-ancestor rule — `python3 x.py $HOME`, +`node x.js "$PWD"`, `python3 x.py /tmp`, `. venv/bin/activate && python -V` +were reported as deleting home / the workspace root, and a NON-git directory's +`$PWD` was labeled "git repository internals". Arguments of an invoked script +(the interpreter script form, `sh x.sh`, the `.`/`source` operand and the bare +`.`) are mentions, not deletions: the ancestor rule now skips them — like the +cd exemption, only while the path is named nowhere else in the command +(`source ./evil.sh; ls ` still hits). Arguments naming a protected path +directly (`python3 x.py $HOME/.ssh`, `.git/config`), the interpreter inline +forms, and real deletes (`rm -rf $HOME`, `mv $HOME /tmp`, `rm -rf "$PWD"`) +are unchanged; actual writes stay covered by the script scan (layer 2) and +the post-run snapshot (layer 3). + +写目标规则曾误拦正常命令:解释器运行是「不确定」的(脚本可能写任何东西), +于是每个绝对路径提及都过了一遍「删除受保护祖先」规则——`python3 x.py +$HOME`、`node x.js "$PWD"`、`python3 x.py /tmp`、`. venv/bin/activate && +python -V` 被报成删除 home / 工作区根目录,非 git 目录下的 `$PWD` 还被说成 +「git 仓库内部文件」。被调用脚本的参数(解释器脚本形式、`sh x.sh`、 +`.`/`source` 的操作数与裸 `.`)只是提及、不是删除:祖先规则现在跳过它们—— +与 cd 豁免一样,仅当该路径未在命令其他位置出现(`source ./evil.sh; ls +` 仍会命中)。直接点名受保护路径的参数(`python3 x.py $HOME/.ssh`、 +`.git/config`)、解释器内联形式、真正的删除(`rm -rf $HOME`、`mv $HOME +/tmp`、`rm -rf "$PWD"`)行为不变;实际写入仍由脚本扫描(第 2 层)与运行后 +快照(第 3 层)兜底。 + ### O156 — Windows PowerShell 5.1: a missing hook command is "failed to start" A hook run through Windows PowerShell 5.1 (`powershell -Command `) diff --git a/README.en.md b/README.en.md index 21a212df..f8cc6c6d 100644 --- a/README.en.md +++ b/README.en.md @@ -453,6 +453,13 @@ 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. +- **A script's arguments are not deletions (O168)**: arguments of an invoked script (the + interpreter script form `python3 x.py $HOME`, `node x.js \"$PWD\"`, `sh x.sh`, the + `.`/`source` operand and the bare `.`) no longer trip the delete-ancestor rule — normal + commands stop being blocked and a non-git directory is no longer labeled git internals. + Like the cd exemption it applies only while the path is named nowhere else in the command; + directly protected arguments (`$HOME/.ssh`, `.git/config`), inline forms and real deletes + are unchanged, and actual writes stay covered by the script scan and the post-run snapshot. - 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 55a00302..52db2eef 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,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 被清空(只剩 `.`、空项)时换成一个不存在的绝对目录,不会留空串。 +- **脚本的参数不是删除(O168)**:解释器脚本形式的参数(`python3 x.py $HOME`、`node x.js "$PWD"`)、`sh x.sh` 的参数、`.`/`source` 的操作数与裸 `.` 不再触发「删除受保护祖先」规则——正常命令不再被误拦,非 git 目录也不再被说成 git 仓库内部文件。豁免与 cd 一样仅限该路径未在命令其他位置出现;直接点名受保护路径(`$HOME/.ssh`、`.git/config`)、内联形式与真实删除行为不变,实际写入仍由脚本扫描与运行后快照兜底。 - 凭据只从 `.env` 或环境变量读,绝不硬编码、不进日志、不传子进程、不写设备清单。 - 无账号、无云服务、无遥测;provider 是普通 HTTP 端点。 diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index 213b9259..e2620a9c 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -599,6 +599,121 @@ function cdOnlyOperands(command: string, cwd: string): Set { return cd; } +/** Heads that run a script file: interpreters and shells (O168 exemption). */ +const SCRIPT_RUN_HEADS = new Set([ + 'node', + 'nodejs', + 'python', + 'python2', + 'python3', + 'ruby', + 'php', + 'perl', + 'lua', + 'bun', + 'deno', + 'sh', + 'bash', + 'zsh', + 'dash', + 'ksh', + 'ash', +]); +/** Inline-code flags: with one of these the invocation is not a script form. */ +const INLINE_CODE_FLAGS = new Set([ + '-c', + '-e', + '-E', + '-p', + '-r', + '--eval', + '--print', + 'eval', +]); +const DOT_SOURCE_HEADS = new Set(['.', 'source']); + +function stripEdgeQuotes(w: string): string { + return w.replace(/^(['"])(.*)\1$/, '$2'); +} + +function resolveScriptArgWord(raw: string, cwd: string, home: string): string | undefined { + const w = stripEdgeQuotes(raw); + if (!w || w.startsWith('-') || /^https?:\/\//i.test(w)) return undefined; + const expanded = w + .replace(/^\$\{PWD\}(?=\/|$)|^\$PWD(?=\/|$)/, () => cwd) + .replace(/^\$\{HOME\}(?=\/|$)|^\$HOME(?=\/|$)/, () => home) + .replace(/^\$\{USERPROFILE\}(?=\/|$)|^\$USERPROFILE(?=\/|$)/, () => home) + .replace(/^~/, () => home); + try { + return path.resolve(cwd, expanded); + } catch { + return undefined; + } +} + +/** + * O168: arguments of an invoked script are not deletions. `python3 x.py + * $HOME`, `node x.js "$PWD"`, `. venv/bin/activate && python -V` are normal + * commands that an uncertain command (an interpreter can write anything) + * turned into "deletes home / the workspace root" hits on a mere mention. + * The exemption covers only the delete-ancestor rule, and — like the cd + * exemption — only while the path is named NOWHERE else in the command + * (`source ./evil.sh; ls ` still hits the root, O151). An argument + * that names a protected path directly (`python3 x.py $HOME/.ssh`, + * `.git/config`) still classifies as before, and the interpreter inline + * forms (`-c` / `-e`) keep the old behavior. Actual writes stay covered by + * layers 2–3 (script scan + post-run snapshot). + */ +function scriptArgumentMentions(forms: readonly string[], cwd: string, home: string): Set { + const out = new Set(); + const argTokens = new Set(); + const add = (raw: string | undefined) => { + if (raw === undefined) return; + const r = resolveScriptArgWord(raw, cwd, home); + if (r) { + out.add(r); + const stripped = stripEdgeQuotes(raw); + argTokens.add(raw); + argTokens.add(stripped); + argTokens.add(stripped.replace(/[\\/]+$/, '')); + } + }; + for (const command of forms) { + for (const segment of command.split(/&&|\|\||[;|\n]/)) { + const words = segment.trim().split(/\s+/).filter(Boolean); + let i = 0; + while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(stripEdgeQuotes(words[i]!))) + i += 1; + if (i >= words.length) continue; + const head = path.basename(stripEdgeQuotes(words[i]!)).toLowerCase(); + const rest = words.slice(i + 1); + if (DOT_SOURCE_HEADS.has(head)) { + // The bare `.` itself resolves to cwd; the operand is the sourced file. + add('.'); + add(rest[0]); + continue; + } + if (!SCRIPT_RUN_HEADS.has(head) && !/^python3\.\d+$/.test(head)) continue; + if (rest.some((a) => INLINE_CODE_FLAGS.has(stripEdgeQuotes(a)))) continue; + const scriptIdx = rest.findIndex((a) => !stripEdgeQuotes(a).startsWith('-')); + if (scriptIdx < 0) continue; + for (const arg of rest.slice(scriptIdx + 1)) add(arg); + } + } + // Named anywhere else in any form → the exemption does not apply. + for (const command of forms) { + for (const word of command.split(/[\s'"`;&|()<>=]+/)) { + if (!word || argTokens.has(word)) continue; + const r = resolveScriptArgWord(word, cwd, home); + if (r) { + out.delete(r); + out.delete(r.replace(/[\\/]+$/, '')); + } + } + } + return out; +} + /** * O151: commands that only READ the paths they are given. Naming the * workspace root as their operand cannot delete the root's `.git`. @@ -1081,6 +1196,15 @@ export function findShellProtectedWrites( let rootReadOnlyMemo: boolean | undefined; const rootReadOnly = () => (rootReadOnlyMemo ??= workspaceRootOnlyReadOnly(command, ctx)); // Prefer absolute mentions from the shared detector; fall back to text regex. + // O168: a script's arguments (`python3 x.py $HOME`, `node x.js "$PWD"`, + // the `.` of `. venv/bin/activate`) are mentions, not deletions — the + // delete-ancestor rule is skipped for them (direct protected paths still + // classify). + const scriptArgs = scriptArgumentMentions( + winForm ? [winForm, command] : [command], + ctx.cwd, + homeOf(ctx) + ); for (const m of mentions) { // O103: a word still holding `$VAR` / `$(…)` is not a path we resolved; // expanding it to '' used to misreport the workspace root as git internals. @@ -1092,9 +1216,10 @@ export function findShellProtectedWrites( // O151: the root named only as a read-only operand (`git -C diff`, // `ls -la `) besides that single cd is not a delete of its .git. const isReadOnlyRoot = !isCd && isWorkspaceRootPath(m, ctx) && rootReadOnly(); + const isScriptArg = scriptArgs.has(path.resolve(ctx.cwd, m)); const hit = classifyProtectedPath(m, ctx) ?? - (isCd || isReadOnlyRoot ? undefined : deletesProtectedAncestor(m, ctx)); + (isCd || isReadOnlyRoot || isScriptArg ? undefined : deletesProtectedAncestor(m, ctx)); if (!hit || seen.has(hit.resolved)) continue; seen.add(hit.resolved); hits.push({ diff --git a/test/o127-linked-worktree-exec-keys.spec.mjs b/test/o127-linked-worktree-exec-keys.spec.mjs index 6d9bf491..1c1b3ac1 100644 --- a/test/o127-linked-worktree-exec-keys.spec.mjs +++ b/test/o127-linked-worktree-exec-keys.spec.mjs @@ -192,7 +192,7 @@ await check('P1: cd && node script → no pre-check hit', () => ); } }); -await check('P2: still blocked — root as script arg, rm of root, writes under .git', () => { +await check('P2a (O168): the root as a script argument is not a deletion', () => { const r = makeLinked(); const ws = r.wt; const ctx = { cwd: ws, workspaceRoot: ws }; @@ -200,6 +200,18 @@ await check('P2: still blocked — root as script arg, rm of root, writes under `cd ${ws} && node x.mjs ${ws}`, `cd ${ws} && node x.mjs ${ws}/`, `node x.mjs ${ws}`, + ]) { + // O168: a script's arguments are mentions, not deletions — actual writes + // stay covered by the script scan (layer 2) and the post-run snapshot + // (layer 3). + assert.deepEqual(findShellProtectedWrites(c, ctx), [], c); + } +}); +await check('P2b: still blocked — rm of root, writes under .git', () => { + const r = makeLinked(); + const ws = r.wt; + const ctx = { cwd: ws, workspaceRoot: ws }; + for (const c of [ `cd ${ws} && rm -rf ${ws}`, `cd ${ws} && bash x.sh > .git`, `cd ${r.main}/.git && node x.mjs`, From 7c6e6494ce49d89f8add07189f33f460df6ed76d Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 19 Oct 2026 01:29:59 +0800 Subject: [PATCH 2/2] test(O168): regression spec; format --- src/safety/protected-paths.ts | 11 +- test/o168-interpreter-arg-writes.spec.mjs | 138 ++++++++++++++++++++++ 2 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 test/o168-interpreter-arg-writes.spec.mjs diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index e2620a9c..e66a76df 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -620,16 +620,7 @@ const SCRIPT_RUN_HEADS = new Set([ 'ash', ]); /** Inline-code flags: with one of these the invocation is not a script form. */ -const INLINE_CODE_FLAGS = new Set([ - '-c', - '-e', - '-E', - '-p', - '-r', - '--eval', - '--print', - 'eval', -]); +const INLINE_CODE_FLAGS = new Set(['-c', '-e', '-E', '-p', '-r', '--eval', '--print', 'eval']); const DOT_SOURCE_HEADS = new Set(['.', 'source']); function stripEdgeQuotes(w: string): string { diff --git a/test/o168-interpreter-arg-writes.spec.mjs b/test/o168-interpreter-arg-writes.spec.mjs new file mode 100644 index 00000000..332e9c7d --- /dev/null +++ b/test/o168-interpreter-arg-writes.spec.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * O168: the write-target rules must not block normal commands. On main + * (O101/O151), an uncertain command (any interpreter run) put every absolute + * mention through the delete-ancestor rule, so `python3 x.py $HOME`, + * `node x.js "$PWD"`, `. venv/bin/activate && python -V` and `python3 x.py + * /tmp` were reported as deleting home / the workspace root — and a NON-git + * directory's $PWD was labeled "git repository internals". A script's + * arguments are mentions, not deletions: the ancestor rule now skips them. + * Directly protected arguments (`$HOME/.ssh`, `.git/config`), the interpreter + * inline forms, and real deletes (`rm -rf $HOME`, `mv $HOME /tmp`) are + * unchanged — actual writes stay covered by the script scan (layer 2) and the + * post-run snapshot (layer 3). + */ +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 { findShellProtectedWrites } from '../dist/safety/protected-paths.js'; + +const WS = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'o168-'))); +// Home lives OUTSIDE the workspace, like a real one (an inside-workspace +// home makes the home anchor swallow the workspace-root anchor). +const HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'o168home-'))); +fs.mkdirSync(HOME, { recursive: true }); +fs.writeFileSync(path.join(WS, 'x.py'), 'import sys\nprint(sys.argv)\n'); +fs.writeFileSync(path.join(WS, 'x.js'), 'console.log(process.argv)\n'); +fs.writeFileSync(path.join(WS, 'x.sh'), 'echo "$@"\n'); +fs.mkdirSync(path.join(WS, 'venv', 'bin'), { recursive: true }); +fs.writeFileSync(path.join(WS, 'venv', 'bin', 'activate'), 'export VIRTUAL_ENV=x\n'); +const ctx = { + cwd: WS, + workspaceRoot: WS, + configDirs: [], + env: process.env, + home: HOME, +}; + +const hitsOf = (cmd) => findShellProtectedWrites(cmd, ctx).map((h) => `${h.raw}:${h.category}`); + +// ── policy: normal commands are no longer hits ────────────────────────────── + +const GREEN = [ + 'python3 x.py $HOME', + 'python3 -u x.py $HOME', + 'python3 x.py /tmp', + 'node x.js "$PWD"', + 'node x.js $HOME', + 'sh x.sh $HOME', + 'bash x.sh "$PWD"', + '. venv/bin/activate && python -V', + 'source venv/bin/activate && python -V', + '. ./venv/bin/activate && python3 -V', +]; + +for (const cmd of GREEN) { + test(`policy GREEN: ${cmd}`, () => { + assert.deepEqual(hitsOf(cmd), [], cmd); + }); +} + +// ── policy: protection unchanged where it matters ────────────────────────── + +const RED = [ + ['python3 x.py $HOME/.ssh', 'ssh'], + ['python3 x.py .git/config', 'git'], + ['node x.js ~/.ssh/authorized_keys', 'ssh'], + ['rm -rf $HOME', 'ssh'], + ['mv $HOME /tmp', 'ssh'], + ['rm -rf .git', 'git'], + ['rm -rf "$PWD"', 'git'], +]; + +for (const [cmd, category] of RED) { + test(`policy RED: ${cmd} still hits (${category})`, () => { + const hits = hitsOf(cmd); + assert.ok(hits.length > 0, cmd); + assert.ok( + hits.some((h) => h.endsWith(`:${category}`)), + `${cmd} -> ${JSON.stringify(hits)}` + ); + }); +} + +test('policy RED: interpreter inline form keeps the old behavior', () => { + // No script file: the argument is not a script argument, so the ancestor + // rule still applies (stricter only). + const hits = hitsOf('python3 -c "print(1)" $HOME'); + assert.ok(hits.length > 0, JSON.stringify(hits)); +}); + +// ── process: the four commands run (exit 0) ──────────────────────────────── + +const FINAL = 'All done, everything is finished.'; + +async function runsClean(command) { + return o152Scenario({ + lang: 'en', + gitInit: false, + seed: { + 'x.py': 'import sys\nprint(sys.argv)\n', + 'x.js': 'console.log(process.argv)\n', + 'x.sh': 'echo "$@"\n', + 'venv/bin/activate': 'export VIRTUAL_ENV=x\n', + }, + steps: () => [{ name: 'exec', args: { command } }], + finalText: FINAL, + }); +} + +const E2E_GREEN = [ + 'python3 x.py $HOME && node -e 1', + 'node x.js "$PWD" && node -e 1', + '. venv/bin/activate && python -V && node -e 1', +]; + +for (const command of E2E_GREEN) { + test( + `process GREEN: ${command} → exit 0`, + { skip: IS_WIN ? 'sh-form e2e; policy covered above' : false }, + async () => { + const r = await runsClean(command); + assert.equal(r.code, 0, `code=${r.code} last=${r.last}\n${(r.err ?? '').slice(-400)}`); + } + ); +} + +test( + 'process RED control: python3 x.py $HOME/.ssh still blocks (exit 9)', + { skip: IS_WIN ? 'sh-form e2e; policy covered above' : false }, + async () => { + const r = await runsClean('python3 x.py $HOME/.ssh; node -e 1'); + assert.equal(r.code, 9, `code=${r.code} last=${r.last}`); + } +);