Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ subcommand position and the global one; `git push -u origin main`
「可执行读取」表里,O152 的执行前拦截从未触发;现已加入(子命令位与全局位都识别)。
`git push -u origin main`(--set-upstream)不受影响。

### 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 <root>` 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
<root>` 仍会命中)。直接点名受保护路径的参数(`python3 x.py $HOME/.ssh`、
`.git/config`)、解释器内联形式、真正的删除(`rm -rf $HOME`、`mv $HOME
/tmp`、`rm -rf "$PWD"`)行为不变;实际写入仍由脚本扫描(第 2 层)与运行后
快照(第 3 层)兜底。

### O162 — hook output over the capture limit blocks

Each hook's stdout and stderr are captured up to 10 MiB per stream; the verdict
Expand Down
8 changes: 7 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,13 @@ one line: press `e` to switch to English.
fails closed — the run ends blocked / the call is denied whatever the exit code or the readable
part of the output said. A `blocking: false` hook cannot restrict a call, so its oversized
output does not block either.

- **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.
- **git pack flags are exec-capable (O169)**: `git clone --upload-pack=<cmd>`,
`git fetch`/`git push` with `--upload-pack` / `--receive-pack=<cmd>` (glued, split, or
clone's short `-u`) make git run that command; they are pre-blocked like `git -c` and
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ moss config set language auto # 默认:仅当系统区域以 zh 开
- **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 被清空(只剩 `.`、空项)时换成一个不存在的绝对目录,不会留空串。
- **hook 输出超过捕获上限即拦截(O162)**:每个 hook 的 stdout / stderr 各有 10 MiB 捕获上限;一旦有输出被截断,判定(最后一行 JSON)就不可信,所有阻塞类事件(`PreToolUse`、`UserPromptSubmit`、`PermissionRequest`、`Stop`)一律按拦截失败关闭——退出码与可读部分说了什么都不算数。`blocking: false` 的 hook 不能收紧任何调用,其输出超限同样不拦截。

- **脚本的参数不是删除(O168)**:解释器脚本形式的参数(`python3 x.py $HOME`、`node x.js "$PWD"`)、`sh x.sh` 的参数、`.`/`source` 的操作数与裸 `.` 不再触发「删除受保护祖先」规则——正常命令不再被误拦,非 git 目录也不再被说成 git 仓库内部文件。豁免与 cd 一样仅限该路径未在命令其他位置出现;直接点名受保护路径(`$HOME/.ssh`、`.git/config`)、内联形式与真实删除行为不变,实际写入仍由脚本扫描与运行后快照兜底。
- **git 的 pack 旗标按可执行拦截(O169)**:`git clone --upload-pack=<cmd>`、`git fetch/push --upload-pack/--receive-pack=<cmd>`(含空格分开与 clone 的 `-u` 短旗标)会让 git 执行该命令,与 `git -c`、`--ext-diff` 等一样在执行前拦截;`git push -u origin main`(设上游)不受影响。
- 凭据只从 `.env` 或环境变量读,绝不硬编码、不进日志、不传子进程、不写设备清单。
- 无账号、无云服务、无遥测;provider 是普通 HTTP 端点。
Expand Down
118 changes: 117 additions & 1 deletion src/safety/protected-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,112 @@ function cdOnlyOperands(command: string, cwd: string): Set<string> {
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 <root>` 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<string> {
const out = new Set<string>();
const argTokens = new Set<string>();
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel script-argument exemptions for mutating pipelines

When a script prints its path argument into a mutating downstream command, the path is named only as a script argument but still becomes a deletion target. With echo.js containing console.log(process.argv[2]), node echo.js /absolute/workspace | xargs rm -rf now returns no protected-path hits; the parent commit blocks it. A local headless CLI reproduction deleted the workspace's README, and snapshot restoration did not recover it. The inspected xargs --help explicitly says it runs COMMAND with “more arguments read from input.” Apply downstream-pipeline safety checks before granting this exemption, as the existing read-only-root exemption does.

Useful? React with 👍 / 👎.

}
}
// 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;
Comment on lines +696 to +697

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track exempt argument occurrences rather than token values

When another command repeats an exempt argument verbatim, argTokens.has(word) skips that occurrence too, so the promised “named nowhere else” check never cancels the exemption. For example, with the workspace at ../proj, node benign.js ../proj; xargs rm -rf ../proj </dev/null now returns no protected-path hits, whereas the parent commit blocks it. Locally, the headless CLI executed this command and deleted the workspace's README before the post-run check reported damage. The inspected xargs --help confirms it runs COMMAND with “arguments INITIAL-ARGS”; the second occurrence is therefore a deletion operand, not a script argument. Remove only the identified script-argument occurrences before checking the remaining command.

Useful? React with 👍 / 👎.

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`.
Expand Down Expand Up @@ -1081,6 +1187,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.
Expand All @@ -1092,9 +1207,10 @@ export function findShellProtectedWrites(
// O151: the root named only as a read-only operand (`git -C <root> diff`,
// `ls -la <root>`) 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({
Expand Down
14 changes: 13 additions & 1 deletion test/o127-linked-worktree-exec-keys.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,26 @@ await check('P1: cd <worktree root> && 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 };
for (const c of [
`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`,
Expand Down
138 changes: 138 additions & 0 deletions test/o168-interpreter-arg-writes.spec.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
);
Loading