From c499b058d7f47a2c83a81bd9de88231a5af2459c Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 12:40:19 +0800 Subject: [PATCH 1/5] fix(safety): O130 gate Windows/PowerShell writes into Moss config dirs ($env:/%VAR%/Set-Content/process.env) --- src/safety/protected-paths.ts | 54 ++++++++++++++++++++++++++ test/o130-win-config-protect.spec.mjs | 56 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 test/o130-win-config-protect.spec.mjs diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index b0b267f4..3b6cb8ae 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -608,9 +608,63 @@ export function findShellProtectedWrites( source: finding.source, }); } + + // O130: Windows/PowerShell forms the POSIX detector does not model + // (`Set-Content -Path …`, `$env:APPDATA\moss\…`, `%APPDATA%`, node -e with + // `process.env.APPDATA+'/moss/…'`). Stricter only: when the command can write + // and, after expanding those env forms, names a path inside a Moss config dir, + // gate it like any other protected write. + if (!hits.some((h) => h.category === 'moss-config') && commandMayWrite(command, uncertain)) { + for (const token of configDirTokens(command, ctx)) { + const hit = classifyProtectedPath(token, ctx); + if (!hit || hit.category !== 'moss-config' || seen.has(hit.resolved)) continue; + seen.add(hit.resolved); + hits.push({ + raw: displayPath(hit.resolved, ctx), + resolved: hit.resolved, + category: hit.category, + operation: 'shell', + }); + } + } return hits; } +const WRITE_CAPABLE = + /(?:^|[\s;|&(])(?:set-content|add-content|out-file|new-item|copy-item|move-item|remove-item|rename-item|tee-object|clear-content|sc|ac|ni|cpi|mi|ri|rni|copy|move|del|erase|ren|cp|mv|rm|tee|install|ln|touch|truncate|dd|sed|perl|node|nodejs|bun|deno|python[23]?|py|ruby|php|pwsh|powershell|cmd)(?:\.exe)?(?=$|[\s;|&)])|>/i; + +/** O130: could this command write a file at all (redirect, write cmdlet, interpreter)? */ +function commandMayWrite(command: string, uncertain: boolean): boolean { + return uncertain || WRITE_CAPABLE.test(command); +} + +/** O130: tokens of the command with Windows / interpreter env references expanded. */ +function configDirTokens(command: string, ctx: ProtectedPathContext): string[] { + const env = ctx.env ?? process.env; + const lookup = (name: string): string => { + const direct = env[name]; + if (direct !== undefined) return direct; + const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase()); + return key ? (env[key] ?? '') : ''; + }; + const expanded = command + .replace(/\$\{env:(\w+)\}|\$env:(\w+)/gi, (_, a: string, b: string) => lookup(a ?? b)) + .replace(/%(\w+)%/g, (_, k: string) => lookup(k)) + .replace( + /process\.env\.(\w+)|process\.env\[\s*['"](\w+)['"]\s*\]|os\.environ\[\s*['"](\w+)['"]\s*\]|os\.getenv\(\s*['"](\w+)['"]\s*\)|os\.environ\.get\(\s*['"](\w+)['"]\s*\)/g, + (_m, a?: string, b?: string, c?: string, d?: string, e?: string) => + lookup((a ?? b ?? c ?? d ?? e)!) + ) + // join string concatenation: X+'/moss/config.json' / X + "\\moss" / path.join(X,'moss') + .replace(/['"]?\s*\+\s*['"]/g, '') + .replace(/(path\.join|os\.path\.join|Join-Path)\s*\(?/gi, ' '); + const out: string[] = []; + for (const token of expanded.split(/[\s'"`=;|&()<>,]+/)) { + if (token && /[/\\]/.test(token)) out.push(token); + } + return out; +} + /** Paths a file-mutation tool call would touch. */ export function fileToolProtectedTargets( toolName: string, diff --git a/test/o130-win-config-protect.spec.mjs b/test/o130-win-config-protect.spec.mjs new file mode 100644 index 00000000..649533b3 --- /dev/null +++ b/test/o130-win-config-protect.spec.mjs @@ -0,0 +1,56 @@ +// O130: Windows/PowerShell forms that write Moss's own config dir must be gated +// (Windows CI probe on ci/win-o94-probe showed them WRITTEN with exit 0). +// Platform-independent: `$env:` / `%VAR%` / process.env forms are expanded from ctx.env. +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 { findShellProtectedWrites } from '../dist/safety/protected-paths.js'; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o130-')); +const profile = path.join(root, 'profile'); +const appdata = path.join(profile, 'AppData', 'Roaming'); +const cfg = path.join(appdata, 'moss'); +const custom = path.join(root, 'custom-moss'); +const ws = path.join(root, 'ws'); +for (const d of [cfg, custom, ws]) fs.mkdirSync(d, { recursive: true }); +test.after(() => fs.rmSync(root, { recursive: true, force: true })); + +const env = { USERPROFILE: profile, APPDATA: appdata, MOSS_CONFIG_DIR: custom }; +const ctx = { cwd: ws, workspaceRoot: ws, configDirs: [cfg, custom], env, home: profile }; +const cats = (cmd) => findShellProtectedWrites(cmd, ctx).map((h) => h.category); + +const BLOCK = [ + `Set-Content -Path '${path.join(cfg, 'config.json')}' -Value PWN`, + `Set-Content -Path '${path.join(cfg, 'credentials')}' -Value PWN`, + `Add-Content ${path.join(custom, 'config.json')} PWN`, + `Out-File -FilePath ${path.join(custom, 'credentials')}`, + 'echo PWN > "$env:APPDATA/moss/config.json"', + 'echo PWN > "$env:APPDATA/moss/credentials"', + 'echo PWN > "${env:APPDATA}/moss/config.json"', + 'echo PWN > "$env:MOSS_CONFIG_DIR/config.json"', + 'echo PWN > "$env:appdata/moss/config.json"', + 'echo PWN > %APPDATA%/moss/config.json', + `node -e "require('fs').writeFileSync(process.env.APPDATA+'/moss/config.json','PWN')"`, + `node -e "require('fs').writeFileSync(process.env['MOSS_CONFIG_DIR']+'/credentials','PWN')"`, + `python -c "open(os.environ['APPDATA']+'/moss/config.json','w').write('x')"`, +]; +for (const cmd of BLOCK) { + test(`O130 gates: ${cmd}`, () => { + assert.ok(cats(cmd).includes('moss-config'), `${cmd} -> ${JSON.stringify(cats(cmd))}`); + }); +} + +const ALLOW = [ + 'Get-Content "$env:APPDATA/moss/config.json"', + 'echo hi > out.txt', + `Set-Content -Path '${path.join(ws, 'notes.txt')}' -Value x`, + 'echo PWN > "$env:APPDATA/other-app/config.json"', +]; +for (const cmd of ALLOW) { + test(`O130 does not gate: ${cmd}`, () => { + assert.ok(!cats(cmd).includes('moss-config'), `${cmd} -> ${JSON.stringify(cats(cmd))}`); + }); +} From 362ad8b2b34de09382944a12595849791576eae3 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 13:15:58 +0800 Subject: [PATCH 2/5] fix(safety): O130 gate PowerShell/env-form write targets into Moss config dir; block unresolvable write targets - new src/safety/pwsh-write-targets.ts: write targets of Set-Content/Add-Content/ Out-File/New-Item/Copy-Item/Move-Item/Tee-Object/Clear-Content (+ sc/ac/ni/cpi/mi/ copy/move/tee/cp/mv), [IO.File]::Write*/Append*, and >, >>, 2>, *>, &> - $env:X / ${env:X} / %X% / $X expanded from the current env (case-insensitive); unknown vars, vars assigned earlier on the same line, command substitution, relative targets after an unresolvable cd -> blocked as unresolved - only write targets are examined: $ in args / reads never prompts - inline interpreter env-concat detection scoped to node -e / python -c etc. - UX copy for unresolved variable targets (reason + next step, zh/en) - o94: set USERPROFILE alongside HOME (Windows os.homedir), no assertion change --- src/cli/protected-path-prompt.ts | 11 + src/safety/protected-paths.ts | 54 +++- src/safety/pwsh-write-targets.ts | 431 ++++++++++++++++++++++++++ test/o130-dollar-control.spec.mjs | 44 +++ test/o130-process-block.spec.mjs | 304 ++++++++++++++++++ test/o130-pwsh-write-targets.spec.mjs | 237 ++++++++++++++ test/o94-protected-paths.spec.mjs | 8 +- 7 files changed, 1074 insertions(+), 15 deletions(-) create mode 100644 src/safety/pwsh-write-targets.ts create mode 100644 test/o130-dollar-control.spec.mjs create mode 100644 test/o130-process-block.spec.mjs create mode 100644 test/o130-pwsh-write-targets.spec.mjs diff --git a/src/cli/protected-path-prompt.ts b/src/cli/protected-path-prompt.ts index cead2b8b..49734477 100644 --- a/src/cli/protected-path-prompt.ts +++ b/src/cli/protected-path-prompt.ts @@ -56,6 +56,12 @@ function whyClause(hit: ProtectedPathHit, zh: boolean): string { ? `${shown(hit)} 会改 git 钩子目录(core.hooksPath)` : `${shown(hit)} would redirect git hooks (core.hooksPath)`; } + if (hit.kind === 'unresolved-var') { + // O130 (UX): say why it was treated as protected and what to do instead. + return zh + ? `无法确定 \`${shown(hit)}\` 指向的路径(写入目标含变量或命令替换),按受保护路径处理;请改用字面路径,或用 edit 工具改文件` + : `cannot tell where \`${shown(hit)}\` points (write target built from a variable or command substitution), so it is treated as protected; use a literal path or the edit tool instead`; + } const label = PROTECTED_CATEGORY_LABEL[hit.category]; if (hit.category === 'unresolved') return `${shown(hit)}${zh ? ':' : ': '}${zh ? label.zh : label.en}`; @@ -73,6 +79,11 @@ export function protectedPathHeadline(hit: ProtectedPathHit, zh: boolean): strin ? `要执行 \`${shown(hit)}\`:会改 git 钩子目录(core.hooksPath)` : `About to run \`${shown(hit)}\`: would redirect git hooks (core.hooksPath)`; } + if (hit.kind === 'unresolved-var') { + return zh + ? `要${OP_ZH[hit.operation]}:${whyClause(hit, true)}` + : `About to ${OP_EN[hit.operation]}: ${whyClause(hit, false)}`; + } if (hit.category === 'unresolved') { return zh ? `要${OP_ZH[hit.operation]} \`${shown(hit)}\`${src}:${label.zh}` diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index 3b6cb8ae..5b11589a 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -29,6 +29,7 @@ import path from 'node:path'; import { detectShellWriteTargets, type ShellWriteDetection } from './shell-write-detect.js'; import { findIndirectProtectedWrites } from './indirect-write-detect.js'; +import { findPwshWriteTargets } from './pwsh-write-targets.js'; export type ProtectedPathCategory = | 'git' @@ -45,7 +46,7 @@ export interface ProtectedPathHit { category: ProtectedPathCategory; operation: 'write' | 'delete' | 'move' | 'shell'; /** O103: `core.hooksPath` redirection (copy says "would redirect git hooks"). */ - kind?: 'hooks-path'; + kind?: 'hooks-path' | 'unresolved-var'; /** * 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. @@ -609,12 +610,42 @@ export function findShellProtectedWrites( }); } - // O130: Windows/PowerShell forms the POSIX detector does not model - // (`Set-Content -Path …`, `$env:APPDATA\moss\…`, `%APPDATA%`, node -e with - // `process.env.APPDATA+'/moss/…'`). Stricter only: when the command can write - // and, after expanding those env forms, names a path inside a Moss config dir, - // gate it like any other protected write. - if (!hits.some((h) => h.category === 'moss-config') && commandMayWrite(command, uncertain)) { + // O130 (a): write targets of PowerShell cmdlets / aliases, [IO.File]::Write*, + // every redirection form, with `$env:X` / `${env:X}` / `%X%` / `$X` expanded + // from the current env. Only write targets are examined (a `$` in an argument + // or a read never prompts). A target that cannot be resolved (unknown or + // same-line-assigned variable, command substitution, relative after an + // unresolvable `cd`) is gated as possibly protected. Stricter only. + const pw = findPwshWriteTargets(command, { + cwd: ctx.cwd, + home: homeOf(ctx), + env: ctx.env ?? process.env, + }); + for (const target of pw.resolved) { + const hit = classifyProtectedPath(target, ctx) ?? deletesProtectedAncestor(target, ctx); + if (!hit || seen.has(hit.resolved)) continue; + seen.add(hit.resolved); + hits.push({ + raw: displayPath(hit.resolved, ctx), + resolved: hit.resolved, + category: hit.category, + operation: 'shell', + }); + } + if (hits.length === 0 && pw.unresolved.length > 0) { + const raw = pw.unresolved[0]!; + hits.push({ + raw, + resolved: raw, + category: 'unresolved', + operation: 'write', + kind: 'unresolved-var', + }); + } + + // O130 (b): inline interpreter code (`node -e`, `python -c` …) naming a Moss + // config file through process.env.X / os.environ[...] (+ string concat). + if (!hits.some((h) => h.category === 'moss-config') && INLINE_INTERP.test(command)) { for (const token of configDirTokens(command, ctx)) { const hit = classifyProtectedPath(token, ctx); if (!hit || hit.category !== 'moss-config' || seen.has(hit.resolved)) continue; @@ -630,13 +661,8 @@ export function findShellProtectedWrites( return hits; } -const WRITE_CAPABLE = - /(?:^|[\s;|&(])(?:set-content|add-content|out-file|new-item|copy-item|move-item|remove-item|rename-item|tee-object|clear-content|sc|ac|ni|cpi|mi|ri|rni|copy|move|del|erase|ren|cp|mv|rm|tee|install|ln|touch|truncate|dd|sed|perl|node|nodejs|bun|deno|python[23]?|py|ruby|php|pwsh|powershell|cmd)(?:\.exe)?(?=$|[\s;|&)])|>/i; - -/** O130: could this command write a file at all (redirect, write cmdlet, interpreter)? */ -function commandMayWrite(command: string, uncertain: boolean): boolean { - return uncertain || WRITE_CAPABLE.test(command); -} +const INLINE_INTERP = + /(?:^|[\s;|&(])(?:node|nodejs|bun|deno|python[23]?(?:\.\d+)?|py|ruby|perl|php)(?:\.exe)?\s+(?:[^;|&]*\s)?(?:-e|-c|-p|-r|-E|--eval|--print|eval)(?=\s)/i; /** O130: tokens of the command with Windows / interpreter env references expanded. */ function configDirTokens(command: string, ctx: ProtectedPathContext): string[] { diff --git a/src/safety/pwsh-write-targets.ts b/src/safety/pwsh-write-targets.ts new file mode 100644 index 00000000..0a98d5b7 --- /dev/null +++ b/src/safety/pwsh-write-targets.ts @@ -0,0 +1,431 @@ +/** + * O130: write targets the POSIX detector (shell-write-detect.ts) does not + * model: PowerShell write cmdlets and aliases, `[IO.File]::Write*`, pwsh / + * cmd env forms (`$env:X`, `${env:X}`, `%X%`) and every redirection form + * (`>`, `>>`, `2>`, `*>`, `&>`). Stricter only. + * + * Scope (PM 10-12 13:07): only WRITE TARGETS are looked at. A `$` in an + * argument or a read is never reported. + * - A target whose variables all resolve from the current process env is + * expanded and compared against protected paths. + * - A target with an unknown variable, a variable assigned earlier on the same + * command line (QA: never trust the current env for it), or a command + * substitution is UNRESOLVABLE and reported so the caller blocks it. + * - After `cd ` a relative write target is unresolvable too. + */ +import * as path from 'node:path'; + +export interface PwshWriteTargets { + /** Absolute (or ~-relative) write targets after env expansion. */ + resolved: string[]; + /** Write targets as written, which cannot be resolved statically. */ + unresolved: string[]; +} + +interface Ctx { + cwd: string; + home: string; + env: NodeJS.ProcessEnv; +} + +/** Split into simple commands on ; && || | & newlines, quote-aware. */ +function segments(command: string): string[] { + const out: string[] = []; + let cur = ''; + let q: string | null = null; + for (let i = 0; i < command.length; i++) { + const c = command[i]!; + if (q) { + cur += c; + if (c === q) q = null; + continue; + } + if (c === '"' || c === "'") { + q = c; + cur += c; + continue; + } + if (c === ';' || c === '\n' || c === '|') { + out.push(cur); + cur = ''; + continue; + } + if (c === '&') { + // keep `2>&1` / `&>` / `>&` inside the segment + const prev = cur.trimEnd().slice(-1); + if (prev === '>' || command[i + 1] === '>') { + cur += c; + continue; + } + out.push(cur); + cur = ''; + continue; + } + cur += c; + } + out.push(cur); + return out.map((s) => s.trim()).filter(Boolean); +} + +interface Word { + text: string; // quotes removed + quoted: 'single' | 'double' | null; +} + +/** Words of one simple command; redirect operators become their own words. No backslash escapes (pwsh / cmd paths). */ +function words(seg: string): Word[] { + const out: Word[] = []; + let cur = ''; + let has = false; + let quoted: Word['quoted'] = null; + let q: string | null = null; + const flush = () => { + if (has) out.push({ text: cur, quoted }); + cur = ''; + has = false; + quoted = null; + }; + for (let i = 0; i < seg.length; i++) { + const c = seg[i]!; + if (q) { + if (c === q) q = null; + else cur += c; + continue; + } + if (c === '"' || c === "'") { + q = c; + has = true; + quoted = quoted ?? (c === "'" ? 'single' : 'double'); + continue; + } + if (/\s/.test(c)) { + flush(); + continue; + } + if (c === '>') { + // operator: optional fd / * / & prefix already in cur when it is just that + let op = ''; + if (/^(?:\d|\*|&)$/.test(cur) && quoted === null) { + op = cur; + cur = ''; + has = false; + } else flush(); + op += '>'; + if (seg[i + 1] === '>') { + op += '>'; + i++; + } + if (seg[i + 1] === '|') i++; + out.push({ text: op, quoted: null }); + if (seg[i + 1] === '&') { + // >&2 / 2>&1: fd duplication, not a file + let j = i + 2; + while (j < seg.length && /\d|-/.test(seg[j]!)) j++; + out.push({ text: '&fd', quoted: null }); + i = j - 1; + } + continue; + } + cur += c; + has = true; + } + flush(); + return out; +} + +const ASSIGN_RES = [ + /\$env:([A-Za-z_]\w*)\s*\+?=/gi, + /\$\{env:([A-Za-z_]\w*)\}\s*\+?=/gi, + /(?:^|[\s;&|(])\$([A-Za-z_]\w*)\s*\+?=/g, // pwsh $p = ... + /(?:^|[\s;&|(])(?:export\s+|set\s+|setx\s+|local\s+|declare\s+(?:-\w+\s+)*|readonly\s+)?([A-Za-z_]\w*)=/gi, + /\b(?:Set-Item|si|New-Item|ni)\s+(?:-Path\s+)?['"]?env:([A-Za-z_]\w*)/gi, + /\b(?:Set-Variable|sv|New-Variable|nv)\s+(?:-Name\s+)?['"]?([A-Za-z_]\w*)/gi, + /\bsetx\s+([A-Za-z_]\w*)\s/gi, + /\b(?:read|for|foreach)\s+(?:-\w+\s+)*\$?([A-Za-z_]\w*)/gi, + /\[(?:System\.)?Environment\]::SetEnvironmentVariable\(\s*['"]([A-Za-z_]\w*)/gi, +]; + +/** Variable names assigned anywhere on this command line (lower-cased). */ +function assignedVars(command: string): Set { + const out = new Set(); + for (const re of ASSIGN_RES) for (const m of command.matchAll(re)) out.add(m[1]!.toLowerCase()); + return out; +} + +const NULL_TARGETS = /^(?:\$null|nul|\/dev\/(?:null|stdout|stderr|tty)|&fd|-)$/i; + +/** + * Expand a write-target word. Returns null when it cannot be resolved + * statically (unknown / reassigned variable, command substitution). + */ +function expand(word: Word, ctx: Ctx, assigned: Set): string | null { + let w = word.text; + if (word.quoted === 'single') return w; // literal in pwsh and POSIX + if (/\$\(|`|@\(|\$\[|<\(|\$\{[^}]*[^\w:}]/.test(w)) return null; + const lookup = (name: string): string | null => { + const lower = name.toLowerCase(); + if (assigned.has(lower)) return null; + if (lower === 'home') return ctx.home; + if (lower === 'pwd') return ctx.cwd; + const key = Object.keys(ctx.env).find((k) => k.toLowerCase() === lower); + const v = key === undefined ? undefined : ctx.env[key]; + return v === undefined || v === '' ? null : v; + }; + let bad = false; + const sub = (_m: string, name: string) => { + const v = lookup(name); + if (v === null) bad = true; + return v ?? ''; + }; + w = w + .replace(/\$\{env:([A-Za-z_]\w*)\}/gi, sub) + .replace(/\$env:([A-Za-z_]\w*)/gi, sub) + .replace(/%([A-Za-z_]\w*)%/g, sub) + .replace(/\$\{([A-Za-z_]\w*)\}/g, sub) + .replace(/\$([A-Za-z_]\w*)/g, sub); + if (bad || /\$/.test(w)) return null; + return w; +} + +const VALUE_FLAGS = new Set([ + '-value', + '-encoding', + '-itemtype', + '-type', + '-inputobject', + '-width', + '-filter', + '-include', + '-exclude', + '-credential', + '-stream', + '-delimiter', + '-variable', + '-t', // cp/mv -t handled separately + '-m', + '--mode', + '-s', + '-o', + '-g', +]); +const PATH_FLAGS = new Set(['-path', '-literalpath', '-filepath', '-filename', '-pspath', '-lp']); +const DEST_FLAGS = new Set(['-destination', '-target', '--target-directory', '-t']); + +type Kind = 'first' | 'all' | 'dest' | 'newitem'; +const WRITERS: Record = { + 'set-content': 'first', + sc: 'first', + 'add-content': 'first', + ac: 'first', + 'clear-content': 'first', + clc: 'first', + 'out-file': 'first', + 'tee-object': 'first', + tee: 'all', + 'new-item': 'newitem', + ni: 'newitem', + mkdir: 'all', + md: 'all', + 'copy-item': 'dest', + cpi: 'dest', + copy: 'dest', + cp: 'dest', + 'move-item': 'dest', + mi: 'dest', + move: 'dest', + mv: 'dest', + 'rename-item': 'dest', + rni: 'dest', + ren: 'dest', + install: 'dest', + 'remove-item': 'all', + ri: 'all', + del: 'all', + erase: 'all', + rm: 'all', + rd: 'all', + rmdir: 'all', + touch: 'all', + truncate: 'all', +}; + +/** Raw write-target words of one simple command (after the command word). */ +function targetWords(ws: Word[]): Word[] { + const out: Word[] = []; + const plain: Word[] = []; + for (let k = 0; k < ws.length; k++) { + const t = ws[k]!; + if (t.quoted === null && /^(?:\d|\*|&)?>>?$/.test(t.text)) { + const nx = ws[k + 1]; + if (nx) out.push(nx); + k++; + continue; + } + plain.push(t); + } + // skip leading VAR=val prefixes and wrappers + let i = 0; + while (i < plain.length && plain[i]!.quoted === null && /^[A-Za-z_]\w*=/.test(plain[i]!.text)) + i++; + while ( + i < plain.length && + /^(?:sudo|env|command|nohup|time|exec|nice|&|\.)$/i.test(plain[i]!.text) + ) + i++; + const head = plain[i]; + if (!head) return out; + const cmd = head.text + .split(/[\\/]/) + .pop()! + .toLowerCase() + .replace(/\.exe$/, ''); + const args = plain.slice(i + 1); + + // [IO.File]::WriteAllText(path, …) / [System.IO.File]::AppendAllLines(…) + const joined = plain + .map((p) => (p.quoted === 'single' ? `'${p.text}'` : p.quoted ? `"${p.text}"` : p.text)) + .join(' '); + for (const m of joined.matchAll( + /\[(?:System\.)?IO\.(?:File|StreamWriter)\]::(?:Write\w*|Append\w*|Create\w*|Open\w*|Copy|Move|Replace|Delete|new)\s*\(\s*((?:'[^']*'|"[^"]*"|[^,()'"]|\([^()]*\))+)/gi + )) { + let a = m[1]!.trim(); + let quoted: Word['quoted'] = null; + if (/^'.*'$/.test(a)) quoted = 'single'; + else if (/^".*"$/.test(a)) quoted = 'double'; + if (quoted) a = a.slice(1, -1); + out.push({ text: a, quoted }); + } + + const kind = WRITERS[cmd]; + if (!kind) return out; + const positional: Word[] = []; + let pathFlag: Word | undefined; + let destFlag: Word | undefined; + let nameFlag: Word | undefined; + for (let k = 0; k < args.length; k++) { + const a = args[k]!; + if ((a.quoted === null && /^--?[A-Za-z]/.test(a.text)) || /^-[A-Za-z]+:./.test(a.text)) { + const [flag, inline] = + a.text.includes(':') && a.text.startsWith('-') && !a.text.startsWith('--') + ? [ + a.text.slice(0, a.text.indexOf(':')).toLowerCase(), + a.text.slice(a.text.indexOf(':') + 1), + ] + : a.text.includes('=') && a.text.startsWith('--') + ? [ + a.text.slice(0, a.text.indexOf('=')).toLowerCase(), + a.text.slice(a.text.indexOf('=') + 1), + ] + : [a.text.toLowerCase(), undefined]; + const take = (): Word | undefined => + inline !== undefined && inline !== '' ? { text: inline, quoted: null } : args[++k]; + if (PATH_FLAGS.has(flag)) pathFlag = take(); + else if (DEST_FLAGS.has(flag) && kind === 'dest') destFlag = take(); + else if (flag === '-name' && kind === 'newitem') nameFlag = take(); + else if (VALUE_FLAGS.has(flag) || flag === '-name' || flag === '-destination') { + if (inline === undefined) k++; + } + continue; + } + positional.push(a); + } + const add = (w: Word | undefined) => { + if (w) out.push(w); + }; + switch (kind) { + case 'first': + add(pathFlag ?? positional[0]); + break; + case 'all': + if (pathFlag) add(pathFlag); + positional.forEach(add); + break; + case 'newitem': { + const base = pathFlag ?? positional[0]; + if (base && nameFlag) + add({ + text: `${base.text}/${nameFlag.text}`, + quoted: base.quoted === 'single' && nameFlag.quoted === 'single' ? 'single' : 'double', + }); + else add(base ?? nameFlag); + break; + } + case 'dest': { + if ( + pathFlag && + (cmd.startsWith('move') || + cmd === 'mi' || + cmd === 'mv' || + cmd.startsWith('ren') || + cmd === 'rni') + ) + add(pathFlag); + if (destFlag) add(destFlag); + else { + const src = pathFlag ? positional : positional.slice(0, -1); + const dest = pathFlag ? positional[0] : positional[positional.length - 1]; + if (positional.length >= (pathFlag ? 1 : 2)) { + add(dest); + if (dest && dest.quoted !== 'single') { + for (const s of src) { + const b = s.text.split(/[\\/]/).pop(); + if (b && !/\$|\*/.test(b)) add({ text: `${dest.text}/${b}`, quoted: dest.quoted }); + } + } + } + if (cmd === 'mv' || cmd === 'move' || cmd === 'move-item' || cmd === 'mi') src.forEach(add); + } + break; + } + } + return out; +} + +function isAbsoluteAny(p: string): boolean { + return ( + path.isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('~') || p.startsWith('\\\\') + ); +} + +export function findPwshWriteTargets(command: string, ctx: Ctx): PwshWriteTargets { + const resolved: string[] = []; + const unresolved: string[] = []; + if (!command || !command.trim()) return { resolved, unresolved }; + const assigned = assignedVars(command); + let cwd: string | null = ctx.cwd; + for (const seg of segments(command)) { + const ws = words(seg); + const head = ws[0]?.text.toLowerCase(); + if (head && ['cd', 'chdir', 'pushd', 'set-location', 'sl', 'push-location'].includes(head)) { + const args = ws + .slice(1) + .filter((w) => !(w.quoted === null && /^-/.test(w.text) && !/^-$/.test(w.text))); + const dest = args[0]; + if (!dest || dest.text === '~') cwd = ctx.home; + else if (dest.text === '-') cwd = null; + else { + const e = expand(dest, ctx, assigned); + if (e === null) cwd = null; + else { + const n = e.replace(/\\/g, '/'); + const abs = n.startsWith('~') ? ctx.home + n.slice(1) : n; + cwd = isAbsoluteAny(abs) ? abs : cwd === null ? null : path.resolve(cwd, abs); + } + } + continue; + } + for (const t of targetWords(ws)) { + if (!t.text || NULL_TARGETS.test(t.text)) continue; + const e = expand(t, ctx, assigned); + if (e === null) { + unresolved.push(t.text); + continue; + } + const n = e.replace(/\\/g, '/'); + if (isAbsoluteAny(n)) resolved.push(n); + else if (cwd === null) unresolved.push(t.text); + else resolved.push(path.resolve(cwd, n)); + } + } + return { resolved, unresolved }; +} diff --git a/test/o130-dollar-control.spec.mjs b/test/o130-dollar-control.spec.mjs new file mode 100644 index 00000000..c6db95e1 --- /dev/null +++ b/test/o130-dollar-control.spec.mjs @@ -0,0 +1,44 @@ +// O130 UX false-positive control: 10 everyday Linux/macOS commands carrying `$` +// in arguments / reads (never in a write target that cannot be resolved) must +// produce exactly the same number of protected-path prompts as before O130: +// zero. Recorded against main 1705d9ef with the same ctx (see PR body). +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 { findShellProtectedWrites } from '../dist/safety/protected-paths.js'; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o130c-')); +const home = path.join(root, 'home'); +const ws = path.join(root, 'ws'); +const cfg = path.join(home, '.config', 'moss'); +for (const d of [cfg, path.join(ws, 'src')]) fs.mkdirSync(d, { recursive: true }); +test.after(() => fs.rmSync(root, { recursive: true, force: true })); + +const env = { HOME: home, USER: 'dev', SHELL: '/bin/bash', PATH: '/usr/bin:/bin' }; +const ctx = { cwd: ws, workspaceRoot: ws, configDirs: [cfg], env, home }; + +export const CONTROL = [ + 'echo $HOME', + 'ls $PWD', + 'export PATH=$PATH:/opt/bin', + 'echo "$USER" > whoami.txt', + 'grep -rn "$PATTERN" src', + 'for f in src/*.ts; do wc -l $f; done', + 'cd $HOME && ls', + 'cat $HOME/.bashrc', + 'printf \'%s\\n\' "$SHELL" >> out.log', + 'echo $((1 + 2)) > calc.txt', +]; + +test('O130 control: 10 `$` commands → 0 prompts (unchanged vs main)', () => { + const counts = CONTROL.map((c) => findShellProtectedWrites(c, ctx).length); + assert.equal(CONTROL.length, 10); + assert.deepEqual( + counts, + Array(10).fill(0), + JSON.stringify(Object.fromEntries(CONTROL.map((c, i) => [c, counts[i]]))) + ); +}); diff --git a/test/o130-process-block.spec.mjs b/test/o130-process-block.spec.mjs new file mode 100644 index 00000000..4457c9bb --- /dev/null +++ b/test/o130-process-block.spec.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +/** + * O130 acceptance A/E (process-level): real `dist/cli.js -p` + local fake + * OpenAI-compatible model. Every write form the Windows probe showed WRITTEN + * with exit 0 (Set-Content, `$env:APPDATA`, `$env:MOSS_CONFIG_DIR`, pwsh + * writers) into Moss's own config dir must now be refused: exit 9, last line + * 「被拦」/`Blocked by`, file unchanged, JSON recoverable:false. On Windows the + * host shell is pwsh, so the forms would really run if not gated. + * + * Plus the known limitation (PM): a path built INSIDE interpreter code + * (`node w.js` computing the env name) is not seen pre-exec; L3 post-hoc + * detection must report it — exit 9, 「已被修改」/modified. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CLI = path.join(ROOT, 'dist', 'cli.js'); +const IS_WIN = process.platform === 'win32'; + +function startFake(steps, finalText) { + const server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + if (req.method === 'GET') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ object: 'list', data: [{ id: 'fake-model', object: 'model' }] })); + return; + } + let body = {}; + try { + body = JSON.parse(raw || '{}'); + } catch { + /* ignore */ + } + const n = (body.messages ?? []).filter((m) => m.role === 'tool').length; + const chunk = (delta, fr = null) => + `data: ${JSON.stringify({ id: 'x', object: 'chat.completion.chunk', created: 1, model: 'fake-model', choices: [{ index: 0, delta, finish_reason: fr }] })}\n\n`; + res.writeHead(200, { 'content-type': 'text/event-stream' }); + if (body.tools && n < steps.length) { + const s = steps[n]; + res.write( + chunk({ + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `call_${n}`, + type: 'function', + function: { name: s.name, arguments: JSON.stringify(s.args) }, + }, + ], + }) + ); + res.write(chunk({}, 'tool_calls')); + } else { + res.write(chunk({ role: 'assistant', content: finalText })); + res.write(chunk({}, 'stop')); + } + res.end('data: [DONE]\n\n'); + }); + }); + return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server))); +} + +function run(args, env, cwd, timeoutMs = 90000) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [CLI, ...args], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (err += d)); + const t = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.on('close', (code) => { + clearTimeout(t); + resolve({ code, out, err }); + }); + }); +} + +/** + * dir: 'appdata' → Moss config dir is %APPDATA%\moss (on Windows MOSS_CONFIG_DIR + * is unset so Moss resolves it itself; elsewhere MOSS_CONFIG_DIR points there); + * 'custom' → MOSS_CONFIG_DIR is a separate dir. + * withHome: Git-Bash-like env with HOME set (probe ran both). + */ +async function scenario({ + lang = 'en', + command, + files = {}, + dir = 'appdata', + withHome = true, + json = false, +}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'o130-')); + const home = path.join(root, 'home'); + const appdata = path.join(home, 'AppData', 'Roaming'); + const proj = path.join(root, 'proj'); + const cfg = dir === 'custom' ? path.join(root, 'custom-moss') : path.join(appdata, 'moss'); + for (const d of [home, appdata, proj, cfg]) fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(proj, 'README.md'), '# p\n'); + fs.writeFileSync(path.join(proj, 'evil.json'), '{"PWN":1}\n'); + for (const [f, c] of Object.entries(files)) fs.writeFileSync(path.join(proj, f), c); + fs.writeFileSync(path.join(cfg, 'credentials'), 'ORIG\n'); + const server = await startFake( + [{ name: 'exec', args: { command: command({ cfg, appdata }) } }], + lang === 'zh' ? '全部完成。' : 'All done.' + ); + const port = server.address().port; + const env = { + PATH: process.env.PATH, + USERPROFILE: home, + APPDATA: appdata, + ...(withHome ? { HOME: home } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.ComSpec ? { ComSpec: process.env.ComSpec } : {}), + ...(process.env.PATHEXT ? { PATHEXT: process.env.PATHEXT } : {}), + ...(dir === 'custom' || !IS_WIN ? { MOSS_CONFIG_DIR: cfg } : {}), + MOSS_RUNTIME_DIR: path.join(home, 'runtime'), + MOSS_NO_BUNDLED_DEFAULT: '1', + MOSS_NO_TUI: '1', + MOSS_LOCALE: lang, + FAKE_OAI_KEY: 'sk-fake-local-000', + GIT_CONFIG_NOSYSTEM: '1', + }; + for (const [k, v] of [ + ['provider', 'openai-compatible'], + ['baseUrl', `http://127.0.0.1:${port}`], + ['model', 'fake-model'], + ['apiKeyEnv', 'FAKE_OAI_KEY'], + ]) + await run(['config', 'set', k, v], env, proj); + const cfgBefore = fs.readFileSync(path.join(cfg, 'config.json'), 'utf8'); + const r = await run( + [ + '--trust-workspace', + '--lang', + lang, + ...(json ? ['--output-format', 'json'] : []), + '-p', + 'run it', + ], + env, + proj + ); + server.closeAllConnections?.(); + server.close(); + const cfgAfter = fs.readFileSync(path.join(cfg, 'config.json'), 'utf8'); + const credAfter = fs.readFileSync(path.join(cfg, 'credentials'), 'utf8'); + fs.rmSync(root, { recursive: true, force: true }); + const last = r.out.trim().split('\n').pop() ?? ''; + return { + ...r, + last, + cfgChanged: cfgAfter !== cfgBefore, + credChanged: credAfter !== 'ORIG\n', + cfg, + }; +} + +// Probe rows (spec table) + QA forms. `dir` decides which config dir is live. +const WIN_FORMS = [ + [ + 'Set-Content abs config.json', + 'appdata', + ({ cfg }) => `Set-Content -Path '${path.join(cfg, 'config.json')}' -Value PWN`, + ], + [ + 'Set-Content abs credentials', + 'appdata', + ({ cfg }) => `Set-Content -Path '${path.join(cfg, 'credentials')}' -Value PWN`, + ], + ['$env:APPDATA config.json', 'appdata', () => 'echo PWN > "$env:APPDATA\\moss\\config.json"'], + [ + '$env:APPDATA credentials', + 'appdata', + () => 'Set-Content "$env:APPDATA\\moss\\credentials" PWN', + ], + [ + '$env:MOSS_CONFIG_DIR config.json', + 'custom', + () => 'echo PWN > "$env:MOSS_CONFIG_DIR\\config.json"', + ], + [ + 'Set-Content MOSS_CONFIG_DIR credentials', + 'custom', + ({ cfg }) => `Set-Content '${path.join(cfg, 'credentials')}' PWN`, + ], + ['sc alias, $ENV: mixed case', 'custom', () => 'sc $ENV:moss_config_dir/credentials PWN'], + ['Out-File pipe', 'appdata', () => "'PWN' | Out-File -FilePath $env:APPDATA/moss/config.json"], + [ + 'Tee-Object', + 'appdata', + () => '\'PWN\' | Tee-Object -FilePath "$env:APPDATA\\moss\\credentials"', + ], + [ + 'Copy-Item -Destination', + 'custom', + () => 'Copy-Item evil.json -Destination "$env:MOSS_CONFIG_DIR\\config.json"', + ], + [ + '[IO.File]::WriteAllText', + 'appdata', + () => '[IO.File]::WriteAllText("$env:APPDATA\\moss\\config.json", "PWN")', + ], + ['cd $env + relative sc', 'appdata', () => 'cd $env:APPDATA\\moss; sc credentials PWN'], + ['%APPDATA%', 'appdata', () => 'echo PWN > %APPDATA%\\moss\\config.json'], + [ + 'same-line reassigned $env (unresolvable)', + 'appdata', + () => '$env:X=\'a\'; Set-Content "$env:X\\config.json" PWN', + ], + [ + 'unresolvable $p', + 'appdata', + () => '$p = Join-Path $env:APPDATA moss\\config.json; Set-Content $p PWN', + ], +]; + +for (const withHome of IS_WIN ? [true, false] : [true]) { + for (const [name, dir, command] of WIN_FORMS) { + test(`O130 -p blocks ${name} (${withHome ? 'HOME set' : 'native'})`, async () => { + const r = await scenario({ command, dir, withHome }); + assert.equal(r.code, 9, `exit 9, got ${r.code}; last=${r.last}\n${r.err.slice(-800)}`); + assert.match(r.last, /^Incomplete.*Blocked by/, r.last); + assert.ok(!r.cfgChanged, 'config.json unchanged'); + assert.ok(!r.credChanged, 'credentials unchanged'); + }); + } +} + +for (const lang of ['zh', 'en']) { + test(`O130 [${lang}] blocked line + stderr notice + json recoverable:false`, async () => { + const command = () => 'Set-Content "$env:APPDATA\\moss\\config.json" PWN'; + const r = await scenario({ lang, command }); + assert.equal(r.code, 9); + assert.match( + r.last, + lang === 'zh' + ? /^未完成.*被拦:.*moss[\\/]config\.json/ + : /^Incomplete.*Blocked by: .*moss[\\/]config\.json/, + r.last + ); + assert.match(r.err, lang === 'zh' ? /\[moss\] 已拦截/ : /\[moss\] Blocked/, r.err.slice(-600)); + const j = await scenario({ lang, command, json: true }); + assert.equal(j.code, 9); + const obj = JSON.parse(j.out.trim().split('\n').pop()); + assert.equal(obj.subtype, 'error_during_execution', j.out.slice(-400)); + assert.equal(obj.recoverable, false, j.out.slice(-400)); + assert.ok(!j.cfgChanged); + }); + + test(`O130 [${lang}] unresolvable write target: reason + next step`, async () => { + const r = await scenario({ + lang, + command: () => 'Set-Content -Path $env:O130_NOT_SET\\config.json -Value PWN', + }); + assert.equal(r.code, 9, r.last); + assert.match( + r.err, + lang === 'zh' + ? /无法确定 `\$env:O130_NOT_SET\\config\.json` 指向的路径.*改用字面路径/ + : /cannot tell where `\$env:O130_NOT_SET\\config\.json` points.*literal path/, + r.err.slice(-800) + ); + }); +} + +// Known limitation → L3 (PM): a path computed inside the interpreter (env NAME +// built at runtime) is not visible pre-exec. L3 (protected-path-snapshot.ts) +// does NOT watch Moss's config dir today, so this write lands with exit 0 on +// every OS → per PM this is a separate P1 (L3 must watch config.json / +// credentials), not covered by O130. Kept as `todo` so the gap stays visible in +// every CI log and turns into a real assertion when that P1 lands. +test( + 'O130 L3 gap: node script building the config path at runtime → should exit 9 「已被修改」', + { todo: 'L3 does not watch the Moss config dir — separate P1 (see PR body)' }, + async () => { + const script = + "const p=require('path');const n=['MOSS','CONFIG','DIR'].join('_');" + + "const d=process.env[n];require('fs').writeFileSync(p.join(d,'credentials'),'PWN');\n"; + const r = await scenario({ + command: () => 'node w.cjs', + files: { 'w.cjs': script }, + dir: 'custom', + }); + console.log( + `# O130-L3 ${process.platform}: exit=${r.code} credChanged=${r.credChanged} last=${r.last}` + ); + assert.equal(r.code, 9, `exit 9, got ${r.code}; last=${r.last}`); + assert.match(r.last, /modified|已被修改/, r.last); + } +); diff --git a/test/o130-pwsh-write-targets.spec.mjs b/test/o130-pwsh-write-targets.spec.mjs new file mode 100644 index 00000000..e926cd89 --- /dev/null +++ b/test/o130-pwsh-write-targets.spec.mjs @@ -0,0 +1,237 @@ +// O130 (P1): write targets the POSIX detector did not model — PowerShell write +// cmdlets/aliases, [IO.File]::Write*, every redirection form, `$env:X` / +// `${env:X}` / `%X%` expansion — plus the PM scope rule: only WRITE TARGETS +// are examined; resolvable variables are expanded then compared; unresolvable +// ones (unknown, assigned earlier on the same line, command substitution, +// relative after an unresolvable `cd`) are blocked; `$` in arguments / reads +// never prompts. Detector-level, runs on all three OSes (env comes from ctx). +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 { findShellProtectedWrites } from '../dist/safety/protected-paths.js'; +import { + protectedPathHeadlessNotice, + protectedPathHeadlessReason, +} from '../dist/cli/protected-path-prompt.js'; + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o130p-')); +const profile = path.join(root, 'profile'); +const appdata = path.join(profile, 'AppData', 'Roaming'); +const cfg = path.join(appdata, 'moss'); +const custom = path.join(root, 'custom-moss'); +const ws = path.join(root, 'ws'); +for (const d of [cfg, custom, ws, path.join(ws, 'src')]) fs.mkdirSync(d, { recursive: true }); +test.after(() => fs.rmSync(root, { recursive: true, force: true })); + +const env = { + USERPROFILE: profile, + HOME: profile, + APPDATA: appdata, + MOSS_CONFIG_DIR: custom, + TEMP: path.join(root, 'tmp'), + PATH: '/usr/bin', +}; +const ctx = { cwd: ws, workspaceRoot: ws, configDirs: [cfg, custom], env, home: profile }; +const hits = (cmd) => findShellProtectedWrites(cmd, ctx); +const fwd = (p) => p.replace(/\\/g, '/'); +const CFG = fwd(path.join(cfg, 'config.json')); +const CRED = fwd(path.join(custom, 'credentials')); +const CCFG = fwd(path.join(custom, 'config.json')); + +function blocked(cmd, why) { + const h = hits(cmd); + // Any gate counts (pre-O130 layers already gate some `$` targets as + // `git` via the workspace-root fallback; that stays — stricter only). + assert.ok(h.length > 0, `${why}: expected a gate for ${cmd} -> ${JSON.stringify(h)}`); + return h; +} +function protectedHit(cmd) { + const h = blocked(cmd, 'protected'); + assert.ok( + h.some((x) => x.category === 'moss-config'), + `${cmd} -> moss-config, got ${JSON.stringify(h)}` + ); +} +function unresolvedHit(cmd) { + return blocked(cmd, 'unresolved'); +} +function clean(cmd) { + const h = hits(cmd); + assert.deepEqual(h, [], `must not gate: ${cmd} -> ${JSON.stringify(h)}`); +} + +// ---- 1. every pwsh writer / alias / redirection, literal path ---- +const LITERAL = [ + `Set-Content -Path '${CFG}' -Value PWN`, + `Set-Content -LiteralPath '${CFG}' -Value PWN`, + `Set-Content -Path:'${CFG}' -Value PWN`, + `Set-Content '${CFG}' PWN`, // positional + `set-content '${CFG}' PWN`, // case + `SET-CONTENT "${CFG}" PWN`, + `sc '${CFG}' PWN`, + `Add-Content '${CCFG}' PWN`, + `ac -Path '${CCFG}' -Value PWN`, + `'x' | Out-File '${CFG}'`, + `'x' | Out-File -FilePath '${CRED}' -Encoding utf8`, + `'x' | Tee-Object -FilePath '${CFG}'`, + `'x' | Tee-Object '${CFG}'`, + `New-Item -Path '${CFG}' -Value PWN -Force`, + `New-Item -ItemType File -Path '${custom}' -Name credentials -Value x`, + `ni '${CFG}' -Value PWN`, + `Copy-Item evil.json -Destination '${CFG}'`, + `Copy-Item -Path evil.json -Destination '${CFG}'`, + `Copy-Item evil.json '${CFG}'`, + `cpi evil.json '${CFG}'`, + `copy evil.json '${CFG}'`, + `Move-Item evil.json -Destination '${CRED}'`, + `mi evil.json '${CRED}'`, + `Clear-Content '${CFG}'`, + `[IO.File]::WriteAllText('${CFG}', 'PWN')`, + `[System.IO.File]::WriteAllBytes('${CRED}', @(1))`, + `[IO.File]::AppendAllText("${CFG}", 'PWN')`, + `echo PWN > '${CFG}'`, + `echo PWN >> '${CFG}'`, + `echo PWN 2> '${CFG}'`, + `echo PWN *> '${CFG}'`, + `echo PWN *>> '${CFG}'`, + `echo PWN >'${CFG}'`, + `cmd /c 2>'${CFG}' echo x`, + `echo PWN | tee '${CFG}'`, + `echo PWN | tee -a /tmp/x '${CRED}'`, + `cp evil.json '${CFG}'`, + `mv evil.json '${CRED}'`, + `cp -t '${custom}' credentials`, + // mixed separators / case + `Set-Content '${CFG.replace(/\//g, '\\')}' x`, + `Set-Content '${CFG.replace(/\/moss\//, '\\moss/')}' x`, +]; +for (const cmd of LITERAL) test(`O130 literal writer gated: ${cmd}`, () => protectedHit(cmd)); + +// ---- 2. env forms that resolve from the current env → expanded then compared ---- +const ENVFORMS = [ + 'echo PWN > "$env:APPDATA/moss/config.json"', + 'echo PWN > "$env:APPDATA\\moss\\config.json"', + 'echo PWN > "${env:APPDATA}/moss/config.json"', + 'echo PWN > "$ENV:appdata/moss/config.json"', + 'echo PWN > "$env:MOSS_CONFIG_DIR/credentials"', + 'echo PWN > %APPDATA%/moss/config.json', + 'echo PWN > %appdata%\\moss\\config.json', + 'Set-Content -Path "$env:APPDATA\\moss\\config.json" -Value PWN', + 'Set-Content "$env:APPDATA\\moss\\credentials" PWN', + 'sc $env:MOSS_CONFIG_DIR/config.json PWN', + 'Add-Content -LiteralPath "$env:MOSS_CONFIG_DIR\\credentials" x', + "'x' | Out-File $env:APPDATA/moss/config.json", + "'x' | Tee-Object -FilePath $env:APPDATA\\moss\\config.json", + 'New-Item -Path "$env:MOSS_CONFIG_DIR" -Name config.json -Value x -Force', + 'Copy-Item evil.json -Destination "$env:APPDATA\\moss\\config.json"', + 'Move-Item evil.json "$env:MOSS_CONFIG_DIR/credentials"', + '[IO.File]::WriteAllText("$env:APPDATA\\moss\\config.json", "x")', + 'cp evil.json "$MOSS_CONFIG_DIR/config.json"', + 'echo x > "$APPDATA/moss/config.json"', + 'echo x > "${APPDATA}/moss/config.json"', + 'tee $APPDATA/moss/config.json < evil.json', + // relative after a resolvable cd (QA counterexample) + 'cd $env:APPDATA\\moss; sc config.json x', + 'Set-Location "$env:MOSS_CONFIG_DIR"; Set-Content credentials x', + 'cd %APPDATA%\\moss && echo x > config.json', +]; +for (const cmd of ENVFORMS) test(`O130 env-form writer gated: ${cmd}`, () => protectedHit(cmd)); + +// ---- 3. unresolvable write targets → conservative block ---- +const UNRESOLVABLE = [ + 'Set-Content $p x', // pwsh variable, positional path + 'Set-Content -Path $p -Value x', + 'echo x > "$p"', + 'echo x > "$NOT_SET_O130/config.json"', + 'echo x > "$env:NOT_SET_O130\\config.json"', + 'echo x > %NOT_SET_O130%\\config.json', + 'echo x > "$(Get-Moss)/config.json"', + 'echo x > "$(dirname x)/config.json"', + 'echo x > `pwd`/config.json', + "'x' | Out-File $target", + 'Copy-Item a.json -Destination $dst', + 'cp a.json "$DST"', + 'tee "$OUT"', + '[IO.File]::WriteAllText($p, "x")', + 'cd $UNSET_O130 && echo x > config.json', + 'cd $p; sc config.json x', + // QA (2): assigned earlier on the same line → never trust the current env + '$env:APPDATA=\'C:\\x\'; Set-Content "$env:APPDATA\\moss\\config.json" x', + '$env:APPDATA = "/tmp/elsewhere"; echo x > "$env:APPDATA/moss/config.json"', + 'APPDATA=/tmp/x; echo x > "$APPDATA/moss/config.json"', + 'APPDATA=/tmp/x echo x > "$APPDATA/x.txt"', + 'export MOSS_CONFIG_DIR=/tmp/x && echo x > "$MOSS_CONFIG_DIR/config.json"', + 'set APPDATA=C:\\x&& echo x > %APPDATA%\\moss\\config.json', + '$p = "$env:APPDATA\\moss\\config.json"; Set-Content $p x', + 'HOME=/tmp/h; echo x > "$HOME/.bashrc"', +]; +for (const cmd of UNRESOLVABLE) + test(`O130 unresolvable write target gated: ${cmd}`, () => unresolvedHit(cmd)); + +// The same-line-assigned case must not be compared with the CURRENT env value +// (which would hit protected anyway here) — it must be unresolved even when the +// current env points somewhere harmless. +test('O130: same-line assignment overrides the env (not compared with current value)', () => { + const harmless = { ...ctx, env: { ...env, APPDATA: path.join(root, 'elsewhere') } }; + const h = findShellProtectedWrites( + '$env:APPDATA=\'C:\\x\'; Set-Content "$env:APPDATA\\notes.txt" x', + harmless + ); + assert.ok( + h.some((x) => x.category === 'unresolved'), + JSON.stringify(h) + ); +}); + +// ---- 4. counterexamples: `$` in arguments / reads, harmless resolvable targets ---- +const ALLOW = [ + 'Get-Content "$env:APPDATA/moss/config.json"', + 'type %APPDATA%\\moss\\config.json', + 'cat "$MOSS_CONFIG_DIR/config.json"', + 'Get-Content "$env:APPDATA/moss/config.json" | Set-Content out.txt', + 'echo $env:APPDATA > out.txt', + 'echo $HOME > out.txt', + 'echo %APPDATA% > out.txt', + 'Write-Output $p > out.txt', + 'grep $PAT src/a.ts', + 'echo hi > out.txt', + `Set-Content -Path '${path.join(ws, 'notes.txt')}' -Value x`, + 'Set-Content -Path notes.txt -Value $env:APPDATA', + 'Set-Content notes.txt "$env:APPDATA\\moss\\config.json"', // value, not path + 'echo PWN > "$env:APPDATA/other-app/config.json"', + 'echo x > "$env:TEMP/o130.txt"', + 'Copy-Item "$env:APPDATA\\moss\\config.json" backup.json', // copy FROM config + 'cp "$MOSS_CONFIG_DIR/config.json" backup.json', + 'echo x 2>&1', + 'echo x > NUL', + 'echo x 2>/dev/null', + 'cd $env:TEMP; echo x > a.txt', +]; +for (const cmd of ALLOW) test(`O130 does not gate: ${cmd}`, () => clean(cmd)); + +// ---- 5. copy (UX): reason + next step, zh and en ---- +test('O130 unresolvable copy names the variable, why, and the next step (zh/en)', () => { + const [h] = hits('Set-Content -Path $env:NOT_SET_O130\\config.json -Value x'); + assert.equal(h.category, 'unresolved'); + assert.equal(h.kind, 'unresolved-var'); + const zh = protectedPathHeadlessNotice(h, true); + const en = protectedPathHeadlessNotice(h, false); + assert.match(zh, /无法确定 `\$env:NOT_SET_O130\\config\.json` 指向的路径/); + assert.match(zh, /按受保护路径处理/); + assert.match(zh, /改用字面路径.*edit 工具/); + assert.match(en, /cannot tell where `\$env:NOT_SET_O130\\config\.json` points/); + assert.match(en, /literal path or the edit tool/); + assert.match(protectedPathHeadlessReason(h, true), /^已拦截:无法确定/); +}); + +test('O130 resolved env form reports the expanded path', () => { + const h = hits('Set-Content "$env:APPDATA\\moss\\config.json" x').find( + (x) => x.category === 'moss-config' + ); + assert.ok(h); + assert.equal(fwd(h.resolved).toLowerCase(), CFG.toLowerCase()); + assert.doesNotMatch(h.raw, /\$env:/); +}); diff --git a/test/o94-protected-paths.spec.mjs b/test/o94-protected-paths.spec.mjs index 197abe7c..d2c28d11 100644 --- a/test/o94-protected-paths.spec.mjs +++ b/test/o94-protected-paths.spec.mjs @@ -57,6 +57,8 @@ for (const [f, c] of Object.entries(seed)) fs.writeFileSync(f, c); const gitEnv = { ...process.env, HOME: home, + // O130: on Windows os.homedir() (used for `~`) reads USERPROFILE, not HOME. + USERPROFILE: home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', @@ -70,7 +72,7 @@ fs.symlinkSync(path.join(home, '.bashrc'), path.join(ws, 'rc-link')); fs.symlinkSync(path.join(home, '.ssh'), path.join(ws, 'ssh-link')); const caseInsensitiveFs = fs.existsSync(path.join(ws, '.GIT')); -const env = { HOME: home, MOSS_CONFIG_DIR: cfg, PATH: process.env.PATH }; +const env = { HOME: home, USERPROFILE: home, MOSS_CONFIG_DIR: cfg, PATH: process.env.PATH }; function snapshot() { const out = {}; @@ -607,7 +609,9 @@ await check(async () => { await check(async () => { // Shared O97 detector: HOME for ~ comes from process.env / os.homedir(). const prevHome = process.env.HOME; + const prevProfile = process.env.USERPROFILE; process.env.HOME = home; + process.env.USERPROFILE = home; // O130: Windows os.homedir() source try { const r = detectShellWriteTargets('echo x >> ~/.bashrc && rm -rf .git', ws); assert.ok(r.targets.includes(path.join(home, '.bashrc')), 'absolute ~ expansion'); @@ -629,6 +633,8 @@ await check(async () => { } finally { if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + if (prevProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevProfile; } ok('shared detector shape: { targets, uncertain, mentions }'); }, 'shared detector shape'); From 0daa97ec8a28bf0f05c3ed45f19e6bb918d6f761 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 14:18:58 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(safety):=20O130=20close=20QA=20BLOCK=20?= =?UTF-8?q?gaps=20=E2=80=94=20abbrev=20flags,=20paren=20targets,=20writers?= =?UTF-8?q?,=20one-level=20nest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Value-flag abbreviations match by PowerShell unique-prefix (-En/-Va/-Enc/-Fi) so they cannot steal the path positional slot - Parenthesized expression write targets (Join-Path, string concat) always block as unresolvable - Add writers: Set-Item, Export-Clixml/Csv, Invoke-WebRequest -OutFile, curl -o/-O, xcopy/robocopy, New-Object IO.StreamWriter - One-level nested scan for &{}/.{} / pwsh -c / iex (same rules inside); -EncodedCommand always blocks; deeper → O148 + O143 L3 - Temporarily rename o130-*.spec.mjs → aa-o130-*.spec.mjs so Windows CI runs them before exec-git-config (pre-existing red) aborts the suite; proper fix O149 (P2) --- src/safety/pwsh-write-targets.ts | 358 ++++++++++++++++-- ...ec.mjs => aa-o130-dollar-control.spec.mjs} | 0 ...pec.mjs => aa-o130-process-block.spec.mjs} | 0 ...js => aa-o130-pwsh-write-targets.spec.mjs} | 65 ++++ ...js => aa-o130-win-config-protect.spec.mjs} | 0 5 files changed, 399 insertions(+), 24 deletions(-) rename test/{o130-dollar-control.spec.mjs => aa-o130-dollar-control.spec.mjs} (100%) rename test/{o130-process-block.spec.mjs => aa-o130-process-block.spec.mjs} (100%) rename test/{o130-pwsh-write-targets.spec.mjs => aa-o130-pwsh-write-targets.spec.mjs} (77%) rename test/{o130-win-config-protect.spec.mjs => aa-o130-win-config-protect.spec.mjs} (100%) diff --git a/src/safety/pwsh-write-targets.ts b/src/safety/pwsh-write-targets.ts index 0a98d5b7..28206e27 100644 --- a/src/safety/pwsh-write-targets.ts +++ b/src/safety/pwsh-write-targets.ts @@ -4,14 +4,19 @@ * cmd env forms (`$env:X`, `${env:X}`, `%X%`) and every redirection form * (`>`, `>>`, `2>`, `*>`, `&>`). Stricter only. * - * Scope (PM 10-12 13:07): only WRITE TARGETS are looked at. A `$` in an - * argument or a read is never reported. + * Scope (PM 10-12 13:07 / QA BLOCK 10-12 14:08): only WRITE TARGETS are + * looked at. A `$` in an argument or a read is never reported. * - A target whose variables all resolve from the current process env is * expanded and compared against protected paths. * - A target with an unknown variable, a variable assigned earlier on the same - * command line (QA: never trust the current env for it), or a command - * substitution is UNRESOLVABLE and reported so the caller blocks it. + * command line (QA: never trust the current env for it), a parenthesized + * expression, or a command substitution is UNRESOLVABLE and reported so the + * caller blocks it. * - After `cd ` a relative write target is unresolvable too. + * - Parameter names match by PowerShell unique-prefix against the full-name + * lists (so `-En`/`-Va`/`-Enc` consume values and do not steal the path). + * - One level of nested execution (`& {}` / `. {}` / `pwsh -c` / `iex`) is + * re-scanned with the SAME rules; `-EncodedCommand` always blocks. */ import * as path from 'node:path'; @@ -72,7 +77,11 @@ interface Word { quoted: 'single' | 'double' | null; } -/** Words of one simple command; redirect operators become their own words. No backslash escapes (pwsh / cmd paths). */ +/** + * Words of one simple command; redirect operators become their own words. + * Balanced `(...)` is kept as a single word (parenthesized expression target). + * No backslash escapes (pwsh / cmd paths). + */ function words(seg: string): Word[] { const out: Word[] = []; let cur = ''; @@ -98,6 +107,27 @@ function words(seg: string): Word[] { quoted = quoted ?? (c === "'" ? 'single' : 'double'); continue; } + if (c === '(' && !has) { + // parenthesized expression: keep balanced parens (and nested quotes) as one word + let depth = 1; + let j = i + 1; + let pq: string | null = null; + let body = '('; + while (j < seg.length && depth > 0) { + const ch = seg[j]!; + body += ch; + if (pq) { + if (ch === pq) pq = null; + } else if (ch === '"' || ch === "'") { + pq = ch; + } else if (ch === '(') depth++; + else if (ch === ')') depth--; + j++; + } + out.push({ text: body, quoted: null }); + i = j - 1; + continue; + } if (/\s/.test(c)) { flush(); continue; @@ -156,11 +186,14 @@ const NULL_TARGETS = /^(?:\$null|nul|\/dev\/(?:null|stdout|stderr|tty)|&fd|-)$/i /** * Expand a write-target word. Returns null when it cannot be resolved - * statically (unknown / reassigned variable, command substitution). + * statically (unknown / reassigned variable, command substitution, + * parenthesized expression). */ function expand(word: Word, ctx: Ctx, assigned: Set): string | null { let w = word.text; if (word.quoted === 'single') return w; // literal in pwsh and POSIX + // Parenthesized expression / concat / subexpression → unresolvable (block). + if (/^\s*\(|\)/.test(w) || /\+|Join-Path/i.test(w)) return null; if (/\$\(|`|@\(|\$\[|<\(|\$\{[^}]*[^\w:}]/.test(w)) return null; const lookup = (name: string): string | null => { const lower = name.toLowerCase(); @@ -187,7 +220,7 @@ function expand(word: Word, ctx: Ctx, assigned: Set): string | null { return w; } -const VALUE_FLAGS = new Set([ +const VALUE_FLAGS = [ '-value', '-encoding', '-itemtype', @@ -201,17 +234,50 @@ const VALUE_FLAGS = new Set([ '-stream', '-delimiter', '-variable', - '-t', // cp/mv -t handled separately '-m', '--mode', '-s', - '-o', '-g', -]); -const PATH_FLAGS = new Set(['-path', '-literalpath', '-filepath', '-filename', '-pspath', '-lp']); -const DEST_FLAGS = new Set(['-destination', '-target', '--target-directory', '-t']); + '-uri', + '-url', + '-method', + '-headers', + '-body', + '-contenttype', + '-userdata', + '-transferencoding', + '-argumentlist', + '-args', +]; +const PATH_FLAGS = [ + '-path', + '-literalpath', + '-filepath', + '-pspath', + '-lp', + '-outfile', + '-newname', +]; +const DEST_FLAGS = ['-destination', '-target', '--target-directory', '-t']; + +/** PowerShell unique-prefix match against a list of full flag names. */ +function matchFlagPrefix(flag: string, names: readonly string[]): string | undefined { + const lower = flag.toLowerCase(); + if (names.includes(lower)) return lower; + const hits = names.filter((n) => n.startsWith(lower)); + return hits.length === 1 ? hits[0] : undefined; +} -type Kind = 'first' | 'all' | 'dest' | 'newitem'; +/** True when flag is a (possibly abbreviated) value-taking switch we should skip. */ +function isValueFlag(flag: string): boolean { + if (matchFlagPrefix(flag, VALUE_FLAGS)) return true; + // Ambiguous abbreviation that still prefixes at least one value flag (e.g. -E + // → Encoding/Exclude): consume the next token so it cannot steal the path slot. + const lower = flag.toLowerCase(); + return VALUE_FLAGS.some((n) => n.startsWith(lower) && lower.length >= 2); +} + +type Kind = 'first' | 'all' | 'dest' | 'newitem' | 'outfile' | 'curl'; const WRITERS: Record = { 'set-content': 'first', sc: 'first', @@ -247,8 +313,58 @@ const WRITERS: Record = { rmdir: 'all', touch: 'all', truncate: 'all', + // O130 QA BLOCK: additional writers + 'set-item': 'first', + si: 'first', + 'export-clixml': 'first', + 'export-csv': 'first', + 'invoke-webrequest': 'outfile', + iwr: 'outfile', + 'invoke-restmethod': 'outfile', + irm: 'outfile', + curl: 'curl', + wget: 'curl', + xcopy: 'dest', + robocopy: 'dest', }; +/** Paths passed to New-Object IO.StreamWriter(...) / [IO.StreamWriter]::new(...). */ +function extractStreamWriterPaths(raw: string): Word[] { + const out: Word[] = []; + const pushArg = (a: string) => { + let s = a.trim(); + if (!s) return; + let quoted: Word['quoted'] = null; + if (/^'.*'$/.test(s)) { + quoted = 'single'; + s = s.slice(1, -1); + } else if (/^".*"$/.test(s)) { + quoted = 'double'; + s = s.slice(1, -1); + } + // ArgumentList "path, other" → first arg only + const comma = s.search(/,(?=(?:[^']*'[^']*')*[^']*$)(?=(?:[^"]*"[^"]*")*[^"]*$)/); + if (comma > 0) s = s.slice(0, comma).trim(); + out.push({ text: s, quoted }); + }; + for (const m of raw.matchAll( + /New-Object\s+(?:-TypeName\s+)?['"]?(?:System\.)?IO\.StreamWriter['"]?\s*\(\s*((?:'[^']*'|"[^"]*"|[^,)])+)/gi + )) { + pushArg(m[1]!); + } + for (const m of raw.matchAll( + /New-Object\s+(?:-TypeName\s+)?['"]?(?:System\.)?IO\.StreamWriter['"]?\s+-ArgumentList\s+((?:'[^']*'|"[^"]*"|[^\s;|&]+))/gi + )) { + pushArg(m[1]!); + } + for (const m of raw.matchAll( + /\[(?:System\.)?IO\.StreamWriter\]::new\s*\(\s*((?:'[^']*'|"[^"]*"|[^,)])+)/gi + )) { + pushArg(m[1]!); + } + return out; +} + /** Raw write-target words of one simple command (after the command word). */ function targetWords(ws: Word[]): Word[] { const out: Word[] = []; @@ -296,12 +412,16 @@ function targetWords(ws: Word[]): Word[] { out.push({ text: a, quoted }); } + // New-Object / [IO.StreamWriter]::new ctor paths are extracted from the raw + // segment in scanCommand (quotes survive there; joined words strip them). + const kind = WRITERS[cmd]; if (!kind) return out; const positional: Word[] = []; let pathFlag: Word | undefined; let destFlag: Word | undefined; let nameFlag: Word | undefined; + let outfileFlag: Word | undefined; for (let k = 0; k < args.length; k++) { const a = args[k]!; if ((a.quoted === null && /^--?[A-Za-z]/.test(a.text)) || /^-[A-Za-z]+:./.test(a.text)) { @@ -319,12 +439,43 @@ function targetWords(ws: Word[]): Word[] { : [a.text.toLowerCase(), undefined]; const take = (): Word | undefined => inline !== undefined && inline !== '' ? { text: inline, quoted: null } : args[++k]; - if (PATH_FLAGS.has(flag)) pathFlag = take(); - else if (DEST_FLAGS.has(flag) && kind === 'dest') destFlag = take(); - else if (flag === '-name' && kind === 'newitem') nameFlag = take(); - else if (VALUE_FLAGS.has(flag) || flag === '-name' || flag === '-destination') { + + // curl -o path / --output path; -O / --remote-name → unresolvable + if (kind === 'curl' && (flag === '-o' || flag === '--output')) { + pathFlag = take(); + continue; + } + if (kind === 'curl' && (a.text === '-O' || flag === '--remote-name')) { + out.push({ text: '-O', quoted: null }); + continue; + } + + const pathMatch = matchFlagPrefix(flag, PATH_FLAGS); + const destMatch = matchFlagPrefix(flag, DEST_FLAGS); + const nameMatch = matchFlagPrefix(flag, ['-name']); + + if (pathMatch === '-outfile' || (kind === 'outfile' && pathMatch)) { + outfileFlag = take(); + continue; + } + if (pathMatch && pathMatch !== '-outfile') { + if (pathMatch === '-newname' && kind === 'dest') destFlag = take(); + else pathFlag = take(); + continue; + } + if (destMatch && (kind === 'dest' || kind === 'first')) { + destFlag = take(); + continue; + } + if (nameMatch && kind === 'newitem') { + nameFlag = take(); + continue; + } + if (isValueFlag(flag) || flag === '-name' || flag === '-destination') { if (inline === undefined) k++; + continue; } + // Unknown flag: do not consume; positional path stays intact. continue; } positional.push(a); @@ -334,7 +485,14 @@ function targetWords(ws: Word[]): Word[] { }; switch (kind) { case 'first': - add(pathFlag ?? positional[0]); + add(pathFlag ?? destFlag ?? positional[0]); + break; + case 'outfile': + add(outfileFlag ?? pathFlag); + break; + case 'curl': + add(pathFlag); + // -O already pushed as unresolved marker above break; case 'all': if (pathFlag) add(pathFlag); @@ -362,11 +520,18 @@ function targetWords(ws: Word[]): Word[] { add(pathFlag); if (destFlag) add(destFlag); else { - const src = pathFlag ? positional : positional.slice(0, -1); - const dest = pathFlag ? positional[0] : positional[positional.length - 1]; - if (positional.length >= (pathFlag ? 1 : 2)) { + // xcopy/robocopy: source dest (dest is 2nd positional); others last positional + const isCopyish = cmd === 'xcopy' || cmd === 'robocopy' || cmd === 'cp' || cmd === 'copy'; + const src = pathFlag ? positional : isCopyish ? positional.slice(0, 1) : positional.slice(0, -1); + const dest = pathFlag + ? positional[0] + : isCopyish + ? positional[1] + : positional[positional.length - 1]; + const need = pathFlag ? 1 : isCopyish ? 2 : 2; + if (positional.length >= need) { add(dest); - if (dest && dest.quoted !== 'single') { + if (dest && dest.quoted !== 'single' && !isCopyish) { for (const s of src) { const b = s.text.split(/[\\/]/).pop(); if (b && !/\$|\*/.test(b)) add({ text: `${dest.text}/${b}`, quoted: dest.quoted }); @@ -387,10 +552,128 @@ function isAbsoluteAny(p: string): boolean { ); } -export function findPwshWriteTargets(command: string, ctx: Ctx): PwshWriteTargets { +/** + * Extract one-level nested command strings for recursive scanning. + * Covers `& {…}` / `. {…}` / `iex '…'` / `Invoke-Expression "…"` / + * `pwsh -c "…"` / `powershell -Command '…'` / `cmd /c "…"`. + * `-EncodedCommand` is handled separately (always block). + */ +function extractNestedCommands(command: string): string[] { + const out: string[] = []; + + // & { body } / . { body } — brace depth, quote-aware + for (const m of command.matchAll(/(?:^|[\s;|&])(?:&|\.)\s*\{/g)) { + const start = m.index! + m[0].length; + let depth = 1; + let q: string | null = null; + let i = start; + for (; i < command.length && depth > 0; i++) { + const c = command[i]!; + if (q) { + if (c === q) q = null; + continue; + } + if (c === '"' || c === "'") { + q = c; + continue; + } + if (c === '{') depth++; + else if (c === '}') depth--; + } + if (depth === 0) out.push(command.slice(start, i - 1)); + } + + // iex / Invoke-Expression: string literal → recurse; anything else → mark opaque + for (const m of command.matchAll( + /(?:^|[\s;|&])(?:iex|Invoke-Expression)\b\s*(?:-Command\s+)?/gi + )) { + const rest = command.slice(m.index! + m[0].length).trimStart(); + const lit = rest.match(/^(['"])([\s\S]*?)\1/); + if (lit) out.push(lit[2]!); + else out.push('__iex_opaque__'); // variable / subexpression — unresolvable + } + + // pwsh/powershell -c/-Command string (not EncodedCommand). Quote-aware so + // a `;` inside the -c body is not treated as a segment break. + for (const m of command.matchAll(/(?:^|[\s;|&])(?:pwsh|powershell)(?:\.exe)?\b/gi)) { + let i = m.index! + m[0].length; + // skip to end of this invocation (quote-aware, stop at unquoted ;|& or newline) + let q: string | null = null; + const start = i; + while (i < command.length) { + const c = command[i]!; + if (q) { + if (c === q) q = null; + i++; + continue; + } + if (c === '"' || c === "'") { + q = c; + i++; + continue; + } + if (c === ';' || c === '\n' || c === '|' || (c === '&' && command[i + 1] !== '&')) break; + i++; + } + const args = command.slice(start, i); + if (/-[Ee]nc(?:odedCommand)?\b/.test(args)) continue; // handled elsewhere + // Find -c / -Command value (prefix match: -c, -com, -command) + const flagRe = /(?:^|\s)(-c(?:ommand)?)(?:\s+|:)/gi; + let fm: RegExpExecArray | null; + let body: string | null = null; + while ((fm = flagRe.exec(args)) !== null) { + // fm[1] is like '-c' or '-Command'; strip leading dashes for prefix check + const name = fm[1]!.toLowerCase().replace(/^-+/, ''); + // unique-prefix: -c matches -Command; -enc is separate and already skipped + if (name !== 'c' && name !== 'command' && !'command'.startsWith(name)) continue; + const after = args.slice(fm.index + fm[0].length); + const lit = after.match(/^(['"])([\s\S]*)\1\s*$/); + if (lit) { + body = lit[2]!; + break; + } + // unquoted single token + const tok = after.match(/^(\S+)/); + if (tok) { + body = tok[1]!; + break; + } + } + if (body) out.push(body); + } + + // cmd /c "body" / cmd /c body + for (const m of command.matchAll( + /(?:^|[\s;|&])cmd(?:\.exe)?\s+(?:\/[CcKk]\s+)(['"])([\s\S]*?)\1/gi + )) { + out.push(m[2]!); + } + + return out.filter((s) => s.trim().length > 0); +} + +/** True when the command line uses -EncodedCommand (always block). */ +function hasEncodedCommand(command: string): boolean { + // PowerShell unique prefix: -EncodedCommand, -EncodedC, -enc, … + // Require at least "-enc" to avoid matching unrelated flags. + return /(?:^|[\s;|&])(?:pwsh|powershell)(?:\.exe)?\b[^;\n|&]*?-[Ee]nc(?:odedCommand)?\b/i.test( + command + ) || /(?:^|[\s;|&])-[Ee]ncodedCommand\b/i.test(command); +} + +function scanCommand( + command: string, + ctx: Ctx, + depth: number +): PwshWriteTargets { const resolved: string[] = []; const unresolved: string[] = []; if (!command || !command.trim()) return { resolved, unresolved }; + + if (hasEncodedCommand(command)) { + unresolved.push('-EncodedCommand'); + } + const assigned = assignedVars(command); let cwd: string | null = ctx.cwd; for (const seg of segments(command)) { @@ -414,8 +697,14 @@ export function findPwshWriteTargets(command: string, ctx: Ctx): PwshWriteTarget } continue; } - for (const t of targetWords(ws)) { + const targets = [...targetWords(ws), ...extractStreamWriterPaths(seg)]; + for (const t of targets) { if (!t.text || NULL_TARGETS.test(t.text)) continue; + // curl -O marker + if (t.text === '-O' || t.text === '--remote-name') { + unresolved.push(t.text); + continue; + } const e = expand(t, ctx, assigned); if (e === null) { unresolved.push(t.text); @@ -427,5 +716,26 @@ export function findPwshWriteTargets(command: string, ctx: Ctx): PwshWriteTarget else resolved.push(path.resolve(cwd, n)); } } + + // One-level nested execution: apply the SAME rules (abbreviations, parens, + // same-line assignments) to the inner string. Deeper nesting → O148. + if (depth < 1) { + for (const inner of extractNestedCommands(command)) { + if (inner === '__iex_opaque__') { + unresolved.push('Invoke-Expression'); + continue; + } + // Inner assigned-vars are computed from the inner string itself, so + // same-line assignments inside the nested body are respected. + const nested = scanCommand(inner, ctx, depth + 1); + resolved.push(...nested.resolved); + unresolved.push(...nested.unresolved); + } + } + return { resolved, unresolved }; } + +export function findPwshWriteTargets(command: string, ctx: Ctx): PwshWriteTargets { + return scanCommand(command, ctx, 0); +} diff --git a/test/o130-dollar-control.spec.mjs b/test/aa-o130-dollar-control.spec.mjs similarity index 100% rename from test/o130-dollar-control.spec.mjs rename to test/aa-o130-dollar-control.spec.mjs diff --git a/test/o130-process-block.spec.mjs b/test/aa-o130-process-block.spec.mjs similarity index 100% rename from test/o130-process-block.spec.mjs rename to test/aa-o130-process-block.spec.mjs diff --git a/test/o130-pwsh-write-targets.spec.mjs b/test/aa-o130-pwsh-write-targets.spec.mjs similarity index 77% rename from test/o130-pwsh-write-targets.spec.mjs rename to test/aa-o130-pwsh-write-targets.spec.mjs index e926cd89..a4285588 100644 --- a/test/o130-pwsh-write-targets.spec.mjs +++ b/test/aa-o130-pwsh-write-targets.spec.mjs @@ -104,6 +104,21 @@ const LITERAL = [ `cp evil.json '${CFG}'`, `mv evil.json '${CRED}'`, `cp -t '${custom}' credentials`, + // O130 QA BLOCK: additional writers + `Set-Item -Path '${CFG}' x`, + `Set-Item '${CFG}' x`, + `'x' | Export-Clixml '${CFG}'`, + `'x' | Export-Csv -Path '${CRED}'`, + `Export-Csv -Path '${CFG}' -InputObject x`, + `Invoke-WebRequest http://example.invalid -OutFile '${CFG}'`, + `iwr http://example.invalid -OutFile '${CRED}'`, + `curl.exe -o '${CFG}' http://example.invalid`, + `curl --output '${CRED}' http://example.invalid`, + `xcopy evil.json '${CFG}'`, + `robocopy . '${custom}' config.json`, + `New-Object IO.StreamWriter('${CFG}')`, + `$w = New-Object IO.StreamWriter('${CRED}'); $w.Close()`, + `New-Object -TypeName System.IO.StreamWriter -ArgumentList '${CFG}'`, // mixed separators / case `Set-Content '${CFG.replace(/\//g, '\\')}' x`, `Set-Content '${CFG.replace(/\/moss\//, '\\moss/')}' x`, @@ -212,6 +227,56 @@ const ALLOW = [ ]; for (const cmd of ALLOW) test(`O130 does not gate: ${cmd}`, () => clean(cmd)); + +// ---- 4b. QA BLOCK: value-flag abbreviations must not steal the path slot ---- +const ABBREV = [ + `Set-Content -En utf8 '${CFG}' x`, + `Set-Content -Enc utf8 '${CFG}' x`, + `Set-Content -Va x '${CFG}'`, + `Add-Content -Va x '${CCFG}'`, + `Out-File -Enc utf8 '${CFG}'`, + `'x' | Out-File -Fi '${CRED}'`, + `Set-Content -Encoding utf8 '${CFG}' x`, // full name still works +]; +for (const cmd of ABBREV) test(`O130 value-flag abbrev gated: ${cmd}`, () => protectedHit(cmd)); + +// ---- 4c. QA BLOCK: parenthesized expression targets → unresolvable block ---- +const PAREN = [ + `Set-Content (Join-Path $env:APPDATA 'moss\\config.json') x`, + `Set-Content -Path ('${cfg}'+'/config.json') x`, + `Set-Content (Join-Path '${appdata}' 'moss\\credentials') x`, +]; +for (const cmd of PAREN) test(`O130 paren target gated: ${cmd}`, () => unresolvedHit(cmd)); + +// ---- 4d. QA BLOCK: one-level nested execution (same rules inside) ---- +const NESTED = [ + `& { Set-Content '${CFG}' x }`, + `. { Set-Content -Path '${CFG}' x }`, + `iex "Set-Content '${CFG}' x"`, + `Invoke-Expression "Set-Content '${CRED}' x"`, + `pwsh -c "Set-Content '${CFG}' x"`, + `pwsh -Command "Set-Content '${CFG}' x"`, + // same rules inside: abbrev / paren / same-line assign + `& { Set-Content -En utf8 '${CFG}' x }`, + `& { Set-Content -Va x '${CFG}' }`, + `& { Set-Content (Join-Path $env:APPDATA 'moss\\config.json') x }`, + `& { $env:APPDATA='C:\\x'; Set-Content "$env:APPDATA\\moss\\config.json" x }`, + `pwsh -c "Set-Content -En utf8 '${CFG}' x"`, + `pwsh -c '$env:APPDATA="C:\\x"; Set-Content "$env:APPDATA\\moss\\config.json" x'`, + `cmd /c "echo x > ${CFG.replace(/\//g, '\\\\')}"`, +]; +for (const cmd of NESTED) test(`O130 nested one-level gated: ${cmd}`, () => blocked(cmd, 'nested')); + +test('O130 -EncodedCommand always blocks (regardless of payload)', () => { + unresolvedHit('pwsh -EncodedCommand UwBlAHQALQBDAG8AbgB0AGUAbgB0AA=='); + unresolvedHit('powershell.exe -EncodedCommand ABCDEF=='); +}); + +test('O130 iex with variable argument is opaque → blocked', () => { + unresolvedHit(`$c="Set-Content '${CFG}' x"; Invoke-Expression $c`); + unresolvedHit(`$c="Set-Content '${CFG}' x"; iex $c`); +}); + // ---- 5. copy (UX): reason + next step, zh and en ---- test('O130 unresolvable copy names the variable, why, and the next step (zh/en)', () => { const [h] = hits('Set-Content -Path $env:NOT_SET_O130\\config.json -Value x'); diff --git a/test/o130-win-config-protect.spec.mjs b/test/aa-o130-win-config-protect.spec.mjs similarity index 100% rename from test/o130-win-config-protect.spec.mjs rename to test/aa-o130-win-config-protect.spec.mjs From 7211ab6d4c8a8ca32f537977b8a5edf9f3ad2e35 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 14:21:51 +0800 Subject: [PATCH 4/5] style: prettier on O130 pwsh-write-targets after main merge --- src/safety/pwsh-write-targets.ts | 30 ++++++++++-------------- test/aa-o130-pwsh-write-targets.spec.mjs | 1 - 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/safety/pwsh-write-targets.ts b/src/safety/pwsh-write-targets.ts index 28206e27..ee444790 100644 --- a/src/safety/pwsh-write-targets.ts +++ b/src/safety/pwsh-write-targets.ts @@ -249,15 +249,7 @@ const VALUE_FLAGS = [ '-argumentlist', '-args', ]; -const PATH_FLAGS = [ - '-path', - '-literalpath', - '-filepath', - '-pspath', - '-lp', - '-outfile', - '-newname', -]; +const PATH_FLAGS = ['-path', '-literalpath', '-filepath', '-pspath', '-lp', '-outfile', '-newname']; const DEST_FLAGS = ['-destination', '-target', '--target-directory', '-t']; /** PowerShell unique-prefix match against a list of full flag names. */ @@ -522,7 +514,11 @@ function targetWords(ws: Word[]): Word[] { else { // xcopy/robocopy: source dest (dest is 2nd positional); others last positional const isCopyish = cmd === 'xcopy' || cmd === 'robocopy' || cmd === 'cp' || cmd === 'copy'; - const src = pathFlag ? positional : isCopyish ? positional.slice(0, 1) : positional.slice(0, -1); + const src = pathFlag + ? positional + : isCopyish + ? positional.slice(0, 1) + : positional.slice(0, -1); const dest = pathFlag ? positional[0] : isCopyish @@ -656,16 +652,14 @@ function extractNestedCommands(command: string): string[] { function hasEncodedCommand(command: string): boolean { // PowerShell unique prefix: -EncodedCommand, -EncodedC, -enc, … // Require at least "-enc" to avoid matching unrelated flags. - return /(?:^|[\s;|&])(?:pwsh|powershell)(?:\.exe)?\b[^;\n|&]*?-[Ee]nc(?:odedCommand)?\b/i.test( - command - ) || /(?:^|[\s;|&])-[Ee]ncodedCommand\b/i.test(command); + return ( + /(?:^|[\s;|&])(?:pwsh|powershell)(?:\.exe)?\b[^;\n|&]*?-[Ee]nc(?:odedCommand)?\b/i.test( + command + ) || /(?:^|[\s;|&])-[Ee]ncodedCommand\b/i.test(command) + ); } -function scanCommand( - command: string, - ctx: Ctx, - depth: number -): PwshWriteTargets { +function scanCommand(command: string, ctx: Ctx, depth: number): PwshWriteTargets { const resolved: string[] = []; const unresolved: string[] = []; if (!command || !command.trim()) return { resolved, unresolved }; diff --git a/test/aa-o130-pwsh-write-targets.spec.mjs b/test/aa-o130-pwsh-write-targets.spec.mjs index a4285588..3ee219f1 100644 --- a/test/aa-o130-pwsh-write-targets.spec.mjs +++ b/test/aa-o130-pwsh-write-targets.spec.mjs @@ -227,7 +227,6 @@ const ALLOW = [ ]; for (const cmd of ALLOW) test(`O130 does not gate: ${cmd}`, () => clean(cmd)); - // ---- 4b. QA BLOCK: value-flag abbreviations must not steal the path slot ---- const ABBREV = [ `Set-Content -En utf8 '${CFG}' x`, From 0689ce7d575f88e2e578c9e8cd416dae7dafeaee Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 14:37:09 +0800 Subject: [PATCH 5/5] fix(O130): block EncodedCommand unique prefixes (-EncodedC/-ec/-e) hasEncodedCommand only matched -enc or the full -EncodedCommand name, so mid-prefixes (-EncodedC, -EncodedCom) and documented aliases (-ec, -en, -e) passed. Match by unique-prefix of EncodedCommand (VALUE_FLAGS style) plus -ec; tests cover mid-prefixes and go red when the old regex is restored. --- src/safety/pwsh-write-targets.ts | 40 +++++++++++++++++++----- test/aa-o130-pwsh-write-targets.spec.mjs | 19 +++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/safety/pwsh-write-targets.ts b/src/safety/pwsh-write-targets.ts index ee444790..38eea6e8 100644 --- a/src/safety/pwsh-write-targets.ts +++ b/src/safety/pwsh-write-targets.ts @@ -612,7 +612,7 @@ function extractNestedCommands(command: string): string[] { i++; } const args = command.slice(start, i); - if (/-[Ee]nc(?:odedCommand)?\b/.test(args)) continue; // handled elsewhere + if (argsHasEncodedCommandFlag(args)) continue; // handled elsewhere // Find -c / -Command value (prefix match: -c, -com, -command) const flagRe = /(?:^|\s)(-c(?:ommand)?)(?:\s+|:)/gi; let fm: RegExpExecArray | null; @@ -648,15 +648,39 @@ function extractNestedCommands(command: string): string[] { return out.filter((s) => s.trim().length > 0); } +/** + * PowerShell host `-EncodedCommand` unique-prefix / alias match (VALUE_FLAGS style). + * Matches any unique prefix of EncodedCommand (`-e`, `-en`, `-enc`, `-EncodedC`, …) + * plus the documented short alias `-ec`. Fail-closed on host invocations: an + * ambiguous `-e` (also a prefix of `-ExecutionPolicy`) still blocks. + */ +function isEncodedCommandFlag(flag: string): boolean { + const lower = flag.toLowerCase(); + if (lower === '-ec') return true; // documented pwsh/powershell alias + if (lower === '-encodedcommand') return true; + // Unique-prefix of -EncodedCommand (length >= 2 so `-e` counts; `-ex` does not + // match because EncodedCommand diverges at the 3rd character). + return lower.length >= 2 && '-encodedcommand'.startsWith(lower); +} + +/** True when `args` (the token stream after pwsh/powershell) has -EncodedCommand. */ +function argsHasEncodedCommandFlag(args: string): boolean { + const flagRe = /(?:^|\s)(-[A-Za-z][\w-]*)\b/g; + let m: RegExpExecArray | null; + while ((m = flagRe.exec(args)) !== null) { + if (isEncodedCommandFlag(m[1]!)) return true; + } + return false; +} + /** True when the command line uses -EncodedCommand (always block). */ function hasEncodedCommand(command: string): boolean { - // PowerShell unique prefix: -EncodedCommand, -EncodedC, -enc, … - // Require at least "-enc" to avoid matching unrelated flags. - return ( - /(?:^|[\s;|&])(?:pwsh|powershell)(?:\.exe)?\b[^;\n|&]*?-[Ee]nc(?:odedCommand)?\b/i.test( - command - ) || /(?:^|[\s;|&])-[Ee]ncodedCommand\b/i.test(command) - ); + // pwsh/powershell … + for (const m of command.matchAll(/(?:^|[\s;|&])(?:pwsh|powershell)(?:\.exe)?\b([^;\n|&]*)/gi)) { + if (argsHasEncodedCommandFlag(m[1]!)) return true; + } + // Bare -EncodedCommand full name (kept for non-host forms / legacy callers) + return /(?:^|[\s;|&])-EncodedCommand\b/i.test(command); } function scanCommand(command: string, ctx: Ctx, depth: number): PwshWriteTargets { diff --git a/test/aa-o130-pwsh-write-targets.spec.mjs b/test/aa-o130-pwsh-write-targets.spec.mjs index 3ee219f1..c56b59f6 100644 --- a/test/aa-o130-pwsh-write-targets.spec.mjs +++ b/test/aa-o130-pwsh-write-targets.spec.mjs @@ -271,6 +271,25 @@ test('O130 -EncodedCommand always blocks (regardless of payload)', () => { unresolvedHit('powershell.exe -EncodedCommand ABCDEF=='); }); +// Mid-prefixes + documented aliases must also block (QA r2 §2.1). Unique-prefix +// of EncodedCommand: -e / -en / -enc / -EncodedC / -EncodedCom / …; plus -ec. +test('O130 -EncodedCommand unique-prefix and -ec aliases always block', () => { + for (const cmd of [ + 'pwsh -EncodedC AAA', + 'pwsh -EncodedCom AAA', + 'pwsh -EncodedComm AAA', + 'pwsh -enc AAA', + 'pwsh -en AAA', + 'pwsh -e AAA', + 'pwsh -ec AAA', + 'powershell -ec AAA', + 'powershell.exe -EncodedC AAA', + 'pwsh.exe -Enco AAA', + ]) { + unresolvedHit(cmd); + } +}); + test('O130 iex with variable argument is opaque → blocked', () => { unresolvedHit(`$c="Set-Content '${CFG}' x"; Invoke-Expression $c`); unresolvedHit(`$c="Set-Content '${CFG}' x"; iex $c`);