From 34e966a0ff89e6e241baeae6180a8afce352a30d Mon Sep 17 00:00:00 2001 From: Moss Grok Date: Mon, 12 Oct 2026 01:45:54 +0800 Subject: [PATCH 1/2] feat(O101): detect indirect writes to protected paths (inline/script/post-run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layers on top of O94/O97: 1) Inline python -c / node -e / bash -c with protected-path literals 2) Pre-run scan of workspace scripts (skip npm test/pytest/node --test) 3) Post-run metadata snapshot (mtime/size/hash only) → O98 incomplete Best-effort interception, not a sandbox. No auto-restore; notices say 「已被修改」not「已拦截」. Delayed background writes are a known limit. --- README.md | 15 +- src/cli/protected-path-prompt.ts | 22 +- src/cli/run-completion.ts | 19 + src/cli/run-outcome.ts | 19 + src/safety/indirect-write-detect.ts | 401 +++++++++++++++++++++ src/safety/protected-path-snapshot.ts | 368 ++++++++++++++++++++ src/safety/protected-paths.ts | 47 ++- src/tools/builtin.ts | 39 ++- test/o101-indirect-writes.spec.mjs | 479 ++++++++++++++++++++++++++ 9 files changed, 1394 insertions(+), 15 deletions(-) create mode 100644 src/safety/indirect-write-detect.ts create mode 100644 src/safety/protected-path-snapshot.ts create mode 100644 test/o101-indirect-writes.spec.mjs diff --git a/README.md b/README.md index e13acfae..0ff3d0f3 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,7 @@ moss config set language auto # 默认:仅当系统区域以 zh 开 - 会话级规则下一个工具调用即生效;`/permissions persist` 写用户配置重启仍生效; - `--read-only` / `MOSS_SAFETY_MODE=read-only` 是压过任何模式(含 full)的只读上限。 - **本机硬拦截永不撤**:本机 `exec` 的毁灭性命令(`rm -rf /` 等)与路径逃逸在 full 模式下同样被拦——full 跳过的是询问,不是检查。设备侧的同一类命令不硬拦死:TTY 确认、allow 规则,或显式信任之后会真的执行。 -- **受保护路径(O94)只是尽力拦截,不是沙箱**:文件工具与 shell 命令直接写入、删除或移动受保护路径(如 `~/.ssh`、`.git/` 内的文件)时,即使在 full 模式下也要确认一次;`git commit`、`git checkout` 等 git 自身对 `.git/` 的写入不弹确认。shell 这一层靠解析命令行识别写入目标,属于尽力拦截,不是操作系统沙箱。通过脚本间接写入(如 `python x.py`、`node script.js`、`bash x.sh` 在脚本里改受保护路径)不在覆盖范围,由 O101 跟踪。 +- **受保护路径(O94)只是尽力拦截,不是沙箱**:文件工具与 shell 命令直接写入、删除或移动受保护路径(如 `~/.ssh`、`.git/` 内的文件)时,即使在 full 模式下也要确认一次;`git commit`、`git checkout` 等 git 自身对 `.git/` 的写入不弹确认。shell 这一层靠解析命令行识别写入目标,属于尽力拦截,不是操作系统沙箱。通过脚本间接写入(如 `python x.py`、`node -e`、`bash -c`)由 O101 三层处理:内联代码与工作区脚本在执行前走同一套确认(`-p` 下拒绝);绕过前两层的改动在命令结束后用元数据快照检出,标成「已被修改」并让 run outcome 未完成(退出非 0),不自动还原。shell 层仍是尽力拦截,不是沙箱;后台延后写入是已知局限。 - **显式信任设备**(任一即可;deny 仍赢):`--trust-device`(仅本进程)、`MOSS_DEVICE_TRUST=full`、`permissions.deviceTrust=full`、`permissions.trustedDevices` 或 `MOSS_DEVICE_TRUST_DEVICES`(逗号分隔的 host / device id)。确认框里选 `a` 只信任提示里写明的范围(例如同一 unit 的 `systemctl restart` 或 `stop`,或同一命令前缀),不是整台设备的全部毁灭性操作。读取 `/etc/shadow`、私钥、`sshd_config`、`authorized_keys` 归入 `sensitive`:同样要确认,但文案和证据不把它叫成毁灭性修改。确认与拒绝文案跟随界面语言(`--lang`、`MOSS_LANG` 或用户配置 `language`;否则系统区域以 `zh` 开头时为简体中文)。每次决定写入 `.moss/evidence.jsonl`(`metric: device_policy`),有进行中的任务时同时写入时间线 `note`。策略说明见 [`docs/superpowers/plans/2026-10-09-device-safety-policy.md`](docs/superpowers/plans/2026-10-09-device-safety-policy.md)。 - **旧键兼容**(一版宽限):`profile` / `trustedTools` / `deniedTools` / `safetyMode` / `approvalPolicy` 读入即按映射表翻译(cautious→manual+只读上限、balanced→manual、autonomous→full、trustedTools→allow 规则、deniedTools→deny 规则),写侧提示 deprecated,新配置请用 `permissions.*` 块。 - 凭据只从 `.env` 或环境变量读,绝不硬编码、不进日志、不传子进程、不写设备清单。 @@ -818,13 +818,14 @@ one line: press `e` to switch to English. escapes stay blocked in full mode — full skips the asking, not the checking. The same shapes on the device are not a permanent hard block: a TTY confirmation, an allow rule, or explicit trust runs them for real. -- **Protected paths (O94) are best-effort interception, not a sandbox**: direct writes, deletes or +- **Protected paths (O94 + O101) are best-effort interception, not a sandbox**: direct writes, deletes or moves of a protected path (e.g. `~/.ssh`, files inside `.git/`) by file tools or shell commands - need one confirmation, even in full mode; git's own writes to `.git/` (`git commit`, - `git checkout`, …) do not prompt. The shell layer finds write targets by parsing the command line; - it is best-effort interception, NOT an OS sandbox. Indirect writes via scripts (`python x.py`, - `node script.js`, `bash x.sh` modifying a protected path inside the script) are not covered and - are tracked as O101. + need one confirmation even in full mode; `git`'s own writes to `.git/` do not. Shell detection + parses the command line (O97/O94) and, with O101, scans inline `-c`/`-e` code and workspace + scripts before run. Anything that still slips through is caught after the command by a metadata + snapshot (mtime/size/hash only — never file contents): the run is marked incomplete ("modified", + not "intercepted"), exit non-zero, no auto-restore. Still not an OS sandbox; delayed background + writes are a known limit. - **Trust a device** (any one; deny rules still win): `--trust-device` (this process only), `MOSS_DEVICE_TRUST=full`, `permissions.deviceTrust=full`, or `permissions.trustedDevices` / `MOSS_DEVICE_TRUST_DEVICES` (comma-separated host or device id). Answering `a` trusts only the diff --git a/src/cli/protected-path-prompt.ts b/src/cli/protected-path-prompt.ts index a27f9837..0dfeec06 100644 --- a/src/cli/protected-path-prompt.ts +++ b/src/cli/protected-path-prompt.ts @@ -30,12 +30,28 @@ function shown(hit: ProtectedPathHit): string { .slice(0, 200); } -/** First line: operation + the path as named + why. */ +/** O101: "deploy.py line 12 would write ~/.ssh/authorized_keys". */ +function sourceClause(hit: ProtectedPathHit, zh: boolean): string { + const src = hit.source; + if (!src) return ''; + const script = src.script.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 120); + if (src.kind === 'inline') { + return zh + ? `(内联 ${script}${src.line ? ` 第 ${src.line} 行` : ''})` + : ` (inline ${script}${src.line ? ` line ${src.line}` : ''})`; + } + return zh + ? `(\`${script}\`${src.line ? ` 第 ${src.line} 行` : ''})` + : ` (\`${script}\`${src.line ? ` line ${src.line}` : ''})`; +} + +/** First line: operation + the path as named + why (+ O101 script/line). */ export function protectedPathHeadline(hit: ProtectedPathHit, zh: boolean): string { const label = PROTECTED_CATEGORY_LABEL[hit.category]; + const src = sourceClause(hit, zh); return zh - ? `要${OP_ZH[hit.operation]} \`${shown(hit)}\`(${label.zh},受保护路径)` - : `About to ${OP_EN[hit.operation]} \`${shown(hit)}\` (${label.en}, a protected path)`; + ? `要${OP_ZH[hit.operation]} \`${shown(hit)}\`${src}(${label.zh},受保护路径)` + : `About to ${OP_EN[hit.operation]} \`${shown(hit)}\`${src} (${label.en}, a protected path)`; } export function protectedPathView( diff --git a/src/cli/run-completion.ts b/src/cli/run-completion.ts index 0dce2c33..db2a5dec 100644 --- a/src/cli/run-completion.ts +++ b/src/cli/run-completion.ts @@ -21,6 +21,7 @@ * than calling these helpers directly. */ import type { MossAgentEvent } from '../core/index.js'; +import { parseProtectedPathModMarker } from '../safety/protected-path-snapshot.js'; import { ExitCode } from './exit-codes.js'; export type VerificationCheck = 'format' | 'lint' | 'typecheck' | 'test' | 'check'; @@ -51,6 +52,11 @@ export interface RunCompletionTracker { lastStep?: string; /** Latest MOSS_GOAL_VERIFY_CMD result (O98 — must feed the tracker). */ goalVerify?: GoalVerifyResult; + /** + * O101 layer 3: protected paths modified by a shell command (post-run + * detection). When set, the run is incomplete regardless of model claims. + */ + protectedPathChanges?: Array<{ path: string; kind: string; command?: string }>; /** @internal pending tool_start inputs keyed by toolCallId. */ pending: Map }>; } @@ -158,6 +164,19 @@ export function noteRunCompletionEvent(tracker: RunCompletionTracker, event: Mos tracker.checks.set(check, { command, passed: !failed }); } } + + // O101 layer 3: post-run protected-path modifications → incomplete. + if (typeof event.result === 'string') { + const mods = parseProtectedPathModMarker(event.result); + if (mods && mods.length > 0) { + const command = typeof input.command === 'string' ? input.command : toolName; + tracker.protectedPathChanges = [ + ...(tracker.protectedPathChanges ?? []), + ...mods.map((m) => ({ ...m, command })), + ]; + tracker.lastFailedStep = describeStep(toolName, input); + } + } } /** diff --git a/src/cli/run-outcome.ts b/src/cli/run-outcome.ts index a07ed5ad..adbac4a3 100644 --- a/src/cli/run-outcome.ts +++ b/src/cli/run-outcome.ts @@ -25,6 +25,7 @@ import { type RunCompletionTracker, type VerificationCheck, } from './run-completion.js'; +import { protectedPathModReason } from '../safety/protected-path-snapshot.js'; export type { GoalVerifyResult, RunCompletionTracker, VerificationCheck }; export { createRunCompletionTracker, noteGoalVerifyResult, noteRunCompletionEvent }; @@ -133,6 +134,24 @@ export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome { stuckAtZh = 'Stop 钩子'; } + // O101 layer 3: protected-path post-run detection → incomplete (model + // success claims must not override). + if (tracker.protectedPathChanges && tracker.protectedPathChanges.length > 0) { + status = status ?? 'incomplete'; + const mods = tracker.protectedPathChanges; + const reason = protectedPathModReason( + mods.map((m) => ({ + path: m.path, + kind: (m.kind as 'added' | 'modified' | 'deleted') || 'modified', + })), + mods[0]?.command + ); + reasonsEn.push(reason.en); + reasonsZh.push(reason.zh); + stuckAt = stuckAt ?? reason.stuckEn; + stuckAtZh = stuckAtZh ?? reason.stuckZh; + } + // Authoritative VERIFY_CMD success: the accept loop is the completion signal. // Do not demote to incomplete just because the continuation turn itself // edited no files (the model may have fixed things in an earlier turn). diff --git a/src/safety/indirect-write-detect.ts b/src/safety/indirect-write-detect.ts new file mode 100644 index 00000000..af22b2dc --- /dev/null +++ b/src/safety/indirect-write-detect.ts @@ -0,0 +1,401 @@ +/** + * O101 layers 1–2: catch protected-path writes that O94's command-line + * detector cannot see — inline interpreter code (`python -c`, `node -e`, + * `bash -c`) and workspace script files invoked by the command. + * + * Best effort, not a sandbox. Script scan is read-only; paths are realpath'd + * and must stay under the workspace. Test runners (`npm test`, `pytest`, + * `node --test`, …) are never deep-scanned — layer 3 (post-run snapshot) + * covers those. Scan failure → same as O94 uncertain: confirm only when the + * command itself names a protected path. + * + * This module returns raw findings (path text + source location). Classification + * lives in protected-paths.ts so there is no circular import. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const MAX_SCRIPT_BYTES = 256 * 1024; + +/** Commands whose script trees we refuse to expand (rely on post-run). */ +const SKIP_DEEP_SCAN = + /(?:^|[\s;&|])(?:npm\s+(?:run\s+)?(?:test|t)\b|yarn\s+test\b|pnpm\s+test\b|npx\s+(?:vitest|jest|mocha)\b|node\s+--test\b|pytest\b|python(?:3(?:\.\d+)?)?\s+-m\s+pytest\b|vitest\b|jest\b|mocha\b|cargo\s+test\b|go\s+test\b)/i; + +const INTERPRETER_INLINE: Record = { + node: ['-e', '--eval', '-p', '--print'], + nodejs: ['-e', '--eval', '-p', '--print'], + bun: ['-e', '--eval'], + deno: ['eval'], + python: ['-c'], + python3: ['-c'], + python2: ['-c'], + ruby: ['-e'], + php: ['-r'], + perl: ['-e', '-E'], + lua: ['-e'], +}; + +const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']); +const SCRIPT_EXTS = new Set([ + '.py', + '.js', + '.mjs', + '.cjs', + '.ts', + '.sh', + '.bash', + '.zsh', + '.rb', + '.pl', + '.php', + '.lua', +]); + +const WRITE_API = + /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|copyFile|copyFileSync|rename|renameSync|truncate|truncateSync|write_text|write_bytes|shutil\.(?:copy|move|copyfile|copy2)|os\.(?:replace|remove|unlink|rename)|Path\([^)]*\)\.(?:write_text|write_bytes|open)|File\.write|file_put_contents|io\.open)\b|\bopen\s*\([^)]*['"][wax+]|\.write\s*\(|\bfs\.\w*[wW]rite|>>?\s*[^\s&|;]|tee\s+/; + +/** Path-like tokens that look protected (literal form in source). */ +const PROTECTED_LIT = + /(?:~\/\.ssh|\$HOME\/\.ssh|\$\{HOME\}\/\.ssh|\.ssh\/|\/\.ssh(?:\/|$)|authorized_keys|id_rsa|id_ed25519|\.git\/(?:config|HEAD|hooks|refs)|(?:^|[\s'"`=(,[{])\.env(?:\.[\w.-]+)?(?:$|[\s'"`),;\]}])|\.envrc|\.bashrc|\.zshrc|\.profile(?:$|[\s'"`])|os\.path\.expanduser\s*\(\s*['"]~\/\.ssh)/i; + +export interface IndirectWriteSource { + kind: 'inline' | 'script'; + /** Script path relative to cwd, or `python -c` / `node -e` for inline. */ + script: string; + line?: number; + /** Offending line text (never contains file contents we wrote — only source). */ + detail?: string; +} + +export interface IndirectWriteFinding { + /** Path text as it appears in the source (e.g. `~/.ssh/authorized_keys`). */ + raw: string; + source: IndirectWriteSource; + /** Scan could not fully decide (symlink to protected, unreadable + named). */ + uncertain?: boolean; +} + +export type ScriptScanStatus = + | 'ok' + | 'skipped-runner' + | 'outside-workspace' + | 'unreadable' + | 'too-large' + | 'binary'; + +export interface ScriptScanResult { + status: ScriptScanStatus; + findings: IndirectWriteFinding[]; + scriptPath?: string; +} + +function basenameCmd(word: string): string { + return ( + word + .split(/[\\/]/) + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') ?? '' + ); +} + +function tokenizeSimple(segment: string): string[] { + const tokens: string[] = []; + const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = pattern.exec(segment))) tokens.push(m[1] ?? m[2] ?? m[3] ?? ''); + return tokens; +} + +function skipPrefixes(tokens: string[]): number { + let i = 0; + while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i += 1; + const prefixes = new Set(['sudo', 'doas', 'env', 'command', 'nohup', 'time', 'nice', 'stdbuf']); + while (i < tokens.length && prefixes.has(basenameCmd(tokens[i]!))) { + i += 1; + while (i < tokens.length && (tokens[i]!.startsWith('-') || /^[A-Za-z_]\w*=/.test(tokens[i]!))) { + const flag = tokens[i]!; + i += 1; + if (/^-[^-]*[uCg]$/.test(flag) || flag === '--user' || flag === '--group') i += 1; + } + } + return i; +} + +function lineOf(text: string, index: number): number { + let line = 1; + for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === '\n') line += 1; + return line; +} + +function extractProtectedRaw(match: string): string { + const m = match.match( + /(~\/\.ssh(?:\/[\w.-]+)?|\.ssh\/[\w.-]+|\.git\/(?:config|HEAD|hooks\/[\w.-]+|refs(?:\/[\w./-]+)?)|\.env(?:\.[\w.-]+)?|\.envrc|\.bashrc|\.zshrc|\.profile)/i + ); + return m?.[1] ?? match.trim().slice(0, 80); +} + +function looksBinary(buf: Buffer): boolean { + const n = Math.min(buf.length, 8000); + let weird = 0; + for (let i = 0; i < n; i += 1) { + const b = buf[i]!; + if (b === 0) return true; + if (b < 7 || (b > 14 && b < 32 && b !== 9 && b !== 10 && b !== 13)) weird += 1; + } + return weird / Math.max(n, 1) > 0.3; +} + +function realpathSafe(p: string): string | undefined { + try { + return fs.realpathSync.native(p); + } catch { + return undefined; + } +} + +function isUnder(child: string, parent: string): boolean { + const c = path.resolve(child).replace(/\\/g, '/').toLowerCase(); + const p = path.resolve(parent).replace(/\\/g, '/').toLowerCase(); + return c === p || c.startsWith(p.endsWith('/') ? p : `${p}/`); +} + +/** + * Scan source text for a write API near a protected-path literal. + * Returns findings with line numbers; never executes the source. + */ +export function scanSourceForProtectedWrites( + source: string, + displayName: string, + kind: 'inline' | 'script' +): IndirectWriteFinding[] { + if (!source || !WRITE_API.test(source)) return []; + const findings: IndirectWriteFinding[] = []; + const seen = new Set(); + let m: RegExpExecArray | null; + const re = new RegExp(PROTECTED_LIT.source, 'gi'); + while ((m = re.exec(source)) !== null) { + const raw = extractProtectedRaw(m[0]!); + const key = raw.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const line = lineOf(source, m.index); + const lineText = source.split('\n')[line - 1]?.trim().slice(0, 120) ?? ''; + findings.push({ + raw, + source: { + kind, + script: displayName, + line, + detail: lineText.replace(/[\u0000-\u001f\u007f]/g, ''), + }, + }); + } + return findings; +} + +/** + * Read one workspace script (no execute) and look for protected-path writes. + * `workspaceRoot` / `cwd` must be absolute. + */ +export function scanWorkspaceScript( + scriptPath: string, + cwd: string, + workspaceRoot?: string +): ScriptScanResult { + const abs = path.resolve(cwd, scriptPath); + const workspace = path.resolve(workspaceRoot ?? cwd); + const real = realpathSafe(abs) ?? abs; + if (!isUnder(real, workspace)) { + return { status: 'outside-workspace', findings: [], scriptPath: real }; + } + // Symlink whose target looks like a protected path → uncertain finding. + try { + const st = fs.lstatSync(abs); + if (st.isSymbolicLink()) { + const target = fs.readlinkSync(abs); + if (PROTECTED_LIT.test(target) || /(?:^|\/)(?:\.ssh|\.git)(?:\/|$)/i.test(target)) { + return { + status: 'ok', + scriptPath: real, + findings: [ + { + raw: target, + source: { + kind: 'script', + script: scriptPath, + line: 1, + detail: `symlink → ${target}`, + }, + uncertain: true, + }, + ], + }; + } + } + } catch { + /* fall through to read */ + } + let buf: Buffer; + try { + const st = fs.statSync(real); + if (st.size > MAX_SCRIPT_BYTES) return { status: 'too-large', findings: [], scriptPath: real }; + buf = fs.readFileSync(real); + } catch { + return { status: 'unreadable', findings: [], scriptPath: real }; + } + if (looksBinary(buf)) return { status: 'binary', findings: [], scriptPath: real }; + const text = buf.toString('utf8'); + const display = path.relative(cwd, real) || scriptPath; + return { + status: 'ok', + scriptPath: real, + findings: scanSourceForProtectedWrites(text, display.replace(/\\/g, '/'), 'script'), + }; +} + +/** Collect script-file operands from a shell command (best effort). */ +export function listInvokedScripts(command: string): string[] { + const out: string[] = []; + for (const segment of command.split(/(?:&&|\|\||[;|\n])/g)) { + const tokens = tokenizeSimple(segment.trim()); + const i = skipPrefixes(tokens); + if (i >= tokens.length) continue; + const cmd = basenameCmd(tokens[i]!); + const args = tokens.slice(i + 1); + + if (SHELLS.has(cmd)) { + const cIdx = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a)); + if (cIdx >= 0) continue; + const file = args.find((a) => !a.startsWith('-')); + if (file) out.push(file); + continue; + } + + const inlineFlags = + INTERPRETER_INLINE[cmd] ?? (/^python3\.\d+$/.test(cmd) ? ['-c'] : undefined); + if (inlineFlags) { + if (args.some((a) => inlineFlags.includes(a))) continue; + if (args[0] === '-m') continue; + const file = args.find( + (a) => !a.startsWith('-') && SCRIPT_EXTS.has(path.extname(a).toLowerCase()) + ); + if (file) out.push(file); + else { + const any = args.find((a) => !a.startsWith('-') && !a.includes('=')); + if (any && /\.(py|js|mjs|cjs|ts|sh|bash|zsh|rb|pl)$/i.test(any)) out.push(any); + } + continue; + } + + const head = tokens[i]!; + if ( + SCRIPT_EXTS.has(path.extname(head).toLowerCase()) || + /^\.\/.*\.(sh|bash|zsh|py|js)$/i.test(head) + ) { + out.push(head); + } + } + return out; +} + +/** Collect inline interpreter scripts from the command. */ +export function listInlineScripts(command: string): Array<{ lang: string; code: string }> { + const out: Array<{ lang: string; code: string }> = []; + for (const segment of command.split(/(?:&&|\|\||[;|\n])/g)) { + const tokens = tokenizeSimple(segment.trim()); + const i = skipPrefixes(tokens); + if (i >= tokens.length) continue; + const cmd = basenameCmd(tokens[i]!); + const args = tokens.slice(i + 1); + + if (SHELLS.has(cmd)) { + const cIdx = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a)); + if (cIdx >= 0 && args[cIdx + 1] !== undefined) { + out.push({ lang: cmd, code: args[cIdx + 1]! }); + } + continue; + } + const inlineFlags = + INTERPRETER_INLINE[cmd] ?? (/^python3\.\d+$/.test(cmd) ? ['-c'] : undefined); + if (!inlineFlags) continue; + const fIdx = args.findIndex((a) => inlineFlags.includes(a)); + if (fIdx >= 0 && args[fIdx + 1] !== undefined) { + out.push({ lang: cmd, code: args[fIdx + 1]! }); + } + } + return out; +} + +/** + * Layer 1 + 2 entry: find protected-path writes in inline code and invoked + * workspace scripts. Does not replace O97's command-line detector. + */ +export function findIndirectProtectedWrites( + command: string, + cwd: string, + workspaceRoot?: string +): IndirectWriteFinding[] { + if (!command.trim()) return []; + const findings: IndirectWriteFinding[] = []; + const seen = new Set(); + const push = (f: IndirectWriteFinding) => { + const key = `${f.raw.toLowerCase()}|${f.source.script}|${f.source.line ?? 0}`; + if (seen.has(key)) return; + seen.add(key); + findings.push(f); + }; + + for (const { lang, code } of listInlineScripts(command)) { + const label = + lang === 'node' || lang === 'nodejs' + ? 'node -e' + : SHELLS.has(lang) + ? `${lang} -c` + : `${lang} -c`; + for (const f of scanSourceForProtectedWrites(code, label, 'inline')) push(f); + } + + if (SKIP_DEEP_SCAN.test(command)) return findings; + + let sawUnreadable = false; + for (const script of listInvokedScripts(command)) { + const result = scanWorkspaceScript(script, cwd, workspaceRoot); + if (result.status === 'ok') { + for (const f of result.findings) push(f); + } else if ( + result.status === 'unreadable' || + result.status === 'too-large' || + result.status === 'binary' || + result.status === 'outside-workspace' + ) { + sawUnreadable = true; + } + } + + if (sawUnreadable && findings.length === 0) { + const named = command.match(PROTECTED_LIT); + if (named) { + push({ + raw: extractProtectedRaw(named[0]!), + source: { + kind: 'script', + script: '(unreadable)', + detail: 'scan failed; command names protected path', + }, + uncertain: true, + }); + } + } + + return findings; +} + +export function shouldSkipDeepScan(command: string): boolean { + return SKIP_DEEP_SCAN.test(command); +} + +/** True when text looks like it names a protected path (for uncertain scan). */ +export function textMentionsProtectedLiteral(text: string): boolean { + return PROTECTED_LIT.test(text); +} diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts new file mode 100644 index 00000000..a7c2c186 --- /dev/null +++ b/src/safety/protected-path-snapshot.ts @@ -0,0 +1,368 @@ +/** + * O101 layer 3: post-run detection of protected-path changes. + * + * Takes a metadata snapshot (mtime, size, content hash) BEFORE a shell + * command runs and diffs it AFTER. On change we report "已被修改" (modified), + * never "已拦截" (intercepted) — the command already ran. No auto-restore. + * + * Never puts file contents, secret values, or full hashes into notices, + * run-outcome lines, tool results, or model context. Hash is compare-only. + * + * Watched: ~/.ssh/*, workspace .env / .env.* / .envrc, and under .git only + * config, HEAD, refs/**, hooks/** (including newly added hooks). Normal + * git commit writes (index, objects, logs, ORIG_HEAD, …) are ignored. + * + * Snapshot unavailable (permission) → skip that path; never false-report + * "modified". Delayed background writes (`&`, nohup) are a known limit. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export type PathChangeKind = 'added' | 'modified' | 'deleted'; + +export interface ProtectedPathChange { + /** Display path (~/.ssh/… or relative to cwd). */ + path: string; + kind: PathChangeKind; +} + +/** Internal meta — never log `hash` in full. */ +interface PathMeta { + mtimeMs: number; + size: number; + /** sha256 hex; compare only. */ + hash: string; +} + +/** Sentinel: path exists but meta could not be read (permission). */ +const UNAVAILABLE = Symbol('unavailable'); + +type Entry = PathMeta | typeof UNAVAILABLE; + +export interface ProtectedPathSnapshot { + /** Absolute path → meta or UNAVAILABLE. Absent key = did not exist. */ + entries: Map; + /** Absolute → display form. */ + display: Map; + takenAt: number; + cwd: string; + home: string; +} + +/** Machine-readable marker for run-completion tracker (no secrets). */ +export const PROTECTED_PATH_MOD_MARKER = '[moss-o101-protected-path-modified]'; + +function homeDir(home?: string): string { + return home || process.env.HOME || process.env.USERPROFILE || os.homedir(); +} + +function displayFor(abs: string, cwd: string, home: string): string { + const rel = path.relative(cwd, abs); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel.replace(/\\/g, '/'); + const fromHome = path.relative(home, abs); + if (!fromHome.startsWith('..') && !path.isAbsolute(fromHome)) { + return fromHome ? `~/${fromHome.replace(/\\/g, '/')}` : '~'; + } + return abs.replace(/\\/g, '/'); +} + +/** PathMeta | UNAVAILABLE | null(ENOENT). */ +function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { + try { + const st = fs.lstatSync(abs); + if (st.isDirectory()) { + return { + mtimeMs: st.mtimeMs, + size: -1, + hash: crypto.createHash('sha256').update(`dir:${st.mtimeMs}`).digest('hex'), + }; + } + if (st.isSymbolicLink()) { + const target = fs.readlinkSync(abs); + return { + mtimeMs: st.mtimeMs, + size: target.length, + hash: crypto.createHash('sha256').update(`link:${target}`).digest('hex'), + }; + } + const buf = fs.readFileSync(abs); + return { + mtimeMs: st.mtimeMs, + size: st.size, + hash: crypto.createHash('sha256').update(buf).digest('hex'), + }; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') return null; + return UNAVAILABLE; + } +} + +function listDirSafe(dir: string): string[] { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +function walkFiles(dir: string, out: string[], depth = 0): void { + if (depth > 8) return; + for (const name of listDirSafe(dir)) { + if (name === '.' || name === '..') continue; + const abs = path.join(dir, name); + let st: fs.Stats; + try { + st = fs.lstatSync(abs); + } catch { + continue; + } + if (st.isDirectory()) walkFiles(abs, out, depth + 1); + else out.push(abs); + } +} + +/** + * Collect absolute paths we watch under cwd + home. + * `.git` only: config, HEAD, refs/**, hooks/**. + */ +export function listWatchedProtectedPaths(cwd: string, home?: string): string[] { + const h = homeDir(home); + const paths: string[] = []; + const ssh = path.join(h, '.ssh'); + paths.push(ssh); + for (const name of listDirSafe(ssh)) { + paths.push(path.join(ssh, name)); + } + + const ws = path.resolve(cwd); + for (const name of listDirSafe(ws)) { + if (name === '.env' || name === '.envrc' || name.startsWith('.env.')) { + paths.push(path.join(ws, name)); + } + } + + const git = path.join(ws, '.git'); + for (const name of ['config', 'HEAD']) { + paths.push(path.join(git, name)); + } + const hooks = path.join(git, 'hooks'); + paths.push(hooks); + for (const name of listDirSafe(hooks)) { + paths.push(path.join(hooks, name)); + } + const refs = path.join(git, 'refs'); + paths.push(refs); + walkFiles(refs, paths); + + return [...new Set(paths)]; +} + +function fillSnapshot( + cwd: string, + home: string, + absPaths: Iterable, + into: ProtectedPathSnapshot +): void { + for (const abs of absPaths) { + if (!into.display.has(abs)) into.display.set(abs, displayFor(abs, cwd, home)); + const m = metaOf(abs); + if (m === null) { + into.entries.delete(abs); + continue; + } + into.entries.set(abs, m); + } +} + +export function takeProtectedPathSnapshot( + cwd: string, + opts?: { home?: string } +): ProtectedPathSnapshot { + const home = homeDir(opts?.home); + const resolvedCwd = path.resolve(cwd); + const snap: ProtectedPathSnapshot = { + entries: new Map(), + display: new Map(), + takenAt: Date.now(), + cwd: resolvedCwd, + home, + }; + fillSnapshot(resolvedCwd, home, listWatchedProtectedPaths(resolvedCwd, home), snap); + return snap; +} + +/** + * After snapshot: re-list watched paths (picks up new hooks) and also re-check + * every path that appeared in `before` (so deletions are visible). + */ +export function takeProtectedPathSnapshotAfter( + before: ProtectedPathSnapshot, + opts?: { home?: string } +): ProtectedPathSnapshot { + const home = homeDir(opts?.home ?? before.home); + const cwd = before.cwd; + const snap: ProtectedPathSnapshot = { + entries: new Map(), + display: new Map(before.display), + takenAt: Date.now(), + cwd, + home, + }; + const paths = new Set([...listWatchedProtectedPaths(cwd, home), ...before.entries.keys()]); + fillSnapshot(cwd, home, paths, snap); + return snap; +} + +/** + * Diff two snapshots. Paths that were unavailable on either side are skipped — + * never false-report modified. + */ +export function diffProtectedPathSnapshots( + before: ProtectedPathSnapshot, + after: ProtectedPathSnapshot +): ProtectedPathChange[] { + const changes: ProtectedPathChange[] = []; + const all = new Set([...before.entries.keys(), ...after.entries.keys()]); + for (const abs of all) { + const bHas = before.entries.has(abs); + const aHas = after.entries.has(abs); + const b = bHas ? before.entries.get(abs) : undefined; + const a = aHas ? after.entries.get(abs) : undefined; + // Unavailable on either side → skip (do not false-report). + if (b === UNAVAILABLE || a === UNAVAILABLE) continue; + const display = after.display.get(abs) ?? before.display.get(abs) ?? abs.replace(/\\/g, '/'); + // After the UNAVAILABLE guard, b/a are PathMeta | undefined. + if (!bHas && aHas && a) { + changes.push({ path: display, kind: 'added' }); + continue; + } + if (bHas && !aHas && b) { + changes.push({ path: display, kind: 'deleted' }); + continue; + } + if (bHas && aHas && b && a) { + if (b.hash !== a.hash || b.size !== a.size) { + changes.push({ path: display, kind: 'modified' }); + } + } + } + return changes; +} + +const KIND_ZH: Record = { + added: '新增', + modified: '修改', + deleted: '删除', +}; +const KIND_EN: Record = { + added: 'added', + modified: 'modified', + deleted: 'deleted', +}; + +function inspectHints(changes: readonly ProtectedPathChange[]): string[] { + const hints: string[] = []; + const joined = changes.map((c) => c.path).join(' '); + if (/\.ssh/.test(joined)) hints.push('ls -la ~/.ssh'); + if (/\.git\/hooks/.test(joined)) hints.push('ls -la .git/hooks'); + if (/\.git\/(config|HEAD|refs)/.test(joined)) hints.push('git status && ls -la .git'); + if (/\.env/.test(joined)) hints.push('ls -la .env*'); + if (hints.length === 0) hints.push('ls -la'); + return [...new Set(hints)]; +} + +/** + * Human + machine notice for tool results / stderr. + * Uses「已被修改」not「已拦截」. No file contents, no full hashes. + */ + +/** Git porcelain that legitimately updates HEAD / refs (not config/hooks). */ +const GIT_REF_UPDATING = + /(?:^|[\s;&|])git\s+(?:commit|checkout|switch|branch|merge|rebase|pull|fetch|reset|tag|stash|cherry-pick|revert|am|bisect)\b/i; + +function isGitHeadsOrRefsPath(p: string): boolean { + const n = p.replace(/\\/g, '/'); + return ( + /(^|\/)\.git\/HEAD$/i.test(n) || + /(^|\/)\.git\/refs\//i.test(n) || + n === 'HEAD' || + n.startsWith('refs/') + ); +} + +/** + * Drop HEAD/refs changes when the command is ordinary git porcelain, so + * `git commit` / `git checkout -b` are not false positives. config / hooks / + * .ssh / .env changes are never filtered. + */ +export function filterExpectedGitMetadataChanges( + command: string, + changes: readonly ProtectedPathChange[] +): ProtectedPathChange[] { + if (!GIT_REF_UPDATING.test(command)) return [...changes]; + return changes.filter((c) => !isGitHeadsOrRefsPath(c.path)); +} + +export function formatProtectedPathModifiedNotice( + changes: readonly ProtectedPathChange[], + zh: boolean, + command?: string +): string { + if (changes.length === 0) return ''; + const cmd = (command ?? '').replace(/\s+/g, ' ').trim().slice(0, 80); + const list = changes + .map((c) => `${c.path} (${zh ? KIND_ZH[c.kind] : KIND_EN[c.kind]})`) + .join(zh ? ';' : '; '); + const hints = inspectHints(changes); + const markerPaths = changes.map((c) => c.path).join(','); + const lines = zh + ? [ + `${PROTECTED_PATH_MOD_MARKER} paths=${markerPaths}`, + `[moss] 受保护路径已被修改(命令已执行,Moss 没有拦截也没有自动还原):${list}`, + cmd ? `命令:${cmd}` : '', + `请自行检查。可复制:`, + ...hints.map((h) => ` ${h}`), + ] + : [ + `${PROTECTED_PATH_MOD_MARKER} paths=${markerPaths}`, + `[moss] Protected paths were modified (command already ran; Moss did not intercept or restore): ${list}`, + cmd ? `command: ${cmd}` : '', + `Inspect yourself. Copyable:`, + ...hints.map((h) => ` ${h}`), + ]; + return lines.filter(Boolean).join('\n'); +} + +/** Parse marker line from a tool result (for run-completion tracker). */ +export function parseProtectedPathModMarker(text: string): ProtectedPathChange[] | undefined { + if (!text.includes(PROTECTED_PATH_MOD_MARKER)) return undefined; + const m = text.match(/\[moss-o101-protected-path-modified\]\s+paths=([^\n]+)/); + if (!m) return [{ path: '(unknown)', kind: 'modified' }]; + return m[1]! + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => ({ path: p, kind: 'modified' as const })); +} + +/** Outcome reason strings (O98). */ +export function protectedPathModReason( + changes: readonly ProtectedPathChange[], + command?: string +): { en: string; zh: string; stuckEn: string; stuckZh: string } { + const paths = changes + .map((c) => c.path) + .slice(0, 5) + .join(', '); + const more = changes.length > 5 ? ` (+${changes.length - 5})` : ''; + const cmd = (command ?? 'shell').replace(/\s+/g, ' ').trim().slice(0, 60); + return { + en: `${cmd} modified protected path(s) ${paths}${more}`, + zh: `${cmd} 修改了受保护路径 ${paths}${more}`, + stuckEn: `protected path modified: ${paths}${more}`, + stuckZh: `受保护路径已被修改:${paths}${more}`, + }; +} diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index 43b3d4e1..f3cea668 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -16,16 +16,19 @@ * nearest existing ancestor (symlink defense). * * Shell commands: this module is POLICY only. Write targets come from - * O97's shared `detectShellWriteTargets` (./shell-write-detect.ts). Rule: - * prompt when a known target is protected, or when the detector is uncertain - * AND a protected path is mentioned (`mentions` / command text). Best effort, - * not a sandbox. + * O97's shared `detectShellWriteTargets` (./shell-write-detect.ts) plus O101 + * layers 1–2 (`indirect-write-detect.ts`: inline `-c`/`-e` and workspace + * script scan). Rule: prompt when a known target is protected, or when the + * detector is uncertain AND a protected path is mentioned, or when a scanned + * script/inline block would write a protected path. Best effort, not a sandbox. + * Layer 3 (post-run snapshot) lives in protected-path-snapshot.ts. */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { detectShellWriteTargets, type ShellWriteDetection } from './shell-write-detect.js'; +import { findIndirectProtectedWrites } from './indirect-write-detect.js'; export type ProtectedPathCategory = 'git' | 'ssh' | 'shell-rc' | 'env' | 'moss-config'; @@ -34,6 +37,16 @@ export interface ProtectedPathHit { resolved: string; category: ProtectedPathCategory; operation: 'write' | 'delete' | 'move' | 'shell'; + /** + * O101: when the write comes from inline interpreter code or a workspace + * script (layers 1–2). Used by the confirm prompt to show script + line. + */ + source?: { + kind: 'inline' | 'script'; + script: string; + line?: number; + detail?: string; + }; } export interface ProtectedPathContext { @@ -378,6 +391,32 @@ export function findShellProtectedWrites( } } } + + // O101 layers 1–2: inline interpreter code + workspace script scan. + // Merge with existing hits; attach source metadata when the same path already hit. + for (const finding of findIndirectProtectedWrites( + command, + ctx.cwd, + ctx.workspaceRoot ?? ctx.cwd + )) { + const hit = + classifyProtectedPath(finding.raw, ctx) ?? deletesProtectedAncestor(finding.raw, ctx); + if (!hit) continue; + const key = hit.resolved; + if (seen.has(key)) { + const existing = hits.find((h) => h.resolved === key); + if (existing && !existing.source) existing.source = finding.source; + continue; + } + seen.add(key); + hits.push({ + raw: finding.raw, + resolved: hit.resolved, + category: hit.category, + operation: 'shell', + source: finding.source, + }); + } return hits; } diff --git a/src/tools/builtin.ts b/src/tools/builtin.ts index 892505f2..cb7f1cf3 100644 --- a/src/tools/builtin.ts +++ b/src/tools/builtin.ts @@ -41,6 +41,13 @@ import { redactedShellWriteRefusal, snapshotMutationFiles, } from '../safety/redacted-writeback.js'; +import { + diffProtectedPathSnapshots, + filterExpectedGitMetadataChanges, + formatProtectedPathModifiedNotice, + takeProtectedPathSnapshot, + takeProtectedPathSnapshotAfter, +} from '../safety/protected-path-snapshot.js'; // Re-export tools from extracted modules for backward compatibility. export { @@ -167,6 +174,29 @@ export const execTool: Tool = { ctx.workspaceDir, extractShellMutationPaths(commandText) ); + // O101 layer 3: metadata snapshot of protected paths (mtime/size/hash only). + const protectedSnapBefore = takeProtectedPathSnapshot(ctx.workspaceDir, { + home: process.env.HOME, + }); + const preferZhLocale = (): boolean => { + const loc = process.env.MOSS_LOCALE || process.env.LANG || ''; + return /^zh\b/i.test(loc); + }; + const o101NoticeAfter = (cmd: string): string => { + try { + const after = takeProtectedPathSnapshotAfter(protectedSnapBefore, { + home: process.env.HOME, + }); + const changes = filterExpectedGitMetadataChanges( + cmd, + diffProtectedPathSnapshots(protectedSnapBefore, after) + ); + if (changes.length === 0) return ''; + return '\n\n' + formatProtectedPathModifiedNotice(changes, preferZhLocale(), cmd); + } catch { + return ''; + } + }; const hideCredentialStream = /\.apikey-key\b/.test(commandText); const streamer = hideCredentialStream ? null : createRedactingChunkWriter(ctx.onToolOutput); const footnote = deviceEnvFootnote(String(input.command ?? '')); @@ -241,7 +271,13 @@ export const execTool: Tool = { } } streamer?.flush(); - return text + writebackWarning + footnote + hooksNotice(`${result.stdout}\n${result.stderr}`); + return ( + text + + writebackWarning + + o101NoticeAfter(commandText) + + footnote + + hooksNotice(`${result.stdout}\n${result.stderr}`) + ); } catch (err) { streamer?.flush(); const writebackWarning = formatRedactedWritebackWarning( @@ -254,6 +290,7 @@ export const execTool: Tool = { const waited = err.timeoutMs && err.timeoutMs > 0 ? err.timeoutMs : timeoutMs; return ( `timed out after ${waited}ms (raise timeout_ms or use exec_background)${output ? `\n${output}` : ''}${writebackWarning}` + + o101NoticeAfter(commandText) + footnote + hooksNotice(`${err.stdout}\n${err.stderr}`) ); diff --git a/test/o101-indirect-writes.spec.mjs b/test/o101-indirect-writes.spec.mjs new file mode 100644 index 00000000..e1fca8d1 --- /dev/null +++ b/test/o101-indirect-writes.spec.mjs @@ -0,0 +1,479 @@ +#!/usr/bin/env node +/** + * O101 — indirect writes to protected paths (inline / script scan / post-run). + * Spec: /workspace/specs/O101.md + * + * Red/green: every gated case must fail when the O101 modules are removed. + */ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'o101-')); +const home = path.join(root, 'home'); +const ws = path.join(root, 'ws'); +const cfg = path.join(home, '.config', 'moss'); +for (const d of [ + home, + ws, + cfg, + path.join(home, '.ssh'), + path.join(ws, 'src'), + path.join(ws, 'test'), + path.join(ws, '.moss'), + path.join(ws, '.git'), +]) + fs.mkdirSync(d, { recursive: true }); + +const PRIVATE_KEY = + '-----BEGIN OPENSSH PRIVATE KEY-----\nSECRETKEYMATERIAL\n-----END OPENSSH PRIVATE KEY-----\n'; +const ENV_SECRET = 'API_TOKEN=super-secret-value-do-not-leak\n'; + +fs.writeFileSync(path.join(home, '.ssh', 'authorized_keys'), 'ssh-ed25519 AAAA user\n'); +fs.writeFileSync(path.join(home, '.ssh', 'id_ed25519'), PRIVATE_KEY); +fs.writeFileSync(path.join(ws, '.env'), ENV_SECRET); +fs.writeFileSync(path.join(ws, 'src', 'ok.ts'), 'export const ok = 1;\n'); +fs.writeFileSync( + path.join(ws, 'test', 'ok.spec.mjs'), + 'import test from "node:test"; test("x", () => {});' +); + +const gitEnv = { + ...process.env, + HOME: home, + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@t', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@t', +}; +execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: ws, env: gitEnv }); +fs.mkdirSync(path.join(ws, '.git', 'hooks'), { recursive: true }); +fs.writeFileSync(path.join(ws, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n'); +execFileSync('git', ['add', 'src', 'test'], { cwd: ws, env: gitEnv }); +execFileSync('git', ['commit', '-qm', 'init'], { cwd: ws, env: gitEnv }); + +const distRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'dist'); +const load = async (rel) => { + const p = path.join(distRoot, rel); + return import(pathToFileURL(p).href); +}; + +let failures = 0; +function ok(label) { + console.log(`ok - ${label}`); +} +function fail(label, err) { + failures += 1; + console.log(`not ok - ${label}: ${err?.message ?? err}`); +} +async function check(label, fn) { + try { + await fn(); + ok(label); + } catch (err) { + fail(label, err); + } +} + +const { findProtectedPathHits } = await load('safety/protected-paths.js'); +const { findIndirectProtectedWrites, scanWorkspaceScript, shouldSkipDeepScan, listInvokedScripts } = + await load('safety/indirect-write-detect.js'); +const { + takeProtectedPathSnapshot, + takeProtectedPathSnapshotAfter, + diffProtectedPathSnapshots, + formatProtectedPathModifiedNotice, + parseProtectedPathModMarker, + PROTECTED_PATH_MOD_MARKER, + protectedPathModReason, +} = await load('safety/protected-path-snapshot.js'); +const { createCliToolApprovalHook, setCliApprovalAsker } = await load('cli/approval.js'); +const { setCliApprovalViewAsker } = await load('cli/approval-view.js'); +const { createRunCompletionTracker, noteRunCompletionEvent } = await load('cli/run-completion.js'); +const { resolveRunOutcome, formatOutcomeLine } = await load('cli/run-outcome.js'); +const { protectedPathHeadline } = await load('cli/protected-path-prompt.js'); + +const ctx = { + cwd: ws, + home, + workspaceRoot: ws, + configDirs: [cfg], + env: { HOME: home, PATH: process.env.PATH }, +}; + +function hits(command) { + return findProtectedPathHits('exec', { command }, ctx, false); +} + +const EXEC_TOOL = { + name: 'exec', + description: 'exec', + inputSchema: { type: 'object', properties: {} }, + metadata: { sideEffectClass: 'local_write' }, + execute: async () => 'ok', +}; + +function makeHook({ printMode = false } = {}) { + const env = { HOME: home, MOSS_CONFIG_DIR: cfg, PATH: process.env.PATH }; + const hook = createCliToolApprovalHook('full-access', env, { + workspaceDir: ws, + interactionMode: () => 'full', + approvalPolicy: 'never', + ...(printMode ? { printMode: true } : {}), + }); + return hook; +} + +function md5(p) { + return createHash('md5').update(fs.readFileSync(p)).digest('hex'); +} + +// ── A. Inline python / node / bash — -p denies, file unchanged ────────── + +await check('A1: python3 -c writing .git/config is a protected hit', () => { + const h = hits(`python3 -c "open('.git/config','a').write('x')"`); + assert.ok(h.length >= 1, `expected hit, got ${JSON.stringify(h)}`); + assert.ok(h.some((x) => x.category === 'git')); +}); + +await check('A2: node -e writing .env is a protected hit', () => { + const h = hits(`node -e "require('fs').writeFileSync('.env','PWNED')"`); + assert.ok(h.length >= 1); + assert.ok(h.some((x) => x.category === 'env' || x.raw.includes('.env'))); +}); + +await check('A3: bash -c appending ~/.ssh/authorized_keys is a protected hit', () => { + const h = hits(`bash -c 'echo k >> ~/.ssh/authorized_keys'`); + assert.ok(h.length >= 1); + assert.ok(h.some((x) => x.category === 'ssh')); +}); + +await check('A4: -p denies python3 -c and leaves .git/config unchanged', async () => { + const before = md5(path.join(ws, '.git', 'config')); + setCliApprovalAsker(null); + setCliApprovalViewAsker(null); + const hook = makeHook({ printMode: true }); + hook.setAsker(null); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const decision = await hook({ + tool: EXEC_TOOL, + input: { command: `python3 -c "open('.git/config','a').write('EVIL')"` }, + toolCallId: 'o101-a4', + sessionKey: 'o101', + }); + assert.equal(decision.approved, false, 'must deny under -p'); + assert.match(decision.reason ?? '', /已拦截|Blocked/); + assert.equal(md5(path.join(ws, '.git', 'config')), before); +}); + +await check('A5: -p denies node -e .env write; file unchanged', async () => { + const before = md5(path.join(ws, '.env')); + const hook = makeHook({ printMode: true }); + hook.setAsker(null); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const decision = await hook({ + tool: EXEC_TOOL, + input: { command: `node -e "require('fs').writeFileSync('.env','PWNED')"` }, + toolCallId: 'o101-a5', + sessionKey: 'o101', + }); + assert.equal(decision.approved, false); + assert.equal(md5(path.join(ws, '.env')), before); +}); + +await check('A6: -p denies bash -c ~/.ssh write; file unchanged', async () => { + const target = path.join(home, '.ssh', 'authorized_keys'); + const before = md5(target); + const hook = makeHook({ printMode: true }); + hook.setAsker(null); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const prev = process.env.HOME; + process.env.HOME = home; + try { + const decision = await hook({ + tool: EXEC_TOOL, + input: { command: `bash -c 'echo k >> ~/.ssh/authorized_keys'` }, + toolCallId: 'o101-a6', + sessionKey: 'o101', + }); + assert.equal(decision.approved, false); + } finally { + process.env.HOME = prev; + } + assert.equal(md5(target), before); +}); + +// ── Layer 2: workspace scripts ────────────────────────────────────────── + +await check('B-script: python script writing expanduser ~/.ssh is scanned', () => { + const script = path.join(ws, 'deploy.py'); + fs.writeFileSync( + script, + "import os\nopen(os.path.expanduser('~/.ssh/authorized_keys'),'a').write('x')\n" + ); + const findings = findIndirectProtectedWrites('python3 deploy.py', ws, ws); + assert.ok(findings.length >= 1, JSON.stringify(findings)); + assert.ok(findings[0].source.script.includes('deploy.py')); + assert.ok(findings[0].source.line >= 1); + const h = hits('python3 deploy.py'); + assert.ok(h.length >= 1, `hits=${JSON.stringify(h)}`); + assert.ok(h[0].source, 'source metadata required for UX'); +}); + +await check('B-script: node script writing .env is scanned', () => { + const script = path.join(ws, 's.js'); + fs.writeFileSync(script, "require('fs').writeFileSync('.env', 'x')\n"); + const h = hits('node s.js'); + assert.ok(h.length >= 1); + assert.equal(h[0].source?.kind, 'script'); +}); + +await check('B-script: bash script appending ~/.ssh is scanned', () => { + const script = path.join(ws, 'x.sh'); + fs.writeFileSync(script, '#!/bin/bash\necho k >> ~/.ssh/authorized_keys\n'); + const h = hits('bash x.sh'); + assert.ok(h.length >= 1); +}); + +await check('B-ux: headline names script and line', () => { + const script = path.join(ws, 'deploy2.py'); + fs.writeFileSync( + script, + "# header\nimport os\nopen(os.path.expanduser('~/.ssh/authorized_keys'),'a').write('x')\n" + ); + const h = hits('python3 deploy2.py'); + assert.ok(h.length >= 1); + const line = protectedPathHeadline(h[0], true); + assert.match(line, /deploy2\.py/); + assert.match(line, /第 \d+ 行|authorized_keys/); +}); + +// ── Layer 3: obfuscation bypass caught post-run ───────────────────────── + +await check('C1: obfuscated path write detected by snapshot (layer 3)', () => { + const before = takeProtectedPathSnapshot(ws, { home }); + // Bypass layers 1–2 with runtime join + const script = path.join(ws, 'obfuscate.py'); + fs.writeFileSync( + script, + [ + 'import os', + "h=os.path.expanduser('~')", + "p=os.path.join(h,'.'+'ssh','authorized_keys')", + "open(p,'a').write('BYPASS\\n')", + '', + ].join('\n') + ); + // Confirm layers 1–2 do NOT see a protected literal write API pair clearly + // (join obfuscation — may or may not hit; layer 3 is the guarantee) + const prev = process.env.HOME; + process.env.HOME = home; + try { + spawnSync('python3', [script], { + cwd: ws, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + } finally { + process.env.HOME = prev; + } + const after = takeProtectedPathSnapshotAfter(before, { home }); + const changes = diffProtectedPathSnapshots(before, after); + assert.ok( + changes.some((c) => c.path.includes('.ssh') || c.path.includes('authorized_keys')), + `expected ssh change, got ${JSON.stringify(changes)}` + ); + const notice = formatProtectedPathModifiedNotice(changes, true, 'python3 obfuscate.py'); + assert.match(notice, /已被修改/); + assert.doesNotMatch(notice, /已拦截/); + assert.ok(!notice.includes(PRIVATE_KEY.slice(0, 20))); + assert.ok(!notice.includes('BYPASS')); // content must not appear + // full hash must not appear (64 hex of sha256) + assert.doesNotMatch(notice, /[a-f0-9]{64}/); +}); + +await check('C2: new .git/hooks/pre-commit detected', () => { + const before = takeProtectedPathSnapshot(ws, { home }); + const hookPath = path.join(ws, '.git', 'hooks', 'pre-commit'); + fs.writeFileSync(hookPath, '#!/bin/sh\necho hi\n'); + const after = takeProtectedPathSnapshotAfter(before, { home }); + const changes = diffProtectedPathSnapshots(before, after); + assert.ok( + changes.some((c) => c.kind === 'added' && c.path.includes('hooks/pre-commit')), + JSON.stringify(changes) + ); +}); + +await check('C3: layer3 + model success claim → incomplete non-zero (O98)', () => { + const tracker = createRunCompletionTracker(); + const changes = [{ path: '.git/hooks/pre-commit', kind: 'added' }]; + const notice = formatProtectedPathModifiedNotice(changes, true, 'python3 x.py'); + noteRunCompletionEvent(tracker, { + type: 'tool_start', + toolCallId: '1', + toolName: 'exec', + input: { command: 'python3 x.py' }, + }); + noteRunCompletionEvent(tracker, { + type: 'tool_end', + toolCallId: '1', + toolName: 'exec', + result: `ok\n${notice}`, + isError: false, + }); + assert.ok(tracker.protectedPathChanges?.length >= 1); + const outcome = resolveRunOutcome({ + tracker, + finalText: 'All done, everything succeeded.', + request: 'install the hook', + }); + assert.equal(outcome.status, 'incomplete'); + assert.notEqual(outcome.exitCode, 0); + const line = formatOutcomeLine(outcome, 'zh'); + assert.match(line, /未完成/); + assert.match(line, /受保护路径|修改/); + assert.doesNotMatch(line, /已拦截/); +}); + +// ── D. No false positives ─────────────────────────────────────────────── + +await check('D1: npm test → 0 hits, skip deep scan', () => { + assert.equal(shouldSkipDeepScan('npm test'), true); + assert.equal(hits('npm test').length, 0); +}); + +await check('D2: pytest → 0 hits', () => { + assert.equal(shouldSkipDeepScan('pytest'), true); + assert.equal(hits('pytest -q').length, 0); +}); + +await check('D3: node --test → 0 hits', () => { + assert.equal(shouldSkipDeepScan('node --test'), true); + assert.equal(hits('node --test').length, 0); +}); + +await check('D4: git commit / checkout -b → 0 snapshot hits', () => { + const before = takeProtectedPathSnapshot(ws, { home }); + execFileSync('git', ['checkout', '-qb', 'feature-o101'], { cwd: ws, env: gitEnv }); + fs.writeFileSync(path.join(ws, 'src', 'ok.ts'), 'export const ok = 2;\n'); + execFileSync('git', ['add', 'src/ok.ts'], { cwd: ws, env: gitEnv }); + execFileSync('git', ['commit', '-qm', 'feat'], { cwd: ws, env: gitEnv }); + const after = takeProtectedPathSnapshotAfter(before, { home }); + const changes = diffProtectedPathSnapshots(before, after); + // refs/heads may change on checkout -b and commit — SPEC says watch refs. + // Normal git commit writes to refs are expected to show up! + // Spec D: "git commit、git checkout -b 全程 0 次确认、0 次检测命中" + // and "False positives: .git only watch config, hooks, HEAD, refs — not normal git commit writes." + // There's a tension: watching refs means commit updates refs/heads/X. + // Research says: exclude index, ORIG_HEAD, logs, objects — but refs ARE watched. + // Spec acceptance D says 0 detection hits for git commit/checkout -b. + // So we should NOT treat refs/heads updates from our own git as hits when + // the command is git itself — OR we should not watch refs content that git + // normally updates (heads, tags) and only watch unusual refs? + // + // Re-read research: "`.git` 要排除 git 自身合法写入(`index`、`ORIG_HEAD`、`logs/`、`objects/`)" + // It still lists refs as watched. But acceptance D requires 0 hits for git commit. + // Practical fix: for post-run, if the command is a plain `git` porcelain that + // only touches refs/HEAD in expected ways, filter those out. + // For this test, filter: only fail if config/hooks/.ssh/.env changed. + const bad = changes.filter( + (c) => !c.path.includes('refs/') && !c.path.endsWith('/HEAD') && !c.path.endsWith('.git/HEAD') + ); + assert.equal(bad.length, 0, `unexpected changes: ${JSON.stringify(changes)}`); +}); + +await check('D5: approval hook: git commit / npm test → approved without prompt', async () => { + let asked = 0; + const hook = makeHook(); + hook.setAsker(async () => { + asked += 1; + return 'n'; + }); + for (const command of ['git commit --allow-empty -qm x', 'git checkout -b z', 'npm test']) { + const d = await hook({ + tool: EXEC_TOOL, + input: { command }, + toolCallId: 'o101-d5', + sessionKey: 'o101', + }); + assert.equal(d.approved, true, command + ' → ' + JSON.stringify(d)); + } + assert.equal(asked, 0, 'must not prompt'); + setCliApprovalAsker(null); +}); + +// ── E. No secret leakage ──────────────────────────────────────────────── + +await check('E: private key / .env value never in notice or outcome', () => { + const changes = [ + { path: '~/.ssh/id_ed25519', kind: 'modified' }, + { path: '.env', kind: 'modified' }, + ]; + const notice = formatProtectedPathModifiedNotice(changes, false, 'node x.js'); + const reason = protectedPathModReason(changes, 'node x.js'); + const blob = notice + reason.en + reason.zh; + assert.ok(!blob.includes('SECRETKEYMATERIAL')); + assert.ok(!blob.includes('super-secret-value')); + assert.ok(!blob.includes(PRIVATE_KEY.trim())); + assert.ok(!blob.includes(ENV_SECRET.trim())); + assert.match(notice, /modified|已被修改/); +}); + +// ── QA extras ─────────────────────────────────────────────────────────── + +await check('QA: scan failure outside workspace does not crash', () => { + const r = scanWorkspaceScript('/etc/passwd', ws, ws); + assert.equal(r.status, 'outside-workspace'); +}); + +await check('QA: unavailable snapshot does not false-report modified', () => { + const before = takeProtectedPathSnapshot(ws, { home }); + const after = takeProtectedPathSnapshotAfter(before, { home }); + // Untouched .env must not appear as modified. + const changes = diffProtectedPathSnapshots(before, after).filter( + (c) => c.path === '.env' || c.path.endsWith('/.env') + ); + assert.equal(changes.length, 0); + // Malicious script changing .env still reports. + const b2 = takeProtectedPathSnapshot(ws, { home }); + fs.appendFileSync(path.join(ws, '.env'), 'x=1\n'); + const a2 = takeProtectedPathSnapshotAfter(b2, { home }); + const ch2 = diffProtectedPathSnapshots(b2, a2); + assert.ok(ch2.some((c) => c.path.includes('.env'))); +}); + +await check('QA: marker parse roundtrip', () => { + const changes = [{ path: '.git/hooks/pre-commit', kind: 'added' }]; + const notice = formatProtectedPathModifiedNotice(changes, true); + assert.ok(notice.includes(PROTECTED_PATH_MOD_MARKER)); + const parsed = parseProtectedPathModMarker(notice); + assert.ok(parsed?.some((p) => p.path.includes('pre-commit'))); +}); + +await check('overhead: snapshot pair < 50ms on fixture (target ≤20ms typical)', () => { + const t0 = Date.now(); + for (let i = 0; i < 20; i++) { + const b = takeProtectedPathSnapshot(ws, { home }); + const a = takeProtectedPathSnapshotAfter(b, { home }); + diffProtectedPathSnapshots(b, a); + } + const avg = (Date.now() - t0) / 20; + console.log(`# snapshot pair avg ${avg.toFixed(2)}ms`); + assert.ok(avg < 50, `avg ${avg}ms too slow`); +}); + +await check('listInvokedScripts finds python/node/bash files', () => { + assert.deepEqual(listInvokedScripts('python3 deploy.py'), ['deploy.py']); + assert.deepEqual(listInvokedScripts('node s.js'), ['s.js']); + assert.deepEqual(listInvokedScripts('bash x.sh'), ['x.sh']); + assert.deepEqual(listInvokedScripts('python3 -c "print(1)"'), []); +}); + +console.log(`# tests done, failures=${failures}`); +if (failures > 0) process.exit(1); +console.log('# pass'); From 9f07daf1fedca9a5f24bce2878a888684a53c9e9 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 04:13:39 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(O101):=20tip=20adapt=20=E2=80=94=20noti?= =?UTF-8?q?ce=20on=20all=20exec=20exits,=20stronger=20A/D=20asserts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Attach layer-3 notice on signal-kill and non-zero exit paths too (command already ran; detect after any exit). - A1–A3 assert O101 inline source metadata (revert-red for O101 path; -p deny itself still covered by O97 mentions). - D4 asserts filterExpectedGitMetadataChanges empties git porcelain. --- src/tools/builtin.ts | 9 +++++- test/o101-indirect-writes.spec.mjs | 50 +++++++++++++++++------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/tools/builtin.ts b/src/tools/builtin.ts index cb7f1cf3..a072d2bc 100644 --- a/src/tools/builtin.ts +++ b/src/tools/builtin.ts @@ -299,6 +299,7 @@ export const execTool: Tool = { ctx.markResultError?.(); return ( `Command killed by ${err.signal}:\n${output || err.message}${writebackWarning}` + + o101NoticeAfter(commandText) + footnote + hooksNotice(`${err.stdout}\n${err.stderr}`) ); @@ -307,7 +308,13 @@ export const execTool: Tool = { err.exitCode === null ? `Command failed:\n${output || err.message}` : `Command failed (exit ${err.exitCode}):\n${output || err.message}`; - return failure + writebackWarning + footnote + hooksNotice(`${err.stdout}\n${err.stderr}`); + return ( + failure + + writebackWarning + + o101NoticeAfter(commandText) + + footnote + + hooksNotice(`${err.stdout}\n${err.stderr}`) + ); } throw err; } diff --git a/test/o101-indirect-writes.spec.mjs b/test/o101-indirect-writes.spec.mjs index e1fca8d1..9d4a6959 100644 --- a/test/o101-indirect-writes.spec.mjs +++ b/test/o101-indirect-writes.spec.mjs @@ -87,6 +87,7 @@ const { takeProtectedPathSnapshot, takeProtectedPathSnapshotAfter, diffProtectedPathSnapshots, + filterExpectedGitMetadataChanges, formatProtectedPathModifiedNotice, parseProtectedPathModMarker, PROTECTED_PATH_MOD_MARKER, @@ -139,18 +140,31 @@ await check('A1: python3 -c writing .git/config is a protected hit', () => { const h = hits(`python3 -c "open('.git/config','a').write('x')"`); assert.ok(h.length >= 1, `expected hit, got ${JSON.stringify(h)}`); assert.ok(h.some((x) => x.category === 'git')); + // O101-specific: inline source metadata (O97 alone has no source). + assert.ok( + h.some((x) => x.source?.kind === 'inline' && /python/.test(x.source.script ?? '')), + `expected inline source, got ${JSON.stringify(h)}` + ); }); await check('A2: node -e writing .env is a protected hit', () => { const h = hits(`node -e "require('fs').writeFileSync('.env','PWNED')"`); assert.ok(h.length >= 1); assert.ok(h.some((x) => x.category === 'env' || x.raw.includes('.env'))); + assert.ok( + h.some((x) => x.source?.kind === 'inline' && /node/.test(x.source.script ?? '')), + `expected inline source, got ${JSON.stringify(h)}` + ); }); await check('A3: bash -c appending ~/.ssh/authorized_keys is a protected hit', () => { const h = hits(`bash -c 'echo k >> ~/.ssh/authorized_keys'`); assert.ok(h.length >= 1); assert.ok(h.some((x) => x.category === 'ssh')); + assert.ok( + h.some((x) => x.source?.kind === 'inline' && /bash/.test(x.source.script ?? '')), + `expected inline source, got ${JSON.stringify(h)}` + ); }); await check('A4: -p denies python3 -c and leaves .git/config unchanged', async () => { @@ -357,34 +371,28 @@ await check('D3: node --test → 0 hits', () => { assert.equal(hits('node --test').length, 0); }); -await check('D4: git commit / checkout -b → 0 snapshot hits', () => { +await check('D4: git commit / checkout -b → 0 detection hits after git filter', () => { const before = takeProtectedPathSnapshot(ws, { home }); execFileSync('git', ['checkout', '-qb', 'feature-o101'], { cwd: ws, env: gitEnv }); fs.writeFileSync(path.join(ws, 'src', 'ok.ts'), 'export const ok = 2;\n'); execFileSync('git', ['add', 'src/ok.ts'], { cwd: ws, env: gitEnv }); execFileSync('git', ['commit', '-qm', 'feat'], { cwd: ws, env: gitEnv }); const after = takeProtectedPathSnapshotAfter(before, { home }); - const changes = diffProtectedPathSnapshots(before, after); - // refs/heads may change on checkout -b and commit — SPEC says watch refs. - // Normal git commit writes to refs are expected to show up! - // Spec D: "git commit、git checkout -b 全程 0 次确认、0 次检测命中" - // and "False positives: .git only watch config, hooks, HEAD, refs — not normal git commit writes." - // There's a tension: watching refs means commit updates refs/heads/X. - // Research says: exclude index, ORIG_HEAD, logs, objects — but refs ARE watched. - // Spec acceptance D says 0 detection hits for git commit/checkout -b. - // So we should NOT treat refs/heads updates from our own git as hits when - // the command is git itself — OR we should not watch refs content that git - // normally updates (heads, tags) and only watch unusual refs? - // - // Re-read research: "`.git` 要排除 git 自身合法写入(`index`、`ORIG_HEAD`、`logs/`、`objects/`)" - // It still lists refs as watched. But acceptance D requires 0 hits for git commit. - // Practical fix: for post-run, if the command is a plain `git` porcelain that - // only touches refs/HEAD in expected ways, filter those out. - // For this test, filter: only fail if config/hooks/.ssh/.env changed. - const bad = changes.filter( - (c) => !c.path.includes('refs/') && !c.path.endsWith('/HEAD') && !c.path.endsWith('.git/HEAD') + const raw = diffProtectedPathSnapshots(before, after); + // Acceptance D: git porcelain must not produce detection hits. Layer 3 watches + // HEAD/refs but filterExpectedGitMetadataChanges drops those for ordinary git. + const afterCheckout = filterExpectedGitMetadataChanges('git checkout -b feature-o101', raw); + const afterCommit = filterExpectedGitMetadataChanges('git commit -qm feat', raw); + assert.equal( + afterCheckout.length, + 0, + `checkout filter should empty: ${JSON.stringify(afterCheckout)} (raw=${JSON.stringify(raw)})` + ); + assert.equal( + afterCommit.length, + 0, + `commit filter should empty: ${JSON.stringify(afterCommit)} (raw=${JSON.stringify(raw)})` ); - assert.equal(bad.length, 0, `unexpected changes: ${JSON.stringify(changes)}`); }); await check('D5: approval hook: git commit / npm test → approved without prompt', async () => {