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..1bea10c6 100644 --- a/src/safety/protected-path-snapshot.ts +++ b/src/safety/protected-path-snapshot.ts @@ -9,14 +9,20 @@ * run-outcome lines, tool results, or model context. Hash is compare-only. * * Watched: ~/.ssh/*, workspace .env / .env.* / .envrc, and under .git only - * config, HEAD, refs/**, hooks/** (including newly added hooks). Normal - * git commit writes (index, objects, logs, ORIG_HEAD, …) are ignored. + * 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, 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 * (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 +34,47 @@ 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' + /** 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 { mtimeMs: number; size: number; /** sha256 hex; compare only. */ 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'; } /** Sentinel: path exists but meta could not be read (permission). */ @@ -55,6 +94,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 +111,364 @@ 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. 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. + */ +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); +} + +/** + * 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.*.merge', + 'remote.*.fetch', +]; + +/** 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('.'); + 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 (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)); + } + 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 + * 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 (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')}`; + 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 }; +} + +/** + * 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 { @@ -79,6 +478,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 +487,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 +495,11 @@ 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 } : {}), + ...(isGitHeadOrLooseRefPath(abs) + ? { refState: classifyGitRefContent(buf.toString('utf8')) } + : {}), }; } catch (err) { const code = (err as NodeJS.ErrnoException)?.code; @@ -128,7 +534,9 @@ 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/**, info/**, objects/info/(http-)alternates, + * modules//config. */ export function listWatchedProtectedPaths(cwd: string, home?: string): string[] { const h = homeDir(home); @@ -147,9 +555,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)) { @@ -158,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)]; } @@ -236,8 +662,30 @@ 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)) { + const c = gitConfigChange(display, 'added', undefined, a); + if (c) changes.push(c); + continue; + } changes.push({ path: display, kind: 'added' }); continue; } @@ -246,7 +694,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' }); } } @@ -305,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( @@ -316,13 +770,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 +802,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 +816,75 @@ 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 = ALL_REASONS.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 === '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`; + } + 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 (/(^|\/)\.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; + 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 +892,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..273edd9b --- /dev/null +++ b/test/o117-git-config-l3-precise.spec.mjs @@ -0,0 +1,753 @@ +#!/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, + isSafeGitRemoteUrl, + 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: git remote add origin https://… + real git push -u (to local bare via -c insteadOf) → no report', + () => { + 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.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); +}); + +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', () => { + 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/); + } + ); +} + +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); +} +console.log('# o117 all ok'); 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');