From 47ca28d73cb0b1ec04122c190b44bf5f1c92b073 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 10:16:03 +0800 Subject: [PATCH 1/8] fix(safety): O117 L3 .git/config allow-list (fail-closed), watch config.worktree, name keys in last line --- src/cli/run-completion.ts | 18 +- src/cli/run-outcome.ts | 7 +- src/core/tools/background-process-registry.ts | 9 +- src/safety/protected-path-snapshot.ts | 294 ++++++++- src/tools/background-exec.ts | 7 +- test/o117-git-config-l3-precise.spec.mjs | 595 ++++++++++++++++++ 6 files changed, 911 insertions(+), 19 deletions(-) create mode 100644 test/o117-git-config-l3-precise.spec.mjs diff --git a/src/cli/run-completion.ts b/src/cli/run-completion.ts index a4a71c3b..ccc3d5d2 100644 --- a/src/cli/run-completion.ts +++ b/src/cli/run-completion.ts @@ -65,7 +65,13 @@ export interface RunCompletionTracker { * 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 }>; + protectedPathChanges?: Array<{ + path: string; + kind: string; + command?: string; + keys?: string[]; + reason?: string; + }>; /** * O103: protected-path writes the approval gate refused before they ran * (`-p` deny or user said no). Drives the outcome on its own — a run with a @@ -453,7 +459,7 @@ export function noteBackgroundProtectedPathMods(tracker: RunCompletionTracker): if (!changes || changes.length === 0) continue; mergeProtectedPathChanges( tracker, - changes.map((c) => ({ path: c.path, kind: c.kind, command: snap.command })), + changes.map((c) => ({ ...c, keys: c.keys ? [...c.keys] : undefined, command: snap.command })), `exec_background: ${snap.command.replace(/\s+/g, ' ').trim().slice(0, 120)}` ); } @@ -461,7 +467,13 @@ export function noteBackgroundProtectedPathMods(tracker: RunCompletionTracker): function mergeProtectedPathChanges( tracker: RunCompletionTracker, - additions: Array<{ path: string; kind: string; command?: string }>, + additions: Array<{ + path: string; + kind: string; + command?: string; + keys?: string[]; + reason?: string; + }>, failedStep?: string ): void { if (additions.length === 0) return; diff --git a/src/cli/run-outcome.ts b/src/cli/run-outcome.ts index 21fe3003..ba4d4250 100644 --- a/src/cli/run-outcome.ts +++ b/src/cli/run-outcome.ts @@ -28,7 +28,10 @@ import { type SafetyBlock, type VerificationCheck, } from './run-completion.js'; -import { protectedPathModReason } from '../safety/protected-path-snapshot.js'; +import { + protectedPathModReason, + type GitConfigChangeReason, +} from '../safety/protected-path-snapshot.js'; import { hookEventZhName } from './hook-event-names.js'; export type { GoalVerifyResult, RunCompletionTracker, VerificationCheck }; @@ -177,6 +180,8 @@ export function resolveRunOutcome(input: ResolveRunOutcomeInput): RunOutcome { mods.map((m) => ({ path: m.path, kind: (m.kind as 'added' | 'modified' | 'deleted') || 'modified', + ...(m.keys ? { keys: m.keys } : {}), + ...(m.reason ? { reason: m.reason as GitConfigChangeReason } : {}), })), mods[0]?.command ); diff --git a/src/core/tools/background-process-registry.ts b/src/core/tools/background-process-registry.ts index f5d7726b..bf7a07ac 100644 --- a/src/core/tools/background-process-registry.ts +++ b/src/core/tools/background-process-registry.ts @@ -47,7 +47,12 @@ export interface BackgroundProcSnapshot { * O108: protected-path changes detected by layer-3 snapshot when this * process fully exited (metadata only — never file contents). */ - protectedPathChanges?: ReadonlyArray<{ path: string; kind: string }>; + protectedPathChanges?: ReadonlyArray<{ + path: string; + kind: string; + keys?: string[]; + reason?: string; + }>; } export interface BackgroundOutputChunk { @@ -80,7 +85,7 @@ export interface BackgroundProc { * O108: set on full exit when layer-3 snapshot detects protected-path * modifications (paths + kind only). */ - protectedPathChanges?: Array<{ path: string; kind: string }>; + protectedPathChanges?: Array<{ path: string; kind: string; keys?: string[]; reason?: string }>; killTimer?: ReturnType; killRequested?: boolean; diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts index 476a60ad..0b32bf4c 100644 --- a/src/safety/protected-path-snapshot.ts +++ b/src/safety/protected-path-snapshot.ts @@ -9,7 +9,10 @@ * 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 + * config, HEAD, refs/**, hooks/** (including newly added hooks). For + * `.git/config` content edits, changes confined to allow-listed benign keys + * are not reported (O117, GIT_CONFIG_BENIGN_KEYS); any other key counts; add/delete/symlink swap or unparsable + * content still reports the whole file (fail-closed). Normal * git commit writes (index, objects, logs, ORIG_HEAD, …) are ignored. * * Snapshot unavailable (permission) → skip that path; never false-report @@ -17,6 +20,7 @@ * (O108). Delayed writes via `&` / `nohup` inside a foreground command, and * writes after the Moss process itself exits, remain known limits. */ +import { spawnSync } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; @@ -28,14 +32,30 @@ export interface ProtectedPathChange { /** Display path (~/.ssh/… or relative to cwd). */ path: string; kind: PathChangeKind; + /** + * O117: for `.git/config` content changes, the non-allow-listed keys whose + * values changed (names only, never values). Absent = whole-file report. + */ + keys?: string[]; + /** + * O117: why a git config file is reported without keys (fail-closed): + * replaced by a symlink / directory, or content git cannot parse. + */ + reason?: GitConfigChangeReason; } +export type GitConfigChangeReason = 'symlink' | 'dir' | 'unparsable'; + /** Internal meta — never log `hash` in full. */ interface PathMeta { mtimeMs: number; size: number; /** sha256 hex; compare only. */ hash: string; + /** O117: raw bytes of git config files only (in memory, never logged). */ + raw?: Buffer; + /** O117: entry type, so symlink/dir swaps of a config file can be named. */ + type?: 'file' | 'symlink' | 'dir'; } /** Sentinel: path exists but meta could not be read (permission). */ @@ -55,6 +75,8 @@ export interface ProtectedPathSnapshot { /** Machine-readable marker for run-completion tracker (no secrets). */ export const PROTECTED_PATH_MOD_MARKER = '[moss-o101-protected-path-modified]'; +/** O117: per-path detail (path, kind, reason, key names) for git config files. */ +export const PROTECTED_PATH_DETAIL_MARKER = '[moss-o117-protected-path-detail]'; function homeDir(home?: string): string { return home || process.env.HOME || process.env.USERPROFILE || os.homedir(); @@ -70,6 +92,162 @@ function displayFor(abs: string, cwd: string, home: string): string { return abs.replace(/\\/g, '/'); } +/** + * O117: git config files whose content is diffed per key: `.git/config`, + * `.git/config.worktree`, `.git/worktrees//config.worktree`. + */ +export function isGitConfigPath(abs: string): boolean { + return /(^|[\\/])\.git[\\/](?:config|config\.worktree|worktrees[\\/][^\\/]+[\\/]config\.worktree)$/.test( + abs + ); +} + +/** + * O117 (PM, allow-list): the only `.git/config` keys whose changes are NOT + * reported. Every other key change — including ones never seen before — is + * reported as "已被修改" (fail-closed). Kept deliberately small: nothing that + * can run a program, pull in other config, or change where code is fetched + * from (so `remote.*.url/fetch`, `branch.*.remote/merge` are NOT listed). + * + * Matching is on the normalised key: section and variable name are + * case-insensitive, the subsection keeps git's case-sensitive spelling. + */ +export const GIT_CONFIG_BENIGN_KEYS: readonly string[] = [ + 'user.name', + 'user.email', + 'color.ui', + 'init.defaultBranch', + 'pull.rebase', + 'core.autocrlf', +]; + +/** Lower-case section + variable name; keep subsection as git does. */ +export function normalizeGitConfigKey(key: string): string { + const first = key.indexOf('.'); + const last = key.lastIndexOf('.'); + if (first === -1) return key.toLowerCase(); + if (first === last) return key.toLowerCase(); + return `${key.slice(0, first).toLowerCase()}${key.slice(first, last)}${key.slice(last).toLowerCase()}`; +} + +export function isBenignGitConfigKey(key: string): boolean { + // Exact two-part keys only (no wildcards, no subsections): QA gate. + const k = normalizeGitConfigKey(key); + if (k.indexOf('.') !== k.lastIndexOf('.')) return false; + return GIT_CONFIG_BENIGN_KEYS.some((e) => e.toLowerCase() === k); +} + +/** + * Parse git config bytes with git itself (`git config --file - --list -z`, + * no includes followed, no system/global config). Returns key→values, or + * null on any failure (fail-closed: caller reports the whole file). + */ +export function parseGitConfigEntries(raw: Buffer): Map | null { + let tmpDir: string | undefined; + try { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o117-')); + const file = path.join(tmpDir, 'config'); + fs.writeFileSync(file, raw, { mode: 0o600 }); + const res = spawnSync('git', ['config', '--file', file, '--no-includes', '--list', '-z'], { + cwd: tmpDir, + encoding: 'buffer', + timeout: 5000, + windowsHide: true, + env: { + PATH: process.env.PATH ?? '', + SystemRoot: process.env.SystemRoot ?? '', + HOME: tmpDir, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: os.devNull, + LC_ALL: 'C', + }, + }); + if (res.error || res.status !== 0 || !res.stdout) return null; + const out = new Map(); + for (const rec of res.stdout.toString('utf8').split('\0')) { + if (!rec) continue; + const nl = rec.indexOf('\n'); + const key = normalizeGitConfigKey(nl === -1 ? rec : rec.slice(0, nl)); + const value = nl === -1 ? '\u0000(implicit-true)' : rec.slice(nl + 1); + const list = out.get(key) ?? []; + list.push(value); + out.set(key, list); + } + return out; + } catch { + return null; + } finally { + if (tmpDir) { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + } +} + +/** + * O117: which non-allow-listed keys differ between two `.git/config` + * contents. Returns [] when only allow-listed keys changed, or null when either side cannot + * be parsed (fail-closed → report whole file). + */ +export function changedUnlistedGitConfigKeys(before: Buffer, after: Buffer): string[] | null { + const b = parseGitConfigEntries(before); + const a = parseGitConfigEntries(after); + if (!b || !a) return null; + const changed: string[] = []; + for (const key of new Set([...b.keys(), ...a.keys()])) { + if (isBenignGitConfigKey(key)) continue; + if (JSON.stringify(b.get(key) ?? []) !== JSON.stringify(a.get(key) ?? [])) changed.push(key); + } + changed.sort(); + const text = `${after.toString('utf8')}\n${before.toString('utf8')}`; + return changed.map((k) => displayGitConfigKey(k, text)); +} + +function escapeRe(x: string): string { + return x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * O117 (UX): show a key with the spelling the file used (e.g. + * `core.hooksPath`), since git itself lower-cases section + name. Falls back + * to the normalised form. Names only, never values. + */ +export function displayGitConfigKey(key: string, text: string): string { + const first = key.indexOf('.'); + const last = key.lastIndexOf('.'); + if (first === -1) return key; + const section = key.slice(0, first); + const middle = key.slice(first, last + 1); + const name = key.slice(last + 1); + const secM = text.match(new RegExp(`^\\s*\\[\\s*(${escapeRe(section)})(?=[\\s\\]".])`, 'im')); + const nameM = text.match(new RegExp(`^\\s*(${escapeRe(name)})\\s*(?:=|$|[#;])`, 'im')); + return `${secM ? secM[1] : section}${middle}${nameM ? nameM[1] : name}`; +} + +/** + * O117: classify a git config file change. Only keys outside + * GIT_CONFIG_BENIGN_KEYS count; symlink/dir swap or unparsable content is + * reported with a reason (fail-closed). Returns undefined = not reported. + */ +function gitConfigChange( + display: string, + kind: PathChangeKind, + b: PathMeta | undefined, + a: PathMeta +): ProtectedPathChange | undefined { + if (a.type === 'symlink') return { path: display, kind, reason: 'symlink' }; + if (a.type === 'dir') return { path: display, kind, reason: 'dir' }; + const before = b ? b.raw : Buffer.alloc(0); + if (!before || !a.raw) return { path: display, kind, reason: 'unparsable' }; + const keys = changedUnlistedGitConfigKeys(before, a.raw); + if (keys === null) return { path: display, kind, reason: 'unparsable' }; + if (keys.length === 0) return undefined; + return { path: display, kind, keys }; +} + /** PathMeta | UNAVAILABLE | null(ENOENT). */ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { try { @@ -79,6 +257,7 @@ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { mtimeMs: st.mtimeMs, size: -1, hash: crypto.createHash('sha256').update(`dir:${st.mtimeMs}`).digest('hex'), + type: 'dir', }; } if (st.isSymbolicLink()) { @@ -87,6 +266,7 @@ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { mtimeMs: st.mtimeMs, size: target.length, hash: crypto.createHash('sha256').update(`link:${target}`).digest('hex'), + type: 'symlink', }; } const buf = fs.readFileSync(abs); @@ -94,6 +274,8 @@ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { mtimeMs: st.mtimeMs, size: st.size, hash: crypto.createHash('sha256').update(buf).digest('hex'), + type: 'file', + ...(isGitConfigPath(abs) ? { raw: buf } : {}), }; } catch (err) { const code = (err as NodeJS.ErrnoException)?.code; @@ -128,7 +310,8 @@ function walkFiles(dir: string, out: string[], depth = 0): void { /** * Collect absolute paths we watch under cwd + home. - * `.git` only: config, HEAD, refs/**, hooks/**. + * `.git` only: config, config.worktree, worktrees//config.worktree, HEAD, + * refs/**, hooks/**. */ export function listWatchedProtectedPaths(cwd: string, home?: string): string[] { const h = homeDir(home); @@ -147,9 +330,14 @@ export function listWatchedProtectedPaths(cwd: string, home?: string): string[] } const git = path.join(ws, '.git'); - for (const name of ['config', 'HEAD']) { + for (const name of ['config', 'config.worktree', 'HEAD']) { paths.push(path.join(git, name)); } + // O117: per-worktree config (extensions.worktreeConfig) can set + // core.hooksPath etc. just like .git/config. + for (const name of listDirSafe(path.join(git, 'worktrees'))) { + paths.push(path.join(git, 'worktrees', name, 'config.worktree')); + } const hooks = path.join(git, 'hooks'); paths.push(hooks); for (const name of listDirSafe(hooks)) { @@ -238,6 +426,11 @@ export function diffProtectedPathSnapshots( 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) { + if (isGitConfigPath(abs)) { + const c = gitConfigChange(display, 'added', undefined, a); + if (c) changes.push(c); + continue; + } changes.push({ path: display, kind: 'added' }); continue; } @@ -246,7 +439,12 @@ export function diffProtectedPathSnapshots( continue; } if (bHas && aHas && b && a) { - if (b.hash !== a.hash || b.size !== a.size) { + if (b.hash !== a.hash || b.size !== a.size || b.type !== a.type) { + if (isGitConfigPath(abs)) { + const c = gitConfigChange(display, 'modified', b, a); + if (c) changes.push(c); + continue; + } changes.push({ path: display, kind: 'modified' }); } } @@ -316,13 +514,31 @@ export function formatProtectedPathModifiedNotice( 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]})`) + .map((c) => { + const detail = describeGitConfigChange(c, zh); + if (detail) return detail; + return `${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 detailLines = changes + .filter((c) => describeGitConfigChange(c, true)) + .map( + (c) => + `${PROTECTED_PATH_DETAIL_MARKER} ${[ + c.path, + c.kind, + c.reason ?? '', + (c.keys ?? []).join(','), + ] + .map((x) => x.replace(/[\t\n]/g, ' ')) + .join('\t')}` + ); const lines = zh ? [ `${PROTECTED_PATH_MOD_MARKER} paths=${markerPaths}`, + ...detailLines, `[moss] 受保护路径已被修改(命令已执行,Moss 没有拦截也没有自动还原):${list}`, cmd ? `命令:${cmd}` : '', `请自行检查。可复制:`, @@ -330,6 +546,7 @@ export function formatProtectedPathModifiedNotice( ] : [ `${PROTECTED_PATH_MOD_MARKER} paths=${markerPaths}`, + ...detailLines, `[moss] Protected paths were modified (command already ran; Moss did not intercept or restore): ${list}`, cmd ? `command: ${cmd}` : '', `Inspect yourself. Copyable:`, @@ -343,11 +560,57 @@ export function parseProtectedPathModMarker(text: string): ProtectedPathChange[] 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' }]; + const details = new Map(); + for (const line of text.split('\n')) { + if (!line.startsWith(PROTECTED_PATH_DETAIL_MARKER)) continue; + const [p, kind, reason, keys] = line.slice(PROTECTED_PATH_DETAIL_MARKER.length + 1).split('\t'); + if (!p) continue; + const k = (['added', 'modified', 'deleted'] as const).find((x) => x === kind) ?? 'modified'; + const r = (['symlink', 'dir', 'unparsable'] as const).find((x) => x === reason); + const ks = (keys ?? '').split(',').filter(Boolean); + details.set(p, { + path: p, + kind: k, + ...(r ? { reason: r } : {}), + ...(ks.length ? { keys: ks } : {}), + }); + } return m[1]! .split(',') .map((p) => p.trim()) .filter(Boolean) - .map((p) => ({ path: p, kind: 'modified' as const })); + .map((p) => details.get(p) ?? { path: p, kind: 'modified' as const }); +} + +const KEYS_SHOWN = 3; + +/** + * O117 (UX): a git config file change in one phrase, e.g. + * 「.git/config 改了 `core.hooksPath`(不在安全清单内)」. Undefined for other + * paths / plain changes. + */ +export function describeGitConfigChange(c: ProtectedPathChange, zh: boolean): string | undefined { + if (c.kind === 'deleted' && isGitConfigPath(c.path)) { + return zh ? `${c.path} 被删除` : `${c.path} was deleted`; + } + if (c.reason === 'symlink') { + return zh ? `${c.path} 被换成软链` : `${c.path} was replaced by a symlink`; + } + if (c.reason === 'dir') { + return zh ? `${c.path} 被换成目录` : `${c.path} was replaced by a directory`; + } + if (c.reason === 'unparsable') { + return zh ? `${c.path} 无法解析` : `${c.path} could not be parsed`; + } + if (!c.keys?.length) return undefined; + const shown = c.keys.slice(0, KEYS_SHOWN).map((k) => `\`${k}\``); + const extra = c.keys.length > KEYS_SHOWN; + if (zh) { + const list = shown.join('、') + (extra ? ` 等 ${c.keys.length} 个` : ''); + return `${c.path} 改了 ${list}(不在安全清单内)`; + } + const list = shown.join(', ') + (extra ? ` and ${c.keys.length - KEYS_SHOWN} more` : ''); + return `${c.path} changed ${list} (not on the safe list)`; } /** Outcome reason strings (O98). */ @@ -355,16 +618,23 @@ export function protectedPathModReason( changes: readonly ProtectedPathChange[], command?: string ): { en: string; zh: string; stuckEn: string; stuckZh: string } { - const paths = changes + const plain = changes.filter((c) => !describeGitConfigChange(c, true)); + const detailZh = changes.map((c) => describeGitConfigChange(c, true)).filter(Boolean); + const detailEn = changes.map((c) => describeGitConfigChange(c, false)).filter(Boolean); + const paths = plain .map((c) => c.path) .slice(0, 5) .join(', '); - const more = changes.length > 5 ? ` (+${changes.length - 5})` : ''; + const more = plain.length > 5 ? ` (+${plain.length - 5})` : ''; const cmd = (command ?? 'shell').replace(/\s+/g, ' ').trim().slice(0, 60); + const stuckEn = [paths ? `protected path modified: ${paths}${more}` : '', ...detailEn]; + const stuckZh = [paths ? `受保护路径已被修改:${paths}${more}` : '', ...detailZh]; + const en = [paths ? `${cmd} modified protected path(s) ${paths}${more}` : cmd, ...detailEn]; + const zh = [paths ? `${cmd} 修改了受保护路径 ${paths}${more}` : cmd, ...detailZh]; return { - en: `${cmd} modified protected path(s) ${paths}${more}`, - zh: `${cmd} 修改了受保护路径 ${paths}${more}`, - stuckEn: `protected path modified: ${paths}${more}`, - stuckZh: `受保护路径已被修改:${paths}${more}`, + en: paths ? en.join('; ') : `${cmd}: protected path modified, ${detailEn.join('; ')}`, + zh: paths ? zh.join(';') : `${cmd}:受保护路径已被修改,${detailZh.join(';')}`, + stuckEn: stuckEn.filter(Boolean).join('; '), + stuckZh: stuckZh.filter(Boolean).join(';'), }; } diff --git a/src/tools/background-exec.ts b/src/tools/background-exec.ts index 2a3cfce9..10073c20 100644 --- a/src/tools/background-exec.ts +++ b/src/tools/background-exec.ts @@ -157,7 +157,12 @@ export const execBackgroundTool: Tool = { diffProtectedPathSnapshots(protectedSnapBefore, after) ); if (changes.length === 0) return; - proc.protectedPathChanges = changes.map((c) => ({ path: c.path, kind: c.kind })); + proc.protectedPathChanges = changes.map((c) => ({ + path: c.path, + kind: c.kind, + ...(c.keys ? { keys: c.keys } : {}), + ...(c.reason ? { reason: c.reason } : {}), + })); const notice = formatProtectedPathModifiedNotice(changes, preferZhLocale(), command); if (notice) appendOutput(proc, 'stderr', `\n${notice}\n`); } catch { diff --git a/test/o117-git-config-l3-precise.spec.mjs b/test/o117-git-config-l3-precise.spec.mjs new file mode 100644 index 00000000..16b2e938 --- /dev/null +++ b/test/o117-git-config-l3-precise.spec.mjs @@ -0,0 +1,595 @@ +#!/usr/bin/env node +/** + * O117 — L3 (post-run snapshot) must not treat every `.git/config` write as a + * protected-path modification. Allow-list (PM): only changes confined to + * GIT_CONFIG_BENIGN_KEYS (user.name, color.ui, …) are silent; any other key counts; unparsable / swapped / deleted config is + * still reported (fail-closed). + */ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +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)), '..'); +if (!fs.existsSync(path.join(root, 'dist/safety/protected-path-snapshot.js'))) { + console.error('# FAIL: dist missing — run npm run build first'); + process.exit(1); +} +const { + takeProtectedPathSnapshot, + takeProtectedPathSnapshotAfter, + diffProtectedPathSnapshots, + formatProtectedPathModifiedNotice, + parseProtectedPathModMarker, + isBenignGitConfigKey, + GIT_CONFIG_BENIGN_KEYS, +} = await import('../dist/safety/protected-path-snapshot.js'); +const { execTool } = await import('../dist/tools/builtin.js'); +const { findShellProtectedWrites, findProtectedPathHits } = + await import('../dist/safety/protected-paths.js'); +const { createRunCompletionTracker, noteRunCompletionEvent } = + await import('../dist/cli/run-completion.js'); +const { resolveRunOutcome, formatOutcomeLine } = await import('../dist/cli/run-outcome.js'); + +let failures = 0; +async function check(name, fn) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failures += 1; + console.error(`not ok - ${name}`); + console.error(err); + } +} + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o117-')); +process.on('exit', () => fs.rmSync(tmpRoot, { recursive: true, force: true })); + +const gitEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: path.join(tmpRoot, 'no-global'), +}; + +function makeRepo() { + const home = fs.mkdtempSync(path.join(tmpRoot, 'home-')); + const ws = fs.mkdtempSync(path.join(tmpRoot, 'ws-')); + const r = spawnSync('git', ['init', '-q', ws], { env: gitEnv }); + assert.equal(r.status, 0, String(r.stderr)); + return { home, ws }; +} + +function git(ws, ...args) { + const r = spawnSync('git', args, { cwd: ws, env: gitEnv, encoding: 'utf8' }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); +} + +/** Run `mutate` between snapshots; return changes touching .git/config. */ +function configChanges(mutate) { + const { home, ws } = makeRepo(); + const before = takeProtectedPathSnapshot(ws, { home }); + mutate(ws); + const after = takeProtectedPathSnapshotAfter(before, { home }); + return diffProtectedPathSnapshots(before, after).filter((c) => c.path.endsWith('.git/config')); +} + +const cfg = (ws) => path.join(ws, '.git', 'config'); + +// ── A. ordinary keys: no report ─────────────────────────────────────── + +await check('A1: git config user.name x → no .git/config change reported', () => { + assert.deepEqual( + configChanges((ws) => git(ws, 'config', 'user.name', 'x')), + [] + ); +}); + +await check( + 'A2: user.email / color / init.defaultBranch / pull.rebase / core.autocrlf / --unset → no report', + () => { + assert.deepEqual( + configChanges((ws) => { + git(ws, 'config', 'user.email', 'x@example.com'); + git(ws, 'config', 'USER.Name', 'y'); + git(ws, 'config', 'color.ui', 'auto'); + git(ws, 'config', 'init.defaultBranch', 'main'); + git(ws, 'config', 'pull.rebase', 'true'); + git(ws, 'config', 'core.autocrlf', 'input'); + git(ws, 'config', '--unset', 'user.email'); + }), + [] + ); + } +); + +await check( + 'A3: remote add / branch upstream are NOT allow-listed → reported (conservative)', + () => { + const r = configChanges((ws) => + git(ws, 'remote', 'add', 'origin', 'https://example.com/r.git') + ); + assert.equal(r.length, 1, JSON.stringify(r)); + assert.deepEqual(r[0].keys, ['remote.origin.fetch', 'remote.origin.url']); + const b = configChanges((ws) => git(ws, 'config', 'branch.main.remote', 'origin')); + assert.deepEqual(b[0]?.keys, ['branch.main.remote']); + } +); + +// ── B. dangerous keys: still reported ───────────────────────────────── + +await check('B1: script writes core.hooksPath (bypassing pre-block) → reported with key', () => { + const changes = configChanges((ws) => { + const script = path.join(ws, 'x.sh'); + fs.writeFileSync(script, 'git config core.hooksPath /tmp/evilhooks\n'); + const r = spawnSync('sh', [script], { cwd: ws, env: gitEnv }); + assert.equal(r.status, 0); + }); + assert.equal(changes.length, 1, JSON.stringify(changes)); + assert.equal(changes[0].kind, 'modified'); + assert.deepEqual(changes[0].keys, ['core.hooksPath']); // original casing + const zh = formatProtectedPathModifiedNotice(changes, true, 'sh x.sh'); + assert.match(zh, /已被修改/); + assert.match(zh, /`core\.hooksPath`/); + assert.doesNotMatch(zh, /evilhooks/); // value never shown +}); + +await check('B2: case / spacing variants of hooksPath via raw file edit → reported', () => { + for (const text of [ + '[CORE]\n\tHOOKSPATH = /tmp/h\n', + '[Core]\n hooksPath=/tmp/h\n', + '[core]\n\thookspath = "/tmp/h"\n', + '[core]\n\thooksPath = /tmp/\\\nh\n', + ]) { + const changes = configChanges((ws) => fs.appendFileSync(cfg(ws), text)); + assert.equal(changes.length, 1, `${JSON.stringify(text)} → ${JSON.stringify(changes)}`); + assert.deepEqual( + changes[0].keys.map((k) => k.toLowerCase()), + ['core.hookspath'] + ); + } +}); + +await check('B3: changing an existing hooksPath value is reported', () => { + const { home, ws } = makeRepo(); + git(ws, 'config', 'core.hooksPath', '/tmp/a'); + const before = takeProtectedPathSnapshot(ws, { home }); + git(ws, 'config', 'core.hooksPath', '/tmp/b'); + const after = takeProtectedPathSnapshotAfter(before, { home }); + const ch = diffProtectedPathSnapshots(before, after).filter((c) => c.path.endsWith('config')); + assert.equal(ch.length, 1); +}); + +await check( + 'B4: include / includeIf / filter / merge driver / fsmonitor / alias / url.insteadOf', + () => { + const cases = [ + ['include.path', '/tmp/evil.cfg', 'include.path'], + ['includeIf.gitdir:/.path', '/tmp/evil.cfg', 'includeif.gitdir:/.path'], + ['filter.x.smudge', 'sh /tmp/p', 'filter.x.smudge'], + ['merge.x.driver', 'sh /tmp/p', 'merge.x.driver'], + ['core.fsmonitor', '/tmp/p', 'core.fsmonitor'], + ['core.sshCommand', '/tmp/p', 'core.sshcommand'], + ['credential.helper', '/tmp/p', 'credential.helper'], + ['diff.x.textconv', '/tmp/p', 'diff.x.textconv'], + ['alias.st', '!sh /tmp/p', 'alias.st'], + ['url.https://evil/.insteadOf', 'https://github.com/', 'url.https://evil/.insteadof'], + ]; + for (const [key, value, norm] of cases) { + const ch = configChanges((ws) => git(ws, 'config', key, value)); + assert.equal(ch.length, 1, `${key}: ${JSON.stringify(ch)}`); + assert.deepEqual( + ch[0].keys.map((k) => k.toLowerCase()), + [norm.toLowerCase()], + key + ); + } + } +); + +await check('B5: deprecated [section.subsection] form of a dangerous key is reported', () => { + const ch = configChanges((ws) => fs.appendFileSync(cfg(ws), '[filter.x]\n\tclean = sh /tmp/p\n')); + assert.equal(ch.length, 1, JSON.stringify(ch)); +}); + +await check('B6: remote.*.uploadpack (runs a command, outside allow-list) → reported', () => { + const ch = configChanges((ws) => git(ws, 'config', 'remote.origin.uploadpack', 'sh /tmp/p')); + assert.equal(ch.length, 1, JSON.stringify(ch)); + assert.deepEqual(ch[0].keys, ['remote.origin.uploadpack']); + assert.doesNotMatch(formatProtectedPathModifiedNotice(ch, true, 'x'), /\/tmp\/p/); +}); + +await check('B7: arbitrary unknown key foo.bar (never on any blacklist) → reported', () => { + const ch = configChanges((ws) => git(ws, 'config', 'foo.bar', '1')); + assert.equal(ch.length, 1, JSON.stringify(ch)); + assert.deepEqual(ch[0].keys, ['foo.bar']); +}); + +await check('B8: benign + unlisted key in one command → only unlisted key named', () => { + const ch = configChanges((ws) => { + git(ws, 'config', 'user.name', 'x'); + git(ws, 'config', 'core.pager', 'sh /tmp/p'); + }); + assert.deepEqual(ch[0]?.keys, ['core.pager']); +}); + +await check('B9: core.fsmonitor and case variant [Core] FSMonitor → reported', () => { + const a = configChanges((ws) => git(ws, 'config', 'core.fsmonitor', '/tmp/p')); + assert.deepEqual(a[0]?.keys, ['core.fsmonitor']); + const b = configChanges((ws) => fs.appendFileSync(cfg(ws), '[Core]\n\tFSMonitor = /tmp/p\n')); + assert.deepEqual(b[0]?.keys, ['core.FSMonitor']); // reported, original casing kept +}); + +// ── C. fail-closed ──────────────────────────────────────────────────── + +await check('C1: unparsable .git/config → whole-file report (no keys)', () => { + const ch = configChanges((ws) => fs.appendFileSync(cfg(ws), '[core\n\thooksPath = /tmp/h\n')); + assert.equal(ch.length, 1, JSON.stringify(ch)); + assert.equal(ch[0].keys, undefined); +}); + +await check('C2: .git/config deleted → reported', () => { + const ch = configChanges((ws) => fs.rmSync(cfg(ws))); + assert.equal(ch.length, 1); + assert.equal(ch[0].kind, 'deleted'); +}); + +await check('C3: .git/config replaced by symlink → reported', () => { + const ch = configChanges((ws) => { + const other = path.join(ws, 'other.cfg'); + fs.writeFileSync(other, fs.readFileSync(cfg(ws))); + fs.rmSync(cfg(ws)); + fs.symlinkSync(other, cfg(ws)); + }); + assert.equal(ch.length, 1, JSON.stringify(ch)); +}); + +await check('C4: allow-list matching — section/name case-insensitive, nothing executable', () => { + assert.deepEqual( + [...GIT_CONFIG_BENIGN_KEYS], + ['user.name', 'user.email', 'color.ui', 'init.defaultBranch', 'pull.rebase', 'core.autocrlf'] + ); + assert.ok(isBenignGitConfigKey('user.name')); + assert.ok(isBenignGitConfigKey('USER.NAME')); + assert.ok(isBenignGitConfigKey('Init.DefaultBranch')); + for (const k of [ + 'core.hookspath', + 'CORE.HOOKSPATH', + 'remote.origin.url', + 'remote.origin.uploadpack', + 'branch.main.remote', + 'foo.bar', + 'init.templatedir', + 'pull.x.rebase', + 'core.autocrlfx', + 'user.signingkey', + 'color.diff.meta', + 'core.fsmonitor', + 'alias.st', + 'include.path', + ]) { + assert.ok(!isBenignGitConfigKey(k), k); + } +}); + +// ── D. end-to-end through execTool + run outcome ────────────────────── + +async function runExec(ws, home, command) { + const prevHome = process.env.HOME; + process.env.HOME = home; + try { + return String( + await execTool.execute( + { command }, + { workspaceDir: ws, sessionKey: 'o117', abortSignal: new AbortController().signal } + ) + ); + } finally { + process.env.HOME = prevHome; + } +} + +function outcomeFor(command, result) { + const tracker = createRunCompletionTracker(); + noteRunCompletionEvent(tracker, { + type: 'tool_start', + toolCallId: '1', + toolName: 'exec', + input: { command }, + }); + noteRunCompletionEvent(tracker, { + type: 'tool_end', + toolCallId: '1', + toolName: 'exec', + result, + isError: false, + }); + return resolveRunOutcome({ tracker, finalText: '已完成。', request: command }); +} + +await check('D1: exec `git config user.name x` → no notice, exit 0', async () => { + const { home, ws } = makeRepo(); + const out = await runExec(ws, home, 'git config user.name x'); + assert.doesNotMatch(out, /受保护路径已被修改|Protected paths were modified/); + const outcome = outcomeFor('git config user.name x', out); + assert.equal(outcome.exitCode, 0, formatOutcomeLine(outcome, 'zh')); + assert.doesNotMatch(formatOutcomeLine(outcome, 'zh'), /未完成/); +}); + +await check('D2: exec script that sets core.hooksPath → notice, exit 9', async () => { + const { home, ws } = makeRepo(); + fs.writeFileSync(path.join(ws, 'x.sh'), 'git config core.hooksPath /tmp/evilhooks\n'); + const out = await runExec(ws, home, 'sh x.sh'); + assert.match(out, /moss-o101-protected-path-modified/); + const outcome = outcomeFor('sh x.sh', out); + assert.equal(outcome.exitCode, 9); + assert.match(formatOutcomeLine(outcome, 'zh'), /已被修改/); +}); + +// ── E. per-worktree config bypass (QA) ──────────────────────────────── + +await check( + 'E1: repositoryformatversion=1 + extensions.worktreeConfig + .git/config.worktree hooksPath → reported', + () => { + const { home, ws } = makeRepo(); + const before = takeProtectedPathSnapshot(ws, { home }); + git(ws, 'config', 'core.repositoryformatversion', '1'); + git(ws, 'config', 'extensions.worktreeConfig', 'true'); + fs.writeFileSync(path.join(ws, '.git', 'config.worktree'), '[core]\n\thooksPath = /tmp/evil\n'); + // precondition: git really honours it + const r = spawnSync('git', ['config', 'core.hooksPath'], { + cwd: ws, + env: gitEnv, + encoding: 'utf8', + }); + assert.equal(r.stdout.trim(), '/tmp/evil'); + const after = takeProtectedPathSnapshotAfter(before, { home }); + const ch = diffProtectedPathSnapshots(before, after); + const wt = ch.find((c) => c.path.endsWith('.git/config.worktree')); + assert.ok(wt, JSON.stringify(ch)); + assert.deepEqual(wt.keys, ['core.hooksPath']); + const main = ch.find((c) => c.path.endsWith('.git/config')); + assert.deepEqual(main?.keys, ['core.repositoryformatversion', 'extensions.worktreeConfig']); + } +); + +await check('E2: linked worktree .git/worktrees//config.worktree hooksPath → reported', () => { + const { home, ws } = makeRepo(); + git( + ws, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e', + 'commit', + '-q', + '--allow-empty', + '-m', + 'i', + '--no-verify' + ); + git(ws, 'worktree', 'add', '-q', path.join(ws, 'wt'), '-b', 'wt'); + const before = takeProtectedPathSnapshot(ws, { home }); + const name = fs.readdirSync(path.join(ws, '.git', 'worktrees'))[0]; + fs.writeFileSync( + path.join(ws, '.git', 'worktrees', name, 'config.worktree'), + '[core]\n\tfsmonitor = /tmp/p\n' + ); + const ch = diffProtectedPathSnapshots(before, takeProtectedPathSnapshotAfter(before, { home })); + const wt = ch.find((c) => c.path.endsWith(`worktrees/${name}/config.worktree`)); + assert.ok(wt, JSON.stringify(ch)); + assert.equal(wt.kind, 'added'); + assert.deepEqual(wt.keys, ['core.fsmonitor']); +}); + +await check('E3: extensions.* / core.repositoryformatversion are not allow-listed', () => { + assert.ok(!isBenignGitConfigKey('extensions.worktreeConfig')); + assert.ok(!isBenignGitConfigKey('core.repositoryformatversion')); +}); + +await check('E4: pre-block (O103) refuses direct writes to config.worktree files', () => { + const { ws } = makeRepo(); + for (const cmd of [ + "printf '[core]\\n hooksPath=/tmp/x\\n' > .git/config.worktree", + 'cp x .git/worktrees/w/config.worktree', + 'git config --worktree core.hooksPath /tmp/x', + ]) { + const hits = findShellProtectedWrites(cmd, { cwd: ws, workspaceRoot: ws }); + assert.ok(hits.length > 0, cmd); + } + for (const p of ['.git/config.worktree', '.git/worktrees/w/config.worktree']) { + const hits = findProtectedPathHits( + 'write_file', + { path: p, content: '[core]\n\thooksPath = /tmp/x\n' }, + { cwd: ws, workspaceRoot: ws }, + false + ); + assert.ok(hits.length > 0, p); + } +}); + +// ── F. user-facing copy (UX) ────────────────────────────────────────── + +function lastLines(changes, command = 'sh x.sh') { + const marker = formatProtectedPathModifiedNotice(changes, true, command); + const parsed = parseProtectedPathModMarker(marker); + const out = {}; + for (const lang of ['zh', 'en']) { + const tracker = createRunCompletionTracker(); + tracker.protectedPathChanges = parsed.map((c) => ({ ...c, command })); + out[lang] = formatOutcomeLine( + resolveRunOutcome({ tracker, finalText: '已完成。', request: command }), + lang + ); + } + return out; +} + +await check('F1: last line names the key with original casing, zh + en', () => { + const ch = configChanges((ws) => git(ws, 'config', 'core.hooksPath', '/tmp/secretdir')); + const { zh, en } = lastLines(ch); + assert.match(zh, /卡在:\.git\/config 改了 `core\.hooksPath`(不在安全清单内)/); + assert.match(en, /\.git\/config changed `core\.hooksPath` \(not on the safe list\)/); + assert.doesNotMatch(zh + en, /secretdir/); +}); + +await check('F2: more than 3 keys → 3 listed + 「等 N 个」 / "and N more"', () => { + const ch = configChanges((ws) => { + for (const k of ['a.one', 'b.two', 'c.three', 'd.four', 'e.five']) git(ws, 'config', k, '1'); + }); + const { zh, en } = lastLines(ch); + assert.match(zh, /改了 `a\.one`、`b\.two`、`c\.three` 等 5 个(不在安全清单内)/); + assert.doesNotMatch(zh, /d\.four/); + assert.match(en, /changed `a\.one`, `b\.two`, `c\.three` and 2 more/); +}); + +await check('F3: delete / symlink / unparsable each say why', () => { + const del = lastLines(configChanges((ws) => fs.rmSync(cfg(ws)))); + assert.match(del.zh, /\.git\/config 被删除/); + assert.match(del.en, /\.git\/config was deleted/); + const link = lastLines( + configChanges((ws) => { + const other = path.join(ws, 'other.cfg'); + fs.writeFileSync(other, fs.readFileSync(cfg(ws))); + fs.rmSync(cfg(ws)); + fs.symlinkSync(other, cfg(ws)); + }) + ); + assert.match(link.zh, /\.git\/config 被换成软链/); + assert.match(link.en, /replaced by a symlink/); + const bad = lastLines(configChanges((ws) => fs.appendFileSync(cfg(ws), '[core\n'))); + assert.match(bad.zh, /\.git\/config 无法解析/); + assert.match(bad.en, /\.git\/config could not be parsed/); +}); + +await check('F4: mixed benign + unlisted → only the unlisted key is named', () => { + const ch = configChanges((ws) => { + git(ws, 'config', 'user.name', 'x'); + git(ws, 'config', 'alias.st', '!sh /tmp/p'); + }); + const { zh } = lastLines(ch); + assert.match(zh, /`alias\.st`/); + assert.doesNotMatch(zh, /user\.name/); +}); + +// ── G. process-level `moss -p` ──────────────────────────────────────── + +function startFake(calls) { + 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 < calls.length) { + res.write( + chunk({ + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `call_${n}`, + type: 'function', + function: { name: 'exec', arguments: JSON.stringify({ command: calls[n] }) }, + }, + ], + }) + ); + res.write(chunk({}, 'tool_calls')); + } else { + res.write(chunk({ role: 'assistant', content: '已设置 user.name,完成。' })); + res.write(chunk({}, 'stop')); + } + res.end('data: [DONE]\n\n'); + }); + }); + return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server))); +} + +function runCli(args, env, cwd) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [path.join(root, 'dist', 'cli.js'), ...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'), 60000); + child.on('close', (code) => { + clearTimeout(t); + resolve({ code, out, err }); + }); + }); +} + +async function pRun(lang, command) { + const home = fs.mkdtempSync(path.join(tmpRoot, 'phome-')); + const proj = fs.mkdtempSync(path.join(tmpRoot, 'pproj-')); + const cfgDir = path.join(home, '.config', 'moss'); + fs.mkdirSync(cfgDir, { recursive: true }); + spawnSync('git', ['init', '-q', proj], { env: gitEnv }); + const server = await startFake([command]); + const env = { + PATH: process.env.PATH, + HOME: home, + MOSS_CONFIG_DIR: cfgDir, + 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:${server.address().port}`], + ['model', 'fake-model'], + ['apiKeyEnv', 'FAKE_OAI_KEY'], + ]) + await runCli(['config', 'set', k, v], env, proj); + const r = await runCli(['--trust-workspace', '--lang', lang, '-p', 'set name'], env, proj); + server.close(); + const name = spawnSync('git', ['config', 'user.name'], { + cwd: proj, + env: gitEnv, + encoding: 'utf8', + }); + return { ...r, last: r.out.trim().split('\n').pop() ?? '', name: name.stdout.trim() }; +} + +for (const lang of ['zh', 'en']) { + await check( + `G1 (${lang}): moss -p runs \`git config user.name x\` → exit 0, not incomplete`, + async () => { + const r = await pRun(lang, 'git config user.name x'); + assert.equal(r.name, 'x', 'precondition: command actually ran'); + assert.equal(r.code, 0, `${r.last}\n${r.err.slice(-400)}`); + assert.doesNotMatch(r.last, /未完成|Incomplete|已被修改|modified/); + } + ); +} + +if (failures > 0) { + console.error(`# ${failures} failure(s)`); + process.exit(1); +} +console.log('# o117 all ok'); From e5d1579aae1c7843e8593a3384f5c946266e22ce Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 12:28:34 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(safety):=20O117=20allow=20remote/branch?= =?UTF-8?q?=20upstream=20leaves=20and=20https/ssh/scp=20remote.*.url=20by?= =?UTF-8?q?=20value=20(PM=20=E5=8F=A3=E5=BE=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/safety/protected-path-snapshot.ts | 137 +++++++++++++++++++- test/o117-git-config-l3-precise.spec.mjs | 158 ++++++++++++++++++++++- 2 files changed, 285 insertions(+), 10 deletions(-) diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts index 0b32bf4c..17da7631 100644 --- a/src/safety/protected-path-snapshot.ts +++ b/src/safety/protected-path-snapshot.ts @@ -107,7 +107,8 @@ export function isGitConfigPath(abs: string): boolean { * reported. Every other key change — including ones never seen before — is * reported as "已被修改" (fail-closed). Kept deliberately small: nothing that * can run a program, pull in other config, or change where code is fetched - * from (so `remote.*.url/fetch`, `branch.*.remote/merge` are NOT listed). + * from. Remote/branch leaves are handled separately (PM 口径, see + * GIT_CONFIG_BENIGN_SUBSECTION_LEAVES / isSafeGitRemoteUrl). * * Matching is on the normalised key: section and variable name are * case-insensitive, the subsection keeps git's case-sensitive spelling. @@ -137,6 +138,135 @@ export function isBenignGitConfigKey(key: string): boolean { return GIT_CONFIG_BENIGN_KEYS.some((e) => e.toLowerCase() === k); } +/** + * O117 (PM, remote 口径): three-part leaf keys allowed regardless of value, + * for any subsection name. Every other `remote..*` / `branch..*` leaf + * (uploadpack, receivepack, proxy, pushurl, vcs, ...) stays reported. + */ +export const GIT_CONFIG_BENIGN_SUBSECTION_LEAVES: readonly string[] = [ + 'branch.*.remote', + 'branch.*.merge', + 'remote.*.fetch', +]; + +/** O117: allowed by value only (see isSafeGitRemoteUrl). */ +export const GIT_CONFIG_VALUE_CHECKED_LEAVES: readonly string[] = ['remote.*.url']; + +function splitSubsectionKey(k: string): { section: string; sub: string; name: string } | null { + const first = k.indexOf('.'); + const last = k.lastIndexOf('.'); + if (first === -1 || first === last) return null; + return { section: k.slice(0, first), sub: k.slice(first + 1, last), name: k.slice(last + 1) }; +} + +function leafPattern(k: string): string | null { + const p = splitSubsectionKey(k); + if (!p || p.sub.length === 0) return null; + return `${p.section}.*.${p.name}`; +} + +function safeDecode(x: string): string | null { + try { + let cur = x; + // Decode repeatedly so %252D style double-encoding is also caught. + for (let i = 0; i < 3; i++) { + const next = decodeURIComponent(cur); + if (next === cur) break; + cur = next; + } + return cur; + } catch { + return null; + } +} + +function hostUserOk(raw: string): boolean { + const v = safeDecode(raw); + if (v === null) return false; + if (v.length === 0) return false; + if (v.startsWith('-')) return false; + if (/[\s\u0000-\u001f\u007f]/.test(v)) return false; + return true; +} + +/** + * O117 (PM): `remote..url` is allowed only for `https://`, `ssh://` and + * scp-style `user@host:path`. Everything else (ext::, fd::, file://, local + * paths, leading `-`, newline/control chars, host or user beginning with `-` + * after URL-decoding — CVE-2017-1000117 —, non-numeric port) is reported. + */ +export function isSafeGitRemoteUrl(url: string): boolean { + if (url.length === 0 || url.startsWith('-')) return false; + if (/[\u0000-\u001f\u007f\s]/.test(url)) return false; + const dec = safeDecode(url); + if (dec === null) return false; + if (dec.startsWith('-') || /[\u0000-\u001f\u007f]/.test(dec)) return false; + if (/^[A-Za-z0-9+.-]*::/.test(url)) return false; // ext::, fd::, :: + const m = /^(https|ssh):\/\/([^/?#]*)(.*)$/i.exec(url); + if (m) { + let auth = m[2]; + if (!auth) return false; + let user: string | undefined; + const at = auth.lastIndexOf('@'); + if (at !== -1) { + user = auth.slice(0, at); + auth = auth.slice(at + 1); + if (m[1].toLowerCase() === 'ssh' && user.includes(':')) return false; + const uname = user.split(':')[0]; + if (!hostUserOk(uname)) return false; + } + let host = auth; + let port: string | undefined; + if (host.startsWith('[')) { + const close = host.indexOf(']'); + if (close === -1) return false; + const rest = host.slice(close + 1); + host = host.slice(1, close); + if (rest) { + if (!rest.startsWith(':')) return false; + port = rest.slice(1); + } + } else { + const c = host.indexOf(':'); + if (c !== -1) { + port = host.slice(c + 1); + host = host.slice(0, c); + } + } + if (!hostUserOk(host)) return false; + if (port !== undefined && port !== '' && !/^[0-9]+$/.test(port)) return false; + return true; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return false; // file://, git://, http:// ... + // scp style: [user@]host:path — host must contain no '/', and a ':' must + // come before any '/', otherwise git treats it as a local path. + const scp = /^(?:([^@/:]+)@)?([^@/:[\]]+|\[[^\]]+\]):(.*)$/.exec(url); + if (!scp) return false; + if (scp[1] !== undefined && !hostUserOk(scp[1])) return false; + const host = scp[2].replace(/^\[|\]$/g, ''); + if (!hostUserOk(host)) return false; + // A single-letter "host" is a Windows drive path (C:\x), i.e. local. + if (/^[a-z]$/i.test(host)) return false; + if (scp[3].startsWith('-')) return false; + return true; +} + +/** + * O117: whether a key change is allowed given its new values. `afterValues` + * is the full list after the command (empty = key removed). + */ +export function isBenignGitConfigEntry(key: string, afterValues: readonly string[]): boolean { + if (isBenignGitConfigKey(key)) return true; + const k = normalizeGitConfigKey(key); + const pat = leafPattern(k); + if (!pat) return false; + if (GIT_CONFIG_BENIGN_SUBSECTION_LEAVES.includes(pat)) return true; + if (GIT_CONFIG_VALUE_CHECKED_LEAVES.includes(pat)) { + return afterValues.every((v) => v !== '\u0000(implicit-true)' && isSafeGitRemoteUrl(v)); + } + return false; +} + /** * Parse git config bytes with git itself (`git config --file - --list -z`, * no includes followed, no system/global config). Returns key→values, or @@ -198,8 +328,9 @@ export function changedUnlistedGitConfigKeys(before: Buffer, after: Buffer): str if (!b || !a) return null; const changed: string[] = []; for (const key of new Set([...b.keys(), ...a.keys()])) { - if (isBenignGitConfigKey(key)) continue; - if (JSON.stringify(b.get(key) ?? []) !== JSON.stringify(a.get(key) ?? [])) changed.push(key); + if (JSON.stringify(b.get(key) ?? []) === JSON.stringify(a.get(key) ?? [])) continue; + if (isBenignGitConfigEntry(key, a.get(key) ?? [])) continue; + changed.push(key); } changed.sort(); const text = `${after.toString('utf8')}\n${before.toString('utf8')}`; diff --git a/test/o117-git-config-l3-precise.spec.mjs b/test/o117-git-config-l3-precise.spec.mjs index 16b2e938..ad3affd1 100644 --- a/test/o117-git-config-l3-precise.spec.mjs +++ b/test/o117-git-config-l3-precise.spec.mjs @@ -24,6 +24,7 @@ const { diffProtectedPathSnapshots, formatProtectedPathModifiedNotice, parseProtectedPathModMarker, + isSafeGitRemoteUrl, isBenignGitConfigKey, GIT_CONFIG_BENIGN_KEYS, } = await import('../dist/safety/protected-path-snapshot.js'); @@ -107,18 +108,146 @@ await check( ); await check( - 'A3: remote add / branch upstream are NOT allow-listed → reported (conservative)', + 'A3: git remote add origin https://… + real git push -u (to local bare via -c insteadOf) → no report', () => { - const r = configChanges((ws) => - git(ws, 'remote', 'add', 'origin', 'https://example.com/r.git') - ); + const bare = fs.mkdtempSync(path.join(tmpRoot, 'bare-')); + assert.equal(spawnSync('git', ['init', '-q', '--bare', bare], { env: gitEnv }).status, 0); + const r = configChanges((ws) => { + git(ws, 'remote', 'add', 'origin', 'https://example.com/r.git'); + git( + ws, + '-c', + 'user.name=t', + '-c', + 'user.email=t@e', + 'commit', + '-q', + '--allow-empty', + '-m', + 'x' + ); + git(ws, 'branch', '-M', 'main'); + // url stays https in .git/config; the -c rewrite is process-only, not written. + git( + ws, + '-c', + `url.file://${bare}.insteadOf=https://example.com/r.git`, + 'push', + '-q', + '-u', + 'origin', + 'main' + ); + const merge = spawnSync('git', ['config', 'branch.main.merge'], { + cwd: ws, + env: gitEnv, + encoding: 'utf8', + }); + assert.equal(merge.stdout.trim(), 'refs/heads/main', 'precondition: push -u wrote upstream'); + }); + assert.deepEqual(r, []); + } +); + +await check('A4: ssh:// / scp-style url, set-upstream, extra fetch refspec → no report', () => { + const r = configChanges((ws) => { + git(ws, 'remote', 'add', 'a', 'ssh://git@github.com:22/o/r.git'); + git(ws, 'remote', 'add', 'b', 'git@github.com:o/r.git'); + git(ws, 'remote', 'set-url', 'a', 'https://user@github.com:8443/o/r.git'); + git(ws, 'config', '--add', 'remote.a.fetch', '+refs/pull/*:refs/remotes/a/pr/*'); + git(ws, 'config', 'branch.dev.remote', 'b'); + git(ws, 'config', 'branch.dev.merge', 'refs/heads/dev'); + git(ws, 'remote', 'remove', 'b'); + }); + assert.deepEqual(r, []); +}); + +const BAD_URLS = [ + 'ext::sh -c touch% /tmp/pwned', + 'fd::17', + 'file:///tmp/evil', + '/tmp/evil', + './evil', + '-oProxyCommand=sh', + 'ssh://-oProxyCommand=touch%20/tmp/p/x', + 'ssh://%2DoProxyCommand=sh/x', + 'ssh://%252DoProxyCommand=sh/x', + 'ssh://-u@host/x', + 'ssh://%2du@host/x', + 'https://host:80a/x', + 'git@-oProxyCommand=sh:x', + '-x@host:path', + 'git://host/x', + 'http://host/x', +]; +for (const url of BAD_URLS) { + await check(`A5: remote.origin.url = ${JSON.stringify(url)} → reported`, () => { + const r = configChanges((ws) => git(ws, 'config', 'remote.origin.url', url)); assert.equal(r.length, 1, JSON.stringify(r)); - assert.deepEqual(r[0].keys, ['remote.origin.fetch', 'remote.origin.url']); - const b = configChanges((ws) => git(ws, 'config', 'branch.main.remote', 'origin')); - assert.deepEqual(b[0]?.keys, ['branch.main.remote']); + assert.deepEqual(r[0].keys, ['remote.origin.url']); + }); +} + +await check('A6: url with newline / control char → reported', () => { + for (const v of ['https://h/x\nfoo', 'https://h/\u0001x']) { + const r = configChanges((ws) => { + fs.appendFileSync( + cfg(ws), + `[remote "o"]\n\turl = "${v.replace('\n', '\\n').replace('\u0001', '\u0001')}"\n` + ); + }); + assert.equal(r.length, 1, JSON.stringify(v)); + } +}); + +await check( + 'A7: same remote, other leaves (pushurl/uploadpack/receivepack/proxy/vcs) → reported', + () => { + for (const leaf of [ + 'pushurl', + 'uploadpack', + 'receivepack', + 'proxy', + 'vcs', + 'tagOpt', + 'mirror', + ]) { + const r = configChanges((ws) => { + git(ws, 'remote', 'add', 'origin', 'https://example.com/r.git'); + git(ws, 'config', `remote.origin.${leaf}`, 'https://example.com/x'); + }); + assert.equal(r.length, 1, leaf); + assert.equal(r[0].keys.length, 1, `${leaf}: ${JSON.stringify(r[0].keys)}`); + assert.equal(r[0].keys[0].toLowerCase(), `remote.origin.${leaf.toLowerCase()}`); + } + const b = configChanges((ws) => git(ws, 'config', 'branch.main.pushRemote', 'x')); + assert.equal(b.length, 1); } ); +await check('A8: isSafeGitRemoteUrl unit table', () => { + for (const u of [ + 'https://github.com/o/r.git', + 'https://u:p@h:443/x', + 'ssh://git@h/x', + 'ssh://[::1]:22/x', + 'git@host:o/r', + 'host.example:o/r', + ]) + assert.ok(isSafeGitRemoteUrl(u), u); + for (const u of [ + ...BAD_URLS, + 'C:\\x', + 'c:/x', + '', + 'https://', + 'https://-h/x', + 'https://%2Dh/x', + 'ssh://h:-1/x', + ]) + assert.ok(!isSafeGitRemoteUrl(u), u); +}); + // ── B. dangerous keys: still reported ───────────────────────────────── await check('B1: script writes core.hooksPath (bypassing pre-block) → reported with key', () => { @@ -588,6 +717,21 @@ for (const lang of ['zh', 'en']) { ); } +await check('G2 (zh): moss -p runs `git remote add origin https://…` → exit 0', async () => { + const r = await pRun( + 'zh', + 'git remote add origin https://example.com/r.git && git config branch.main.remote origin && git config branch.main.merge refs/heads/main' + ); + assert.equal(r.code, 0, `${r.last}\n${r.err.slice(-400)}`); + assert.doesNotMatch(r.last, /未完成|已被修改/); +}); + +await check('G3 (zh): moss -p runs `git remote add x ext::sh …` → exit 9', async () => { + const r = await pRun('zh', "git remote add x 'ext::sh -c id'"); + assert.equal(r.code, 9, `${r.last}\n${r.err.slice(-400)}`); + assert.match(r.out, /remote\.x\.url/); +}); + if (failures > 0) { console.error(`# ${failures} failure(s)`); process.exit(1); From 218ffba890a003ce808eb74ecb13393d0ef27cbc Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 12:40:04 +0800 Subject: [PATCH 3/8] fix(safety): O117 check branch.*.remote by value (name, '.', or safe URL) --- src/safety/protected-path-snapshot.ts | 15 ++++++++++++++- test/o117-git-config-l3-precise.spec.mjs | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts index 17da7631..4d4af746 100644 --- a/src/safety/protected-path-snapshot.ts +++ b/src/safety/protected-path-snapshot.ts @@ -144,7 +144,6 @@ export function isBenignGitConfigKey(key: string): boolean { * (uploadpack, receivepack, proxy, pushurl, vcs, ...) stays reported. */ export const GIT_CONFIG_BENIGN_SUBSECTION_LEAVES: readonly string[] = [ - 'branch.*.remote', 'branch.*.merge', 'remote.*.fetch', ]; @@ -152,6 +151,17 @@ export const GIT_CONFIG_BENIGN_SUBSECTION_LEAVES: readonly string[] = [ /** O117: allowed by value only (see isSafeGitRemoteUrl). */ export const GIT_CONFIG_VALUE_CHECKED_LEAVES: readonly string[] = ['remote.*.url']; +/** + * O117: `branch..remote` may hold a URL instead of a remote name (git then + * fetches/pushes that URL directly), so it is allowed by value: `.`, a plain + * remote name (no `/`, `:`, leading `-`), or a URL isSafeGitRemoteUrl accepts. + */ +export function isSafeGitBranchRemote(v: string): boolean { + if (v === '.') return true; + if (/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(v)) return true; + return isSafeGitRemoteUrl(v); +} + function splitSubsectionKey(k: string): { section: string; sub: string; name: string } | null { const first = k.indexOf('.'); const last = k.lastIndexOf('.'); @@ -261,6 +271,9 @@ export function isBenignGitConfigEntry(key: string, afterValues: readonly string const pat = leafPattern(k); if (!pat) return false; if (GIT_CONFIG_BENIGN_SUBSECTION_LEAVES.includes(pat)) return true; + if (pat === 'branch.*.remote') { + return afterValues.every((v) => v !== '\u0000(implicit-true)' && isSafeGitBranchRemote(v)); + } if (GIT_CONFIG_VALUE_CHECKED_LEAVES.includes(pat)) { return afterValues.every((v) => v !== '\u0000(implicit-true)' && isSafeGitRemoteUrl(v)); } diff --git a/test/o117-git-config-l3-precise.spec.mjs b/test/o117-git-config-l3-precise.spec.mjs index ad3affd1..273edd9b 100644 --- a/test/o117-git-config-l3-precise.spec.mjs +++ b/test/o117-git-config-l3-precise.spec.mjs @@ -248,6 +248,20 @@ await check('A8: isSafeGitRemoteUrl unit table', () => { assert.ok(!isSafeGitRemoteUrl(u), u); }); +await check('A9: branch.*.remote holding a dangerous URL / path → reported', () => { + for (const v of ['ext::sh -c id', '/tmp/evil', './evil', '-oProxyCommand=sh', 'ssh://%2Dx/y']) { + const r = configChanges((ws) => git(ws, 'config', 'branch.main.remote', v)); + assert.equal(r.length, 1, v); + assert.deepEqual(r[0].keys, ['branch.main.remote']); + } + for (const v of ['origin', '.', 'up-stream_2', 'https://example.com/r.git']) + assert.deepEqual( + configChanges((ws) => git(ws, 'config', 'branch.main.remote', v)), + [], + v + ); +}); + // ── B. dangerous keys: still reported ───────────────────────────────── await check('B1: script writes core.hooksPath (bypassing pre-block) → reported with key', () => { From e37c968228dd0852b7567bcc0260bcdb144c6572 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 11:30:43 +0800 Subject: [PATCH 4/8] fix(safety): O123 L3 path allow-list for ordinary git commit/checkout/merge (HEAD/refs judged by content) --- src/safety/protected-path-snapshot.ts | 142 +++++++++- test/o123-git-commit-l3.spec.mjs | 370 ++++++++++++++++++++++++++ 2 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 test/o123-git-commit-l3.spec.mjs diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts index 4d4af746..1bea10c6 100644 --- a/src/safety/protected-path-snapshot.ts +++ b/src/safety/protected-path-snapshot.ts @@ -13,7 +13,9 @@ * `.git/config` content edits, changes confined to allow-listed benign keys * are not reported (O117, GIT_CONFIG_BENIGN_KEYS); any other key counts; add/delete/symlink swap or unparsable * content still reports the whole file (fail-closed). Normal - * git commit writes (index, objects, logs, ORIG_HEAD, …) are ignored. + * git commit writes (index, objects, logs, ORIG_HEAD, …) are ignored, and + * HEAD / refs/** changes are reported only when anomalous (O123: points + * outside refs/, not a commit id, symlink/dir swap). * * Snapshot unavailable (permission) → skip that path; never false-report * "modified". `exec_background` runs the same check on full process exit @@ -44,7 +46,22 @@ export interface ProtectedPathChange { reason?: GitConfigChangeReason; } -export type GitConfigChangeReason = 'symlink' | 'dir' | 'unparsable'; +export type GitConfigChangeReason = + | 'symlink' + | 'dir' + | 'unparsable' + /** O123: `.git/HEAD` / a ref points outside `refs/` (e.g. `ref: ../x`). */ + | 'ref-outside' + /** O123: ref / HEAD content is not a commit id or `ref: refs/…`. */ + | 'ref-invalid'; + +const ALL_REASONS: readonly GitConfigChangeReason[] = [ + 'symlink', + 'dir', + 'unparsable', + 'ref-outside', + 'ref-invalid', +]; /** Internal meta — never log `hash` in full. */ interface PathMeta { @@ -54,6 +71,8 @@ interface PathMeta { hash: string; /** O117: raw bytes of git config files only (in memory, never logged). */ raw?: Buffer; + /** O123: classification of `.git/HEAD` / `.git/refs/**` content (no content kept). */ + refState?: RefState; /** O117: entry type, so symlink/dir swaps of a config file can be named. */ type?: 'file' | 'symlink' | 'dir'; } @@ -392,6 +411,64 @@ function gitConfigChange( return { path: display, kind, keys }; } +/** + * O123 (QA path allow-list): `.git/HEAD` and loose refs are what ordinary + * `git commit` / `checkout -b` / `merge` rewrite. Their changes are NOT + * reported when the new content is well-formed — a commit id, or + * `ref: refs/` with no `..`, absolute or backslash parts. Anything + * else (`ref: ../x`, garbage, a symlink, a directory swap) is reported. + * This is command-independent: `git -c k=v commit`, `git -C dir commit` or + * a script that commits are judged by what they wrote, not by their text. + */ +type RefState = 'ok' | 'outside' | 'invalid'; + +export function isGitHeadOrLooseRefPath(abs: string): boolean { + return /(^|[\\/])\.git[\\/](?:HEAD|refs[\\/].+)$/.test(abs); +} + +export function classifyGitRefContent(text: string): RefState { + const t = text.replace(/\r?\n$/, ''); + if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(t)) return 'ok'; + const m = t.match(/^ref:\s*(.*)$/); + if (!m) return 'invalid'; + const target = m[1]!; + if ( + !target.startsWith('refs/') || + target.includes('\\') || + target.split('/').some((seg) => seg === '..' || seg === '.' || seg === '') || + /[\x00-\x1f]/.test(target) + ) { + return 'outside'; + } + return 'ok'; +} + +function isGitRefsDir(abs: string): boolean { + return /(^|[\\/])\.git[\\/]refs$/.test(abs); +} + +/** O123: HEAD / loose ref change → reported only when anomalous. */ +function gitRefChange( + display: string, + abs: string, + b: PathMeta | undefined, + a: PathMeta | undefined +): ProtectedPathChange | undefined { + const isHead = /(^|[\\/])HEAD$/.test(abs) && !/[\\/]refs[\\/]/.test(abs); + if (!a) { + if (!b) return undefined; + // Deleting a branch / tag ref is ordinary (`git branch -d`); HEAD is not. + return isHead ? { path: display, kind: 'deleted' } : undefined; + } + const kind: PathChangeKind = b ? 'modified' : 'added'; + if (b && b.hash === a.hash && b.size === a.size && b.type === a.type) return undefined; + if (a.type === 'symlink') return { path: display, kind, reason: 'symlink' }; + if (a.type === 'dir') return { path: display, kind, reason: 'dir' }; + if (a.refState === 'ok') return undefined; + if (a.refState === 'outside') return { path: display, kind, reason: 'ref-outside' }; + return { path: display, kind, reason: 'ref-invalid' }; +} + /** PathMeta | UNAVAILABLE | null(ENOENT). */ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { try { @@ -420,6 +497,9 @@ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { hash: crypto.createHash('sha256').update(buf).digest('hex'), type: 'file', ...(isGitConfigPath(abs) ? { raw: buf } : {}), + ...(isGitHeadOrLooseRefPath(abs) + ? { refState: classifyGitRefContent(buf.toString('utf8')) } + : {}), }; } catch (err) { const code = (err as NodeJS.ErrnoException)?.code; @@ -454,8 +534,9 @@ function walkFiles(dir: string, out: string[], depth = 0): void { /** * Collect absolute paths we watch under cwd + home. - * `.git` only: config, config.worktree, worktrees//config.worktree, HEAD, - * refs/**, hooks/**. + * `.git` only: config, config.worktree, worktrees//config(.worktree), + * HEAD, refs/**, hooks/**, info/**, objects/info/(http-)alternates, + * modules//config. */ export function listWatchedProtectedPaths(cwd: string, home?: string): string[] { const h = homeDir(home); @@ -490,6 +571,19 @@ export function listWatchedProtectedPaths(cwd: string, home?: string): string[] const refs = path.join(git, 'refs'); paths.push(refs); walkFiles(refs, paths); + // O123: keep reporting the rest of .git that can change behaviour: + // info/** (exclude, attributes, grafts…), object alternates, submodule + // and linked-worktree configs. + walkFiles(path.join(git, 'info'), paths); + for (const name of ['alternates', 'http-alternates']) { + paths.push(path.join(git, 'objects', 'info', name)); + } + for (const name of listDirSafe(path.join(git, 'modules'))) { + paths.push(path.join(git, 'modules', name, 'config')); + } + for (const name of listDirSafe(path.join(git, 'worktrees'))) { + paths.push(path.join(git, 'worktrees', name, 'config')); + } return [...new Set(paths)]; } @@ -568,6 +662,23 @@ export function diffProtectedPathSnapshots( // 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, '/'); + if (isGitRefsDir(abs)) { + // O123: the refs/ directory itself — only a type swap counts; its + // mtime moves on every branch create/delete. + if (b && a && b.type === a.type) continue; + if (b && a) { + changes.push({ + path: display, + kind: 'modified', + ...(a.type === 'symlink' ? { reason: 'symlink' as const } : {}), + }); + continue; + } + } else if (isGitHeadOrLooseRefPath(abs)) { + const c = gitRefChange(display, abs, b, a); + if (c) changes.push(c); + continue; + } // After the UNAVAILABLE guard, b/a are PathMeta | undefined. if (!bHas && aHas && a) { if (isGitConfigPath(abs)) { @@ -647,7 +758,8 @@ export function filterExpectedGitMetadataChanges( changes: readonly ProtectedPathChange[] ): ProtectedPathChange[] { if (!GIT_REF_UPDATING.test(command)) return [...changes]; - return changes.filter((c) => !isGitHeadsOrRefsPath(c.path)); + // O123: never drop an anomalous HEAD/ref change (`ref: ../x`, symlink, …). + return changes.filter((c) => c.reason !== undefined || !isGitHeadsOrRefsPath(c.path)); } export function formatProtectedPathModifiedNotice( @@ -710,7 +822,7 @@ export function parseProtectedPathModMarker(text: string): ProtectedPathChange[] const [p, kind, reason, keys] = line.slice(PROTECTED_PATH_DETAIL_MARKER.length + 1).split('\t'); if (!p) continue; const k = (['added', 'modified', 'deleted'] as const).find((x) => x === kind) ?? 'modified'; - const r = (['symlink', 'dir', 'unparsable'] as const).find((x) => x === reason); + const r = ALL_REASONS.find((x) => x === reason); const ks = (keys ?? '').split(',').filter(Boolean); details.set(p, { path: p, @@ -737,6 +849,16 @@ export function describeGitConfigChange(c: ProtectedPathChange, zh: boolean): st if (c.kind === 'deleted' && isGitConfigPath(c.path)) { return zh ? `${c.path} 被删除` : `${c.path} was deleted`; } + if (c.reason === 'ref-outside') { + return zh + ? `${c.path} 指向仓库外路径(不在 refs/ 下)` + : `${c.path} points outside the repository (not under refs/)`; + } + if (c.reason === 'ref-invalid') { + return zh + ? `${c.path} 内容不是提交号或 refs/ 引用` + : `${c.path} no longer holds a commit id or refs/ reference`; + } if (c.reason === 'symlink') { return zh ? `${c.path} 被换成软链` : `${c.path} was replaced by a symlink`; } @@ -746,6 +868,14 @@ export function describeGitConfigChange(c: ProtectedPathChange, zh: boolean): st if (c.reason === 'unparsable') { return zh ? `${c.path} 无法解析` : `${c.path} could not be parsed`; } + if (/(^|\/)\.git\/hooks\/./.test(c.path.replace(/\\/g, '/'))) { + const verb = zh + ? { added: '被新增', modified: '被改', deleted: '被删除' }[c.kind] + : { added: 'was added', modified: 'was changed', deleted: 'was deleted' }[c.kind]; + return zh + ? `${c.path} ${verb}(git 钩子,会执行命令)` + : `${c.path} ${verb} (git hook, runs commands)`; + } if (!c.keys?.length) return undefined; const shown = c.keys.slice(0, KEYS_SHOWN).map((k) => `\`${k}\``); const extra = c.keys.length > KEYS_SHOWN; diff --git a/test/o123-git-commit-l3.spec.mjs b/test/o123-git-commit-l3.spec.mjs new file mode 100644 index 00000000..c4f99e0b --- /dev/null +++ b/test/o123-git-commit-l3.spec.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +/** + * O123 — ordinary `git commit` / `checkout -b` / `merge` must not be reported + * by L3 as「已被修改 .git/refs/heads/…」. QA path allow-list: well-formed + * HEAD / loose-ref rewrites are silent regardless of how the command is + * spelled (`git -c k=v commit`, `git -C`, scripts). hooks/, config, info/, + * HEAD → `ref: ../x`, ref symlinks are still reported (exit 9). + */ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +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)), '..'); +if (!fs.existsSync(path.join(root, 'dist/safety/protected-path-snapshot.js'))) { + console.error('# FAIL: dist missing — run npm run build first'); + process.exit(1); +} +const { + takeProtectedPathSnapshot, + takeProtectedPathSnapshotAfter, + diffProtectedPathSnapshots, + filterExpectedGitMetadataChanges, + formatProtectedPathModifiedNotice, + parseProtectedPathModMarker, +} = await import('../dist/safety/protected-path-snapshot.js'); +const { createRunCompletionTracker } = await import('../dist/cli/run-completion.js'); +const { resolveRunOutcome, formatOutcomeLine } = await import('../dist/cli/run-outcome.js'); + +let failures = 0; +async function check(name, fn) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failures += 1; + console.error(`not ok - ${name}`); + console.error(err); + } +} + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o123-')); +process.on('exit', () => fs.rmSync(tmpRoot, { recursive: true, force: true })); +const gitEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: path.join(tmpRoot, 'no-global'), + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@e', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@e', +}; +function git(ws, ...args) { + const r = spawnSync('git', args, { cwd: ws, env: gitEnv, encoding: 'utf8' }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); +} +function makeRepo() { + const home = fs.mkdtempSync(path.join(tmpRoot, 'home-')); + const ws = fs.mkdtempSync(path.join(tmpRoot, 'ws-')); + git(ws, 'init', '-q', '-b', 'master'); + fs.writeFileSync(path.join(ws, 'a.txt'), '1\n'); + git(ws, 'add', 'a.txt'); + git(ws, 'commit', '-qm', 'init'); + return { home, ws }; +} +function changesAround(mutate) { + const { home, ws } = makeRepo(); + const before = takeProtectedPathSnapshot(ws, { home }); + mutate(ws); + return diffProtectedPathSnapshots(before, takeProtectedPathSnapshotAfter(before, { home })); +} +function lastLines(changes, command = 'sh x.sh') { + const parsed = parseProtectedPathModMarker( + formatProtectedPathModifiedNotice(changes, true, command) + ); + const out = {}; + for (const lang of ['zh', 'en']) { + const tracker = createRunCompletionTracker(); + tracker.protectedPathChanges = parsed.map((c) => ({ ...c, command })); + out[lang] = formatOutcomeLine( + resolveRunOutcome({ tracker, finalText: '已完成。', request: command }), + lang + ); + } + return out; +} + +// ── A. snapshot level: ordinary git writes are silent (no command filter) ── + +await check('A1: `git -c … commit --allow-empty` (UX a5 repro) → no change', () => { + const ch = changesAround((ws) => + git(ws, '-c', 'user.name=u', 'commit', '--allow-empty', '-qm', 'x') + ); + assert.deepEqual(ch, []); +}); +await check('A2: git add + commit of a real change → no change', () => { + const ch = changesAround((ws) => { + fs.writeFileSync(path.join(ws, 'a.txt'), '2\n'); + git(ws, 'add', 'a.txt'); + git(ws, 'commit', '-qm', 'two'); + }); + assert.deepEqual(ch, []); +}); +await check('A3: checkout -b, merge, branch -d, tag → no change', () => { + const ch = changesAround((ws) => { + git(ws, 'checkout', '-qb', 'feat'); + fs.writeFileSync(path.join(ws, 'b.txt'), 'b\n'); + git(ws, 'add', 'b.txt'); + git(ws, 'commit', '-qm', 'b'); + git(ws, 'checkout', '-q', 'master'); + git(ws, 'merge', '-q', '--no-ff', '-m', 'm', 'feat'); + git(ws, 'tag', 'v1'); + git(ws, 'branch', '-qd', 'feat'); + }); + assert.deepEqual(ch, []); +}); +await check('A4: detached HEAD (commit id) → no change', () => { + const ch = changesAround((ws) => git(ws, 'checkout', '-q', '--detach')); + assert.deepEqual(ch, []); +}); + +// ── B. still reported ───────────────────────────────────────────────── + +await check( + 'B1: .git/HEAD → `ref: ../x` reported with reason, even after git commit filter', + () => { + const ch = changesAround((ws) => + fs.writeFileSync(path.join(ws, '.git', 'HEAD'), 'ref: ../x\n') + ); + const head = ch.find((c) => c.path.endsWith('.git/HEAD')); + assert.ok(head, JSON.stringify(ch)); + assert.equal(head.reason, 'ref-outside'); + assert.equal(filterExpectedGitMetadataChanges('git commit -qm x', ch).length, ch.length); + const { zh, en } = lastLines(ch); + assert.match(zh, /\.git\/HEAD 指向仓库外路径/); + assert.match(en, /\.git\/HEAD points outside the repository/); + assert.doesNotMatch(zh + en, /未知|unknown/i); + } +); +await check('B2: garbage in a ref → reported', () => { + const ch = changesAround((ws) => + fs.writeFileSync(path.join(ws, '.git/refs/heads/master'), 'evil\n') + ); + assert.equal(ch.find((c) => c.path.endsWith('refs/heads/master'))?.reason, 'ref-invalid'); +}); +await check('B3: ref replaced by a symlink outside the repo → reported', () => { + const ch = changesAround((ws) => { + const ref = path.join(ws, '.git/refs/heads/master'); + const out = path.join(tmpRoot, `outside-${Date.now()}`); + fs.copyFileSync(ref, out); + fs.rmSync(ref); + fs.symlinkSync(out, ref); + }); + assert.equal(ch.find((c) => c.path.endsWith('refs/heads/master'))?.reason, 'symlink'); +}); +await check('B4: new hook after a commit → reported, says why (zh + en)', () => { + const ch = changesAround((ws) => { + git(ws, 'commit', '--allow-empty', '-qm', 'x'); + fs.writeFileSync(path.join(ws, '.git/hooks/pre-commit'), '#!/bin/sh\n', { mode: 0o755 }); + }); + const c = filterExpectedGitMetadataChanges('git commit -qm x', ch); + // The hooks/ directory entry is reported too (pre-existing); no refs/objects/index. + assert.deepEqual( + c.map((x) => x.path).sort(), + ['.git/hooks', '.git/hooks/pre-commit'], + JSON.stringify(ch) + ); + const { zh, en } = lastLines(c); + assert.match(zh, /卡在:.*\.git\/hooks\/pre-commit 被新增(git 钩子,会执行命令)/); + assert.match(en, /\.git\/hooks\/pre-commit was added \(git hook, runs commands\)/); + assert.doesNotMatch(zh, /refs|index|objects/); +}); +await check('B5: commit + core.hooksPath → only config named', () => { + const ch = changesAround((ws) => { + git(ws, 'commit', '--allow-empty', '-qm', 'x'); + git(ws, 'config', 'core.hooksPath', '/tmp/evil'); + }); + assert.deepEqual( + ch.map((c) => [c.path, c.keys]), + [['.git/config', ['core.hooksPath']]] + ); +}); +await check( + 'B6: info/, objects/info/alternates, modules/*/config, worktrees/*/config watched', + () => { + const ch = changesAround((ws) => { + fs.writeFileSync(path.join(ws, '.git/info/attributes'), '* filter=x\n'); + fs.mkdirSync(path.join(ws, '.git/objects/info'), { recursive: true }); + fs.writeFileSync(path.join(ws, '.git/objects/info/alternates'), '/tmp/o\n'); + }); + const paths = ch.map((c) => c.path).sort(); + assert.deepEqual(paths, ['.git/info/attributes', '.git/objects/info/alternates']); + const { home, ws } = makeRepo(); + fs.mkdirSync(path.join(ws, '.git/modules/sub'), { recursive: true }); + fs.writeFileSync(path.join(ws, '.git/modules/sub/config'), ''); + const before = takeProtectedPathSnapshot(ws, { home }); + fs.writeFileSync(path.join(ws, '.git/modules/sub/config'), '[core]\n\tfsmonitor = /tmp/p\n'); + const ch2 = diffProtectedPathSnapshots( + before, + takeProtectedPathSnapshotAfter(before, { home }) + ); + assert.ok( + ch2.some((c) => c.path === '.git/modules/sub/config'), + JSON.stringify(ch2) + ); + } +); + +// ── C. process-level `moss -p` ──────────────────────────────────────── + +function startFake(calls) { + 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 < calls.length) { + res.write( + chunk({ + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `call_${n}`, + type: 'function', + function: { name: 'exec', arguments: JSON.stringify({ command: calls[n] }) }, + }, + ], + }) + ); + res.write(chunk({}, 'tool_calls')); + } else { + res.write(chunk({ role: 'assistant', content: '已提交,完成。' })); + res.write(chunk({}, 'stop')); + } + res.end('data: [DONE]\n\n'); + }); + }); + return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server))); +} + +function runCli(args, env, cwd) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [path.join(root, 'dist', 'cli.js'), ...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'), 60000); + child.on('close', (code) => { + clearTimeout(t); + resolve({ code, out, err }); + }); + }); +} + +async function pRun(lang, calls, prep) { + const home = fs.mkdtempSync(path.join(tmpRoot, 'phome-')); + const proj = fs.mkdtempSync(path.join(tmpRoot, 'pproj-')); + const cfgDir = path.join(home, '.config', 'moss'); + fs.mkdirSync(cfgDir, { recursive: true }); + git(proj, 'init', '-q', '-b', 'master'); + git(proj, 'commit', '-q', '--allow-empty', '-m', 'init'); + if (prep) prep(proj); + const server = await startFake(calls); + const env = { + PATH: process.env.PATH, + HOME: home, + MOSS_CONFIG_DIR: cfgDir, + 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', + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@e', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@e', + }; + for (const [k, v] of [ + ['provider', 'openai-compatible'], + ['baseUrl', `http://127.0.0.1:${server.address().port}`], + ['model', 'fake-model'], + ['apiKeyEnv', 'FAKE_OAI_KEY'], + ]) + await runCli(['config', 'set', k, v], env, proj); + const r = await runCli(['--trust-workspace', '--lang', lang, '-p', 'commit it'], env, proj); + server.close(); + const n = spawnSync('git', ['rev-list', '--count', '--all'], { + cwd: proj, + env: gitEnv, + encoding: 'utf8', + }); + return { + ...r, + last: r.out.trim().split('\n').pop() ?? '', + commits: Number(n.stdout.trim()), + proj, + }; +} + +for (const lang of ['zh', 'en']) { + await check( + `C1 (${lang}): moss -p git add+commit, commit --allow-empty, checkout -b, merge → exit 0`, + async () => { + const r = await pRun(lang, [ + "echo hi > a.txt && git add a.txt && git commit -qm 'add a'", + 'git -c user.name=u commit --allow-empty -qm x', + 'git checkout -qb feat && echo b > b.txt && git add b.txt && git commit -qm b && git checkout -q master && git merge -q --no-ff -m m feat', + ]); + assert.ok(r.commits >= 5, `precondition: commits ran (${r.commits})`); + assert.equal(r.code, 0, `${r.last}\n${r.err.slice(-400)}`); + assert.doesNotMatch(r.last, /未完成|Incomplete|已被修改|modified/); + } + ); +} +await check( + 'C2 (zh): moss -p commit + write .git/hooks/x → exit 9, names the hook only', + async () => { + // Script file: the O103 pre-block does not see the path; L3 must. + const r = await pRun('zh', ['git commit -q --allow-empty -m x && sh w.sh'], (p) => + fs.writeFileSync(path.join(p, 'w.sh'), "d=.git; printf '#!/bin/sh\\n' > $d/hooks/x\n") + ); + assert.equal(r.code, 9, `${r.last}\n${r.err.slice(-400)}`); + assert.match(r.last, /\.git\/hooks\/x/); + assert.doesNotMatch(r.last, /refs|objects|index/); + } +); +await check('C3 (en): moss -p HEAD → ref: ../x via script → exit 9', async () => { + const r = await pRun('en', ['git commit -q --allow-empty -m x && sh w.sh'], (p) => + fs.writeFileSync(path.join(p, 'w.sh'), "d=.git; printf 'ref: ../x\\n' > $d/HEAD\n") + ); + assert.equal(r.code, 9, `${r.last}\n${r.err.slice(-400)}`); + assert.match(r.last, /\.git\/HEAD points outside/); +}); +await check('C4 (zh): moss -p script sets core.hooksPath → exit 9', async () => { + const r = await pRun('zh', ['git commit -q --allow-empty -m x && sh x.sh'], (p) => + fs.writeFileSync(path.join(p, 'x.sh'), 'git config core.hooksPath /tmp/evilhooks\n') + ); + assert.equal(r.code, 9, `${r.last}\n${r.err.slice(-400)}`); + assert.match(r.last, /core\.hooksPath/); +}); + +if (failures > 0) { + console.error(`# ${failures} failure(s)`); + process.exit(1); +} +console.log('# o123 all ok'); From 22b08883374c610ed58d666782dbe438e19e8081 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 12:08:31 +0800 Subject: [PATCH 5/8] fix(safety): O127 L3 snapshot follows linked-worktree .git file to gitdir/commondir --- src/safety/protected-path-snapshot.ts | 165 ++++++++-- test/o127-linked-worktree-exec-keys.spec.mjs | 307 +++++++++++++++++++ 2 files changed, 447 insertions(+), 25 deletions(-) create mode 100644 test/o127-linked-worktree-exec-keys.spec.mjs diff --git a/src/safety/protected-path-snapshot.ts b/src/safety/protected-path-snapshot.ts index 1bea10c6..c0b7e4db 100644 --- a/src/safety/protected-path-snapshot.ts +++ b/src/safety/protected-path-snapshot.ts @@ -53,7 +53,11 @@ export type GitConfigChangeReason = /** O123: `.git/HEAD` / a ref points outside `refs/` (e.g. `ref: ../x`). */ | 'ref-outside' /** O123: ref / HEAD content is not a commit id or `ref: refs/…`. */ - | 'ref-invalid'; + | 'ref-invalid' + /** O127: the worktree's `.git` file was rewritten (gitdir pointer changed). */ + | 'gitlink-rewritten' + /** O127: `.git` file's gitdir / commondir does not exist or cannot be read. */ + | 'gitlink-missing'; const ALL_REASONS: readonly GitConfigChangeReason[] = [ 'symlink', @@ -61,6 +65,8 @@ const ALL_REASONS: readonly GitConfigChangeReason[] = [ 'unparsable', 'ref-outside', 'ref-invalid', + 'gitlink-rewritten', + 'gitlink-missing', ]; /** Internal meta — never log `hash` in full. */ @@ -75,6 +81,8 @@ interface PathMeta { refState?: RefState; /** O117: entry type, so symlink/dir swaps of a config file can be named. */ type?: 'file' | 'symlink' | 'dir'; + /** O127: for a worktree `.git` *file*, whether its gitdir/commondir resolve. */ + gitLink?: 'ok' | 'missing'; } /** Sentinel: path exists but meta could not be read (permission). */ @@ -423,7 +431,66 @@ function gitConfigChange( type RefState = 'ok' | 'outside' | 'invalid'; export function isGitHeadOrLooseRefPath(abs: string): boolean { - return /(^|[\\/])\.git[\\/](?:HEAD|refs[\\/].+)$/.test(abs); + // O127: a linked worktree's own HEAD lives at .git/worktrees//HEAD. + return /(^|[\\/])\.git[\\/](?:HEAD|refs[\\/].+|worktrees[\\/][^\\/]+[\\/]HEAD)$/.test(abs); +} + +/** + * O127: where git really keeps metadata for `ws`. A linked worktree (or + * `--separate-git-dir` checkout) has a `.git` *file* (`gitdir: `); + * per-worktree state (HEAD, config.worktree) lives in that gitdir and shared + * state (config, hooks, refs, info…) in its `commondir`. Read with plain fs — + * never runs git in the repo under test. `null` dirs = cannot resolve. + */ +export interface GitDirs { + /** `.git` is a file (linked worktree / separate git dir). */ + linked: boolean; + gitDir: string | null; + commonDir: string | null; +} + +export function resolveGitDirs(ws: string): GitDirs { + const dotGit = path.join(ws, '.git'); + let st: fs.Stats; + try { + st = fs.lstatSync(dotGit); + } catch { + return { linked: false, gitDir: dotGit, commonDir: dotGit }; + } + if (!st.isFile()) return { linked: false, gitDir: dotGit, commonDir: dotGit }; + let text: string; + try { + text = fs.readFileSync(dotGit, 'utf8'); + } catch { + return { linked: true, gitDir: null, commonDir: null }; + } + const m = text.match(/^gitdir:[ \t]*(.+?)[ \t]*\r?$/m); + if (!m) return { linked: true, gitDir: null, commonDir: null }; + const gitDir = path.resolve(ws, m[1]!); + if (!isRealDir(gitDir)) return { linked: true, gitDir: null, commonDir: null }; + let commonDir = gitDir; + try { + const c = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim(); + if (c) commonDir = path.resolve(gitDir, c); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') { + return { linked: true, gitDir, commonDir: null }; + } + } + if (!isRealDir(commonDir)) return { linked: true, gitDir, commonDir: null }; + return { linked: true, gitDir, commonDir }; +} + +function isRealDir(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +function isDotGitFile(abs: string): boolean { + return path.basename(abs) === '.git'; } export function classifyGitRefContent(text: string): RefState { @@ -497,6 +564,14 @@ function metaOf(abs: string): PathMeta | typeof UNAVAILABLE | null { hash: crypto.createHash('sha256').update(buf).digest('hex'), type: 'file', ...(isGitConfigPath(abs) ? { raw: buf } : {}), + ...(isDotGitFile(abs) && st.isFile() + ? { + gitLink: (() => { + const d = resolveGitDirs(path.dirname(abs)); + return d.gitDir && d.commonDir ? ('ok' as const) : ('missing' as const); + })(), + } + : {}), ...(isGitHeadOrLooseRefPath(abs) ? { refState: classifyGitRefContent(buf.toString('utf8')) } : {}), @@ -532,29 +607,7 @@ function walkFiles(dir: string, out: string[], depth = 0): void { } } -/** - * Collect absolute paths we watch under cwd + home. - * `.git` only: config, config.worktree, worktrees//config(.worktree), - * HEAD, refs/**, hooks/**, info/**, objects/info/(http-)alternates, - * modules//config. - */ -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'); +function watchGitDir(git: string, paths: string[]): void { for (const name of ['config', 'config.worktree', 'HEAD']) { paths.push(path.join(git, name)); } @@ -584,6 +637,40 @@ export function listWatchedProtectedPaths(cwd: string, home?: string): string[] for (const name of listDirSafe(path.join(git, 'worktrees'))) { paths.push(path.join(git, 'worktrees', name, 'config')); } +} + +/** + * Collect absolute paths we watch under cwd + home. + * `.git` only: config, config.worktree, worktrees//config(.worktree), + * HEAD, refs/**, hooks/**, info/**, objects/info/(http-)alternates, + * modules//config. + */ +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 dirs = resolveGitDirs(ws); + if (dirs.linked) { + // O127: the `.git` file itself (pointer rewrite / gitdir gone is reported) + // plus the real per-worktree gitdir and the shared commondir. + paths.push(path.join(ws, '.git')); + if (dirs.gitDir) { + for (const name of ['config.worktree', 'HEAD']) paths.push(path.join(dirs.gitDir, name)); + } + } + if (dirs.commonDir) watchGitDir(dirs.commonDir, paths); return [...new Set(paths)]; } @@ -662,6 +749,23 @@ export function diffProtectedPathSnapshots( // 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, '/'); + if (isDotGitFile(abs)) { + // O127: worktree `.git` file. Any content/type change, or its gitdir + // vanishing, is reported (fail-closed). + if (b && !a) { + changes.push({ path: display, kind: 'deleted' }); + continue; + } + if (!a) continue; + const kind: PathChangeKind = b ? 'modified' : 'added'; + if (a.type === 'symlink') changes.push({ path: display, kind, reason: 'symlink' }); + else if (a.type === 'dir') changes.push({ path: display, kind, reason: 'dir' }); + else if (b && (b.hash !== a.hash || b.type !== a.type)) + changes.push({ path: display, kind, reason: 'gitlink-rewritten' }); + else if (a.gitLink === 'missing' && (!b || b.gitLink !== 'missing')) + changes.push({ path: display, kind, reason: 'gitlink-missing' }); + continue; + } if (isGitRefsDir(abs)) { // O123: the refs/ directory itself — only a type swap counts; its // mtime moves on every branch create/delete. @@ -742,6 +846,7 @@ function isGitHeadsOrRefsPath(p: string): boolean { const n = p.replace(/\\/g, '/'); return ( /(^|\/)\.git\/HEAD$/i.test(n) || + /(^|\/)\.git\/worktrees\/[^/]+\/HEAD$/i.test(n) || /(^|\/)\.git\/refs\//i.test(n) || n === 'HEAD' || n.startsWith('refs/') @@ -859,6 +964,16 @@ export function describeGitConfigChange(c: ProtectedPathChange, zh: boolean): st ? `${c.path} 内容不是提交号或 refs/ 引用` : `${c.path} no longer holds a commit id or refs/ reference`; } + if (c.reason === 'gitlink-rewritten') { + return zh + ? `${c.path} 被改写(worktree 的 gitdir 指向变了)` + : `${c.path} was rewritten (worktree gitdir pointer changed)`; + } + if (c.reason === 'gitlink-missing') { + return zh + ? `${c.path} 指向的 gitdir 不存在或读不了` + : `${c.path} points to a gitdir that is missing or unreadable`; + } if (c.reason === 'symlink') { return zh ? `${c.path} 被换成软链` : `${c.path} was replaced by a symlink`; } diff --git a/test/o127-linked-worktree-exec-keys.spec.mjs b/test/o127-linked-worktree-exec-keys.spec.mjs new file mode 100644 index 00000000..a4b30699 --- /dev/null +++ b/test/o127-linked-worktree-exec-keys.spec.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +/** + * O127 — L3 snapshot must follow a linked worktree's `.git` file to the real + * gitdir + commondir: executable keys (core.sshCommand, core.fsmonitor, + * config.worktree hooksPath) changed from inside a linked worktree are + * reported; allow-listed keys and ordinary commits stay silent; a rewritten + * `.git` file / missing gitdir is reported (fail-closed). + */ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import http from 'node:http'; +import fs from 'node:fs'; +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)), '..'); +if (!fs.existsSync(path.join(root, 'dist/safety/protected-path-snapshot.js'))) { + console.error('# FAIL: dist missing — run npm run build first'); + process.exit(1); +} +const { + takeProtectedPathSnapshot, + takeProtectedPathSnapshotAfter, + diffProtectedPathSnapshots, + filterExpectedGitMetadataChanges, + formatProtectedPathModifiedNotice, +} = await import('../dist/safety/protected-path-snapshot.js'); +const { findShellProtectedWrites } = await import('../dist/safety/protected-paths.js'); + +let failures = 0; +async function check(name, fn) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failures += 1; + console.error(`not ok - ${name}`); + console.error(err); + } +} + +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'moss-o127-')); +process.on('exit', () => fs.rmSync(tmpRoot, { recursive: true, force: true })); +const gitEnv = { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: path.join(tmpRoot, 'no-global'), +}; +function git(cwd, ...args) { + const r = spawnSync('git', args, { cwd, env: gitEnv, encoding: 'utf8' }); + assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`); + return r.stdout; +} +const ID = ['-c', 'user.name=t', '-c', 'user.email=t@e']; + +/** main repo + linked worktree outside it. */ +function makeLinked() { + const base = fs.mkdtempSync(path.join(tmpRoot, 'r-')); + const home = path.join(base, 'home'); + fs.mkdirSync(home); + const main = path.join(base, 'main'); + git(base, 'init', '-q', main); + git(main, ...ID, 'commit', '-q', '--allow-empty', '-m', 'i', '--no-verify'); + const wt = path.join(base, 'wt'); + git(main, 'worktree', 'add', '-q', wt, '-b', 'wt'); + assert.ok(fs.lstatSync(path.join(wt, '.git')).isFile(), 'precondition: .git is a file'); + return { home, main, wt }; +} +function changes(r, mutate, command = '') { + const before = takeProtectedPathSnapshot(r.wt, { home: r.home }); + mutate(r); + const after = takeProtectedPathSnapshotAfter(before, { home: r.home }); + return filterExpectedGitMetadataChanges(command, diffProtectedPathSnapshots(before, after)); +} + +// ── A. executable keys from a linked worktree ────────────────────────── +for (const [key, value] of [ + ['core.sshCommand', 'sh /tmp/p'], + ['core.fsmonitor', '/tmp/p'], + ['CORE.FsMonitor', '/tmp/p'], + ['remote.origin.uploadpack', 'sh /tmp/p'], +]) { + await check(`A1: linked worktree \`git config ${key}\` → reported with key`, () => { + const r = makeLinked(); + const ch = changes(r, (x) => git(x.wt, 'config', key, value)); + const c = ch.find((y) => y.path.endsWith('.git/config')); + assert.ok(c, JSON.stringify(ch)); + assert.equal(c.keys.length, 1); + assert.equal(c.keys[0].toLowerCase(), key.toLowerCase()); + const zh = formatProtectedPathModifiedNotice(ch, true, `git config ${key}`); + assert.match(zh, /改了 `.+`(不在安全清单内)/); + }); +} + +await check('A2: linked worktree config.worktree core.hooksPath → reported', () => { + const r = makeLinked(); + const ch = changes(r, (x) => { + git(x.wt, 'config', 'extensions.worktreeConfig', 'true'); + git(x.wt, 'config', '--worktree', 'core.hooksPath', '/tmp/evil'); + assert.equal(git(x.wt, 'config', 'core.hooksPath').trim(), '/tmp/evil'); + }); + const c = ch.find((y) => /\.git\/worktrees\/[^/]+\/config\.worktree$/.test(y.path)); + assert.ok(c, JSON.stringify(ch)); + assert.deepEqual(c.keys, ['core.hooksPath']); +}); + +await check( + 'A3: pre-check (事前) still blocks hooksPath / direct gitdir writes from worktree', + () => { + const r = makeLinked(); + const ctx = { cwd: r.wt, workspaceRoot: r.wt }; + assert.ok(findShellProtectedWrites('git config --worktree core.hooksPath /tmp/e', ctx).length); + const name = fs.readdirSync(path.join(r.main, '.git', 'worktrees'))[0]; + const p = path.join(r.main, '.git', 'worktrees', name, 'config.worktree'); + assert.ok(findShellProtectedWrites(`echo x >> ${p}`, ctx).length); + assert.ok(findShellProtectedWrites(`echo x > .git`, ctx).length); + } +); + +// ── B. benign keys and ordinary commits stay silent ──────────────────── +await check('B1: linked worktree allow-listed keys → silent', () => { + const r = makeLinked(); + const ch = changes(r, (x) => { + git(x.wt, 'config', 'user.name', 'x'); + git(x.wt, 'config', 'color.ui', 'auto'); + }); + assert.deepEqual(ch, []); +}); +await check('B2: linked worktree git commit / checkout -b → silent', () => { + const r = makeLinked(); + const ch = changes(r, (x) => { + git(x.wt, ...ID, 'commit', '-q', '--allow-empty', '-m', 'c', '--no-verify'); + git(x.wt, 'checkout', '-q', '-b', 'feat'); + }); + assert.deepEqual(ch, []); +}); + +// ── C. fail-closed on the .git file ──────────────────────────────────── +await check('C1: .git file rewritten → reported with reason', () => { + const r = makeLinked(); + const other = fs.mkdtempSync(path.join(tmpRoot, 'evil-')); + const ch = changes(r, (x) => fs.writeFileSync(path.join(x.wt, '.git'), `gitdir: ${other}\n`)); + const c = ch.find((y) => y.path === '.git'); + assert.equal(c?.reason, 'gitlink-rewritten', JSON.stringify(ch)); + assert.match(formatProtectedPathModifiedNotice(ch, true), /\.git 被改写/); +}); +await check('C2: gitdir removed → reported (gitdir missing)', () => { + const r = makeLinked(); + const name = fs.readdirSync(path.join(r.main, '.git', 'worktrees'))[0]; + const ch = changes(r, (x) => + fs.rmSync(path.join(x.main, '.git', 'worktrees', name), { recursive: true, force: true }) + ); + const c = ch.find((y) => y.path === '.git'); + assert.equal(c?.reason, 'gitlink-missing', JSON.stringify(ch)); + assert.match(formatProtectedPathModifiedNotice(ch, true), /gitdir 不存在/); +}); +await check('C3: unparsable shared config from worktree → reported', () => { + const r = makeLinked(); + const ch = changes(r, (x) => fs.appendFileSync(path.join(x.main, '.git', 'config'), '\n[[[\n')); + const c = ch.find((y) => y.path.endsWith('.git/config')); + assert.equal(c?.reason, 'unparsable', JSON.stringify(ch)); +}); +await check('C4: .git file replaced by symlink → reported', () => { + const r = makeLinked(); + const ch = changes(r, (x) => { + const g = path.join(x.wt, '.git'); + const t = path.join(x.wt, '..', 'gitlink-copy'); + fs.copyFileSync(g, t); + fs.rmSync(g); + fs.symlinkSync(t, g); + }); + assert.equal(ch.find((y) => y.path === '.git')?.reason, 'symlink', JSON.stringify(ch)); +}); + +// ── D. main repo unchanged ───────────────────────────────────────────── +await check('D1: main checkout: commit silent, sshCommand reported', () => { + const r = makeLinked(); + const before = takeProtectedPathSnapshot(r.main, { home: r.home }); + git(r.main, ...ID, 'commit', '-q', '--allow-empty', '-m', 'm', '--no-verify'); + git(r.main, 'config', 'core.sshCommand', 'sh /tmp/p'); + const ch = diffProtectedPathSnapshots( + before, + takeProtectedPathSnapshotAfter(before, { home: r.home }) + ); + assert.deepEqual( + ch.map((c) => [c.path, c.keys]), + [['.git/config', ['core.sshCommand']]] + ); +}); + +// ── G. process-level `moss -p` in a linked worktree ──────────────────── +function startFake(calls) { + 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 < calls.length) { + res.write( + chunk({ + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `call_${n}`, + type: 'function', + function: { name: 'exec', arguments: JSON.stringify({ command: calls[n] }) }, + }, + ], + }) + ); + res.write(chunk({}, 'tool_calls')); + } else { + res.write(chunk({ role: 'assistant', content: '已设置 user.name,完成。' })); + res.write(chunk({}, 'stop')); + } + res.end('data: [DONE]\n\n'); + }); + }); + return new Promise((r) => server.listen(0, '127.0.0.1', () => r(server))); +} + +function runCli(args, env, cwd) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [path.join(root, 'dist', 'cli.js'), ...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'), 60000); + child.on('close', (code) => { + clearTimeout(t); + resolve({ code, out, err }); + }); + }); +} + +async function pRun(lang, command) { + const r = makeLinked(); + const cfgDir = path.join(r.home, '.config', 'moss'); + fs.mkdirSync(cfgDir, { recursive: true }); + const server = await startFake([command]); + const env = { + PATH: process.env.PATH, + HOME: r.home, + MOSS_CONFIG_DIR: cfgDir, + MOSS_RUNTIME_DIR: path.join(r.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:${server.address().port}`], + ['model', 'fake-model'], + ['apiKeyEnv', 'FAKE_OAI_KEY'], + ]) + await runCli(['config', 'set', k, v], env, r.wt); + const res = await runCli(['--trust-workspace', '--lang', lang, '-p', 'set name'], env, r.wt); + server.close(); + return { ...res, last: res.out.trim().split('\n').pop() ?? '', r }; +} + +for (const lang of ['zh', 'en']) { + await check( + `G1 (${lang}): moss -p in worktree, git config core.sshCommand → exit 9`, + async () => { + const res = await pRun(lang, 'git config core.sshCommand "sh /tmp/p"'); + assert.equal(git(res.r.wt, 'config', 'core.sshCommand').trim(), 'sh /tmp/p'); + assert.equal(res.code, 9, `${res.last}\n${res.err.slice(-400)}`); + assert.match(res.out + res.err, /core\.sshCommand/); + } + ); + await check(`G2 (${lang}): moss -p in worktree, git config user.name x → exit 0`, async () => { + const res = await pRun(lang, 'git config user.name x'); + assert.equal(git(res.r.wt, 'config', 'user.name').trim(), 'x'); + assert.equal(res.code, 0, `${res.last}\n${res.err.slice(-400)}`); + assert.doesNotMatch(res.last, /未完成|Incomplete|已被修改|modified/); + }); +} + +if (failures > 0) { + console.error(`# ${failures} failure(s)`); + process.exit(1); +} +console.log('# o127 all ok'); From 1dee51039284c4bc128fbe2684bf38b3f8d02314 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 12:46:14 +0800 Subject: [PATCH 6/8] fix(safety): O127 pre-check no longer treats a bare cd operand (workspace root) as an ancestor-delete target --- src/safety/protected-paths.ts | 37 +++++++++++- test/o127-linked-worktree-exec-keys.spec.mjs | 63 +++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index 6c8e33a9..576d63a8 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -525,6 +525,32 @@ function gitHooksPathHits(command: string, ctx: ProtectedPathContext): Protected return hits; } +/** + * O127: absolute paths that appear in the command ONLY as the operand of a + * plain `cd ` / `pushd ` (no `$` / backtick). If the same path is + * also named anywhere else in the command it is not returned. + */ +function cdOnlyOperands(command: string, cwd: string): Set { + const cd = new Set(); + const re = + /(?:^|&&|\|\||;|\n|\()\s*(?:cd|pushd)\s+(?:--\s+)?(['"]?)([^\s'"`$;&|()<>]+)\1(?=\s*(?:$|&&|\|\||;|\n|\)))/g; + let rest = command; + for (const m of command.matchAll(re)) { + const raw = m[2]!; + if (raw.startsWith('~') || raw === '-') continue; + cd.add(path.resolve(cwd, raw)); + rest = rest.replace(m[0], ' '); + } + if (cd.size === 0) return cd; + for (const word of rest.split(/[\s'"`;&|()<>=]+/)) { + if (!word) continue; + const abs = path.resolve(cwd, word); + cd.delete(abs); + cd.delete(abs.replace(/[\\/]+$/, '')); + } + return cd; +} + export function findShellProtectedWrites( command: string, ctx: ProtectedPathContext @@ -549,12 +575,21 @@ export function findShellProtectedWrites( }); } if (hits.length === 0 && uncertain) { + // O127 (S2): `cd && node x.mjs` is uncertain (a script + // could write anything), and the `cd` operand — the workspace root — was + // then judged as "deleting an ancestor of .git" and blocked. A `cd` + // operand is never written; it still gets the normal protected-path + // classification; only the ancestor-delete rule is skipped, and only when + // the path is named nowhere else in the command. + const cdOnly = cdOnlyOperands(command, ctx.cwd); // Prefer absolute mentions from the shared detector; fall back to text regex. for (const m of mentions) { // O103: a word still holding `$VAR` / `$(…)` is not a path we resolved; // expanding it to '' used to misreport the workspace root as git internals. if (/\$|`/.test(m)) continue; - const hit = classifyProtectedPath(m, ctx) ?? deletesProtectedAncestor(m, ctx); + const isCd = cdOnly.has(path.resolve(ctx.cwd, m)); + const hit = + classifyProtectedPath(m, ctx) ?? (isCd ? undefined : deletesProtectedAncestor(m, ctx)); if (!hit || seen.has(hit.resolved)) continue; seen.add(hit.resolved); hits.push({ diff --git a/test/o127-linked-worktree-exec-keys.spec.mjs b/test/o127-linked-worktree-exec-keys.spec.mjs index a4b30699..2ffe6962 100644 --- a/test/o127-linked-worktree-exec-keys.spec.mjs +++ b/test/o127-linked-worktree-exec-keys.spec.mjs @@ -173,6 +173,35 @@ await check('C4: .git file replaced by symlink → reported', () => { assert.equal(ch.find((y) => y.path === '.git')?.reason, 'symlink', JSON.stringify(ch)); }); +// ── P. pre-check: `cd ` operand is not a write target ─────────── +await check('P1: cd && node script → no pre-check hit', () => { + const r = makeLinked(); + for (const root of [r.wt, r.main]) { + const ctx = { cwd: root, workspaceRoot: root }; + assert.deepEqual(findShellProtectedWrites(`cd ${root} && node x.mjs`, ctx), []); + assert.deepEqual( + findShellProtectedWrites(`cd ${root} && node --test t.mjs 2>&1 | tail -25`, ctx), + [] + ); + } +}); +await check('P2: still blocked — root as script arg, rm of root, writes under .git', () => { + const r = makeLinked(); + const ws = r.wt; + const ctx = { cwd: ws, workspaceRoot: ws }; + for (const c of [ + `cd ${ws} && node x.mjs ${ws}`, + `cd ${ws} && node x.mjs ${ws}/`, + `node x.mjs ${ws}`, + `cd ${ws} && rm -rf ${ws}`, + `cd ${ws} && bash x.sh > .git`, + `cd ${r.main}/.git && node x.mjs`, + `cd ${ws} && node -e "require('fs').rmSync('.git')"`, + ]) { + assert.ok(findShellProtectedWrites(c, ctx).length > 0, c); + } +}); + // ── D. main repo unchanged ───────────────────────────────────────────── await check('D1: main checkout: commit silent, sshCommand reported', () => { const r = makeLinked(); @@ -190,6 +219,7 @@ await check('D1: main checkout: commit silent, sshCommand reported', () => { }); // ── G. process-level `moss -p` in a linked worktree ──────────────────── +const toolResults = []; function startFake(calls) { const server = http.createServer((req, res) => { let raw = ''; @@ -206,7 +236,10 @@ function startFake(calls) { } catch { /* ignore */ } - const n = (body.messages ?? []).filter((m) => m.role === 'tool').length; + const toolMsgs = (body.messages ?? []).filter((m) => m.role === 'tool'); + for (const m of toolMsgs) + toolResults.push(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)); + const n = toolMsgs.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' }); @@ -258,6 +291,10 @@ async function pRun(lang, command) { const r = makeLinked(); const cfgDir = path.join(r.home, '.config', 'moss'); fs.mkdirSync(cfgDir, { recursive: true }); + if (typeof command === 'function') { + fs.writeFileSync(path.join(r.wt, 'ro.mjs'), "console.log('read only');\n"); + command = command(r); + } const server = await startFake([command]); const env = { PATH: process.env.PATH, @@ -300,6 +337,30 @@ for (const lang of ['zh', 'en']) { }); } +// S2 (自举): `cd && node …` was blocked before it ran +// (" is a protected path (git repository internals)"). +for (const [label, command] of [ + ['git status', 'git status'], + ['ls -la', 'ls -la'], + ['cat .git', 'cat .git'], + ['git log', 'git log --oneline -1'], + ['cd && git status', (r) => `cd ${r.wt} && git status`], + ['cd && node ro.mjs', (r) => `cd ${r.wt} && node ro.mjs`], + ['cd && node --test … | tail', (r) => `cd ${r.wt} && node --test ro.mjs 2>&1 | tail -25`], +]) { + await check( + `G3: moss -p in worktree root, read-only \`${label}\` → exit 0, not blocked`, + async () => { + toolResults.length = 0; + const res = await pRun('zh', command); + const all = res.out + res.err + '\n' + toolResults.join('\n'); + assert.ok(toolResults.length > 0, 'precondition: command ran'); + assert.equal(res.code, 0, `${res.last}\n${all.slice(-600)}`); + assert.doesNotMatch(all, /被拦|已拦截|已被修改|受保护路径已|protected path|moss-o101/i); + } + ); +} + if (failures > 0) { console.error(`# ${failures} failure(s)`); process.exit(1); From 971994a09fced1b6ea60683ab8563115f0b1ff7b Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 13:27:48 +0800 Subject: [PATCH 7/8] fix(safety): O127 cd exemption only when target realpath is the workspace root (cd / and cd ~ still prompt) --- src/safety/protected-paths.ts | 18 +++++++++++++++++- test/o127-linked-worktree-exec-keys.spec.mjs | 9 +++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index 576d63a8..041596b7 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -530,6 +530,20 @@ function gitHooksPathHits(command: string, ctx: ProtectedPathContext): Protected * plain `cd ` / `pushd ` (no `$` / backtick). If the same path is * also named anywhere else in the command it is not returned. */ +function realOrResolved(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return path.resolve(p); + } +} + +function isWorkspaceRootPath(p: string, ctx: ProtectedPathContext): boolean { + const root = ctx.workspaceRoot; + if (!root) return false; + return realOrResolved(path.resolve(ctx.cwd, p)) === realOrResolved(root); +} + function cdOnlyOperands(command: string, cwd: string): Set { const cd = new Set(); const re = @@ -587,7 +601,9 @@ export function findShellProtectedWrites( // O103: a word still holding `$VAR` / `$(…)` is not a path we resolved; // expanding it to '' used to misreport the workspace root as git internals. if (/\$|`/.test(m)) continue; - const isCd = cdOnly.has(path.resolve(ctx.cwd, m)); + // PM 10-12: exempt only when the cd target IS the workspace/worktree root + // (realpath); `cd /`, `cd ~`, `cd ..` still hit the ancestor rule. + const isCd = cdOnly.has(path.resolve(ctx.cwd, m)) && isWorkspaceRootPath(m, ctx); const hit = classifyProtectedPath(m, ctx) ?? (isCd ? undefined : deletesProtectedAncestor(m, ctx)); if (!hit || seen.has(hit.resolved)) continue; diff --git a/test/o127-linked-worktree-exec-keys.spec.mjs b/test/o127-linked-worktree-exec-keys.spec.mjs index 2ffe6962..7e587237 100644 --- a/test/o127-linked-worktree-exec-keys.spec.mjs +++ b/test/o127-linked-worktree-exec-keys.spec.mjs @@ -202,6 +202,15 @@ await check('P2: still blocked — root as script arg, rm of root, writes under } }); +await check('P3 (PM 10-12): cd not blocked; cd / and cd ~ still prompt', () => { + const r = makeLinked(); + const ws = r.wt; + const ctx = { cwd: ws, workspaceRoot: ws, home: path.dirname(ws) }; + assert.deepEqual(findShellProtectedWrites(`cd ${ws} && node x`, ctx), []); + assert.ok(findShellProtectedWrites('cd / && node x', ctx).length > 0, 'cd /'); + assert.ok(findShellProtectedWrites('cd ~ && node x', ctx).length > 0, 'cd ~'); +}); + // ── D. main repo unchanged ───────────────────────────────────────────── await check('D1: main checkout: commit silent, sshCommand reported', () => { const r = makeLinked(); From 3ec69ed06d4c8b6501f853ccfb9acff4e8c38d6c Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Mon, 12 Oct 2026 14:16:33 +0800 Subject: [PATCH 8/8] fix(safety): O127 cancel workspace-root cd exemption after a second dir change QA B1: exemption checked only that the first cd operand was the workspace root, so `cd && cd && node x`, `cd && cd $HOME && node x`, and similar follow-up cd/pushd/popd/chdir (any operand) stayed silent. Count directory-changing builtins; allow the exemption only when there is at most one. Add P4 regression tests that go red when this guard is reverted. --- src/safety/protected-paths.ts | 21 ++++++++++++++- test/o127-linked-worktree-exec-keys.spec.mjs | 27 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/safety/protected-paths.ts b/src/safety/protected-paths.ts index 041596b7..1fcfefba 100644 --- a/src/safety/protected-paths.ts +++ b/src/safety/protected-paths.ts @@ -544,6 +544,21 @@ function isWorkspaceRootPath(p: string, ctx: ProtectedPathContext): boolean { return realOrResolved(path.resolve(ctx.cwd, p)) === realOrResolved(root); } +/** + * O127 QA B1: count cd/pushd/popd/chdir builtins (with or without an operand, + * whether or not the operand is resolvable). A second directory change cancels + * the workspace-root cd exemption — otherwise `cd && cd && node x` (bare + * cd → $HOME) and `cd && cd $HOME && node x` silently skipped the + * ancestor check. + */ +function countDirectoryChanges(command: string): number { + const re = + /(?:^|&&|\|\||;|\n|\()\s*(?:builtin\s+|command\s+)?(?:cd|pushd|popd|chdir)(?=\s|$|;|&|\||<|>|\)|\n)/gi; + let n = 0; + for (const _ of command.matchAll(re)) n += 1; + return n; +} + function cdOnlyOperands(command: string, cwd: string): Set { const cd = new Set(); const re = @@ -596,6 +611,9 @@ export function findShellProtectedWrites( // classification; only the ancestor-delete rule is skipped, and only when // the path is named nowhere else in the command. const cdOnly = cdOnlyOperands(command, ctx.cwd); + // QA B1 / PM 10-12: a second cd/pushd/popd/chdir (any operand) cancels the + // exemption — the first cd-to-root must also be the last directory change. + const allowCdExempt = countDirectoryChanges(command) <= 1; // Prefer absolute mentions from the shared detector; fall back to text regex. for (const m of mentions) { // O103: a word still holding `$VAR` / `$(…)` is not a path we resolved; @@ -603,7 +621,8 @@ export function findShellProtectedWrites( if (/\$|`/.test(m)) continue; // PM 10-12: exempt only when the cd target IS the workspace/worktree root // (realpath); `cd /`, `cd ~`, `cd ..` still hit the ancestor rule. - const isCd = cdOnly.has(path.resolve(ctx.cwd, m)) && isWorkspaceRootPath(m, ctx); + const isCd = + allowCdExempt && cdOnly.has(path.resolve(ctx.cwd, m)) && isWorkspaceRootPath(m, ctx); const hit = classifyProtectedPath(m, ctx) ?? (isCd ? undefined : deletesProtectedAncestor(m, ctx)); if (!hit || seen.has(hit.resolved)) continue; diff --git a/test/o127-linked-worktree-exec-keys.spec.mjs b/test/o127-linked-worktree-exec-keys.spec.mjs index 7e587237..8bb64b96 100644 --- a/test/o127-linked-worktree-exec-keys.spec.mjs +++ b/test/o127-linked-worktree-exec-keys.spec.mjs @@ -211,6 +211,33 @@ await check('P3 (PM 10-12): cd not blocked; cd / and cd ~ still prompt', assert.ok(findShellProtectedWrites('cd ~ && node x', ctx).length > 0, 'cd ~'); }); +// QA B1 / PM 10-12: a second directory change cancels the workspace-root cd +// exemption (bare cd, $VAR, $(...), flags, pushd — resolvable or not). +await check('P4 (QA B1): second dir-change after cd restores ancestor prompt', () => { + const r = makeLinked(); + const ws = r.wt; + const ctx = { cwd: ws, workspaceRoot: ws, home: path.dirname(ws) }; + const stillExempt = [`cd ${ws} && node x`]; + const mustPrompt = [ + `cd ${ws} && cd && node x`, + `cd ${ws} && cd $HOME && node x`, + `cd ${ws} && cd "$HOME" && node x`, + `cd ${ws} && cd $X && node x`, + `cd ${ws} && cd "$(dirname "$PWD")" && node x`, + `cd ${ws} && cd -L && node x`, + `cd ${ws} && pushd && node x`, + ]; + for (const c of stillExempt) { + assert.deepEqual(findShellProtectedWrites(c, ctx), [], c); + } + for (const c of mustPrompt) { + assert.ok(findShellProtectedWrites(c, ctx).length > 0, c); + } + // Keep the PM green cases from P3 visible next to the new reds. + assert.ok(findShellProtectedWrites('cd / && node x', ctx).length > 0, 'cd /'); + assert.ok(findShellProtectedWrites('cd ~ && node x', ctx).length > 0, 'cd ~'); +}); + // ── D. main repo unchanged ───────────────────────────────────────────── await check('D1: main checkout: commit silent, sshCommand reported', () => { const r = makeLinked();