diff --git a/.github/workflows/reusable-validate.yml b/.github/workflows/reusable-validate.yml index e255d1f..70b2510 100644 --- a/.github/workflows/reusable-validate.yml +++ b/.github/workflows/reusable-validate.yml @@ -34,7 +34,7 @@ jobs: run: node scripts/ci/validate-commands.js - name: Validate skills - run: node scripts/ci/validate-skills.js + run: node scripts/ci/validate-skills.js --strict - name: Validate workflow security run: node scripts/ci/validate-workflow-security.js diff --git a/scripts/ci/validate-skills.js b/scripts/ci/validate-skills.js index 066045d..c253ce9 100644 --- a/scripts/ci/validate-skills.js +++ b/scripts/ci/validate-skills.js @@ -1,21 +1,232 @@ #!/usr/bin/env node /** - * Validate skill directories have SKILL.md with required structure + * Validate curated skill directories (skills/ in repo). + * + * Checks: + * 1. Each sub-directory of skills/ contains a SKILL.md file. + * 2. SKILL.md is non-empty. + * 3. SKILL.md frontmatter (if present) declares a `name:` field. + * 4. SKILL.md frontmatter `description:` uses an inline scalar — not a + * literal block scalar (`|` / `|-` / `|+`), which preserves internal + * newlines and breaks flat-table renderers keyed off `description`. + * + * Frontmatter findings default to WARN so CI does not break while + * pre-existing data defects are being cleaned up out of band (see #1663). + * Pass `--strict` or set `CI_STRICT_SKILLS=1` to promote frontmatter + * findings to errors (exit 1). + * + * Structural findings (missing/empty SKILL.md) are always errors. + * + * Scope: curated only. Learned/imported/evolved roots are out of scope. + * If skills/ does not exist, exit 0 (no curated skills to validate). */ const fs = require('fs'); const path = require('path'); -const { validateDirs } = require('../lib/validator'); -validateDirs({ - dir: path.join(__dirname, '../../skills'), - label: 'skill', - validate(dirPath) { - const skillMd = path.join(dirPath, 'SKILL.md'); - if (!fs.existsSync(skillMd)) return ['Missing SKILL.md']; +const SKILLS_DIR = path.join(__dirname, '../../skills'); - const content = fs.readFileSync(skillMd, 'utf-8'); - if (content.trim().length === 0) return ['SKILL.md is empty']; - return null; +const STRICT = process.argv.includes('--strict') || process.env.CI_STRICT_SKILLS === '1'; + +/** + * Parse the leading YAML frontmatter of a markdown document. + * + * Returns `{ present, lines }` so callers can inspect raw lines + * (needed to detect block-scalar `description:` values). + * + * Tolerant of UTF-8 BOM and CRLF line endings, matching the other + * validators in this directory. + * + * @param {string} content + * @returns {{present: boolean, lines: string[]}} + */ +function extractFrontmatter(content) { + // Strip BOM if present (UTF-8 BOM: U+FEFF). + const clean = content.replace(/^\uFEFF/, ''); + const match = clean.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) return { present: false, lines: [] }; + return { + present: true, + lines: match[1].split(/\r?\n/) + }; +} + +/** + * Extract top-level keys (with trimmed values) and flag block-scalar + * `description:` values. + * + * Lines that continue a block scalar (`|` or `>`) are skipped — we only + * care about the top-level key set and the raw indicator on the + * `description:` line. Block-scalar indicators accept YAML chomp and + * indent modifiers and trailing comments, e.g. `|`, `|-`, `|+`, `|2`, + * `|-2`, `>- # note`. + * + * @param {string[]} lines + * @returns {{values: Record, descriptionIndicator: string|null}} + */ +function inspectFrontmatter(lines) { + const values = Object.create(null); + let descriptionIndicator = null; + let inBlockScalar = false; + let blockScalarIndent = -1; + + for (const rawLine of lines) { + if (inBlockScalar) { + // Stay inside the block until a line with indent <= the opener's + // indent (or an empty continuation). + const leadingSpaces = rawLine.match(/^(\s*)/)[1].length; + if (rawLine.trim() === '' || leadingSpaces > blockScalarIndent) { + continue; + } + inBlockScalar = false; + blockScalarIndent = -1; + } + + const match = rawLine.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) continue; + + const key = match[1]; + const rawValue = match[2]; + // Strip unquoted comments for value/indicator inspection. Handles both + // trailing comments (`foo: bar # note`) and comment-only values + // (`foo: # todo`) so the latter is treated as empty. + const valueNoComment = rawValue + .replace(/^\s*#.*$/, '') + .replace(/\s+#.*$/, '') + .trim(); + values[key] = valueNoComment; + + // Detect literal / folded block-scalar indicators. Accept chomp + // modifiers (`-` / `+`) and optional indent-indicator digits in + // either order, per YAML 1.2. + if (/^[|>](?:[+-]?\d+|\d+[+-]?|[+-])?$/.test(valueNoComment)) { + if (key === 'description') { + descriptionIndicator = valueNoComment; + } + inBlockScalar = true; + blockScalarIndent = rawLine.match(/^(\s*)/)[1].length; + } } -}); + + return { values, descriptionIndicator }; +} + +/** + * Normalize a YAML scalar value before treating it as empty. Catches + * quoted-empty (`""`, `''`) and YAML null forms (`~`, `null`) that + * `inspectFrontmatter` leaves as literal strings — without this, a + * frontmatter line like `name: ""` or `name: null` would pass the + * non-empty check even though both encode the absence of a value. + * + * @param {string} value + * @returns {string} + */ +function normalizeYamlScalar(value) { + const v = value.trim(); + if (/^(?:~|null)$/i.test(v)) return ''; + if ( + (v.startsWith('"') && v.endsWith('"') && v.length >= 2) || + (v.startsWith("'") && v.endsWith("'") && v.length >= 2) + ) { + return v.slice(1, -1).trim(); + } + return v; +} + +/** + * Validate a single skill directory. + * + * Returns `{ fatal }` where `fatal` indicates a structural error that + * should be surfaced via `console.error` and abort CI (missing/empty + * SKILL.md). Frontmatter findings are routed through + * `reportFrontmatterFinding`, which owns the WARN/ERROR decision based + * on strict mode. + * + * @param {string} dir + * @param {string} skillsDir + * @param {(msg: string) => void} reportFrontmatterFinding + * @returns {{fatal: boolean}} + */ +function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) { + const skillMd = path.join(skillsDir, dir, 'SKILL.md'); + if (!fs.existsSync(skillMd)) { + console.error(`ERROR: ${dir}/ - Missing SKILL.md`); + return { fatal: true }; + } + + let content; + try { + content = fs.readFileSync(skillMd, 'utf-8'); + } catch (err) { + console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`); + return { fatal: true }; + } + if (content.trim().length === 0) { + console.error(`ERROR: ${dir}/SKILL.md - Empty file`); + return { fatal: true }; + } + + const fm = extractFrontmatter(content); + if (fm.present) { + const { values, descriptionIndicator } = inspectFrontmatter(fm.lines); + + if (!Object.prototype.hasOwnProperty.call(values, 'name')) { + reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter missing required field: name`); + } else if (normalizeYamlScalar(values.name) === '') { + reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter 'name' is empty`); + } + + if (descriptionIndicator && descriptionIndicator.startsWith('|')) { + reportFrontmatterFinding( + `${dir}/SKILL.md - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead` + ); + } + } + + return { fatal: false }; +} + +function validateSkills() { + if (!fs.existsSync(SKILLS_DIR)) { + console.log('No curated skills directory (skills/), skipping'); + process.exit(0); + } + + const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name); + + let hasErrors = false; + let warnCount = 0; + let validCount = 0; + + const reportFrontmatterFinding = msg => { + if (STRICT) { + console.error(`ERROR: ${msg}`); + hasErrors = true; + } else { + console.warn(`WARN: ${msg}`); + warnCount++; + } + }; + + for (const dir of dirs) { + const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding); + if (fatal) { + hasErrors = true; + continue; + } + validCount++; + } + + if (hasErrors) { + process.exit(1); + } + + let msg = `Validated ${validCount} skill directories`; + if (warnCount > 0) { + msg += ` (${warnCount} warning${warnCount === 1 ? '' : 's'})`; + } + console.log(msg); +} + +validateSkills(); diff --git a/scripts/hooks/block-no-verify.js b/scripts/hooks/block-no-verify.js index 4562630..3129467 100644 --- a/scripts/hooks/block-no-verify.js +++ b/scripts/hooks/block-no-verify.js @@ -35,6 +35,236 @@ const GIT_COMMANDS_WITH_NO_VERIFY = [ */ const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\'; +// Git config section and variable names are case-insensitive +// (subsection names are case-sensitive but core.hooksPath has none), +// so we normalize the candidate token to lowercase before matching. +// See https://git-scm.com/docs/git-config — "The variable names are +// case-insensitive." +const GIT_CONFIG_KEY_PREFIX = 'core.hookspath='; + +const COMMIT_OPTIONS_WITH_VALUE = new Set([ + '-m', + '--message', + '-F', + '--file', + '-C', + '--reuse-message', + '-c', + '--reedit-message', + '--author', + '--date', + '--template', + '--fixup', + '--squash', + '--pathspec-from-file', +]); + +const COMMIT_OPTIONS_WITH_INLINE_VALUE = [ + '--message=', + '--file=', + '--reuse-message=', + '--reedit-message=', + '--author=', + '--date=', + '--template=', + '--fixup=', + '--squash=', + '--pathspec-from-file=', +]; + +// Short options that take a value. When seen as part of a combined +// short-option token (e.g. -tn), git's parser treats the rest of the +// token as the option's value (template path 'n' here), so the scanner +// must stop at this character — anything after it is the inline value, +// not another flag. +const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']); + +function tokenizeShellWords(input, start = 0, end = input.length) { + const tokens = []; + let value = ''; + let tokenStart = null; + let quote = null; + let escaped = false; + + function beginToken(index) { + if (tokenStart === null) { + tokenStart = index; + } + } + + function pushToken(index) { + if (tokenStart === null) { + return; + } + + tokens.push({ + value, + start: tokenStart, + end: index, + }); + value = ''; + tokenStart = null; + } + + for (let i = start; i < end; i++) { + const char = input.charAt(i); + + if (escaped) { + beginToken(i - 1); + value += char; + escaped = false; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + continue; + } + + if (quote === '"' && char === '\\') { + beginToken(i); + escaped = true; + continue; + } + + beginToken(i); + value += char; + continue; + } + + if (char === '"' || char === "'") { + beginToken(i); + quote = char; + continue; + } + + if (char === '\\') { + beginToken(i); + escaped = true; + continue; + } + + if (/\s/.test(char)) { + pushToken(i); + continue; + } + + beginToken(i); + value += char; + } + + if (escaped) { + value += '\\'; + } + pushToken(end); + + return tokens; +} + +function findCommandSegmentEnd(input, start) { + let quote = null; + let escaped = false; + + for (let i = start; i < input.length; i++) { + const char = input.charAt(i); + + if (escaped) { + escaped = false; + continue; + } + + if (quote) { + if (quote === '"' && char === '\\') { + escaped = true; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + if (char === ';' || char === '|' || char === '&' || char === '\n') { + return i; + } + } + + return input.length; +} + +function commitOptionConsumesNextValue(value) { + if (isCommitNoVerifyShortFlag(value)) { + return false; + } + + if (COMMIT_OPTIONS_WITH_VALUE.has(value)) { + return true; + } + + const shortValueOption = getCommitShortValueOption(value); + return Boolean(shortValueOption && shortValueOption.consumesNextValue); +} + +function commitOptionContainsInlineValue(value) { + if (isCommitNoVerifyShortFlag(value)) { + return false; + } + + if (COMMIT_OPTIONS_WITH_INLINE_VALUE.some(prefix => value.startsWith(prefix))) { + return true; + } + + const shortValueOption = getCommitShortValueOption(value); + return Boolean(shortValueOption && shortValueOption.containsInlineValue); +} + +function getCommitShortValueOption(value) { + if (!value.startsWith('-') || value.startsWith('--') || value === '-') { + return null; + } + + const options = value.slice(1); + for (let i = 0; i < options.length; i++) { + if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(options.charAt(i))) { + return { + consumesNextValue: i === options.length - 1, + containsInlineValue: i < options.length - 1, + }; + } + } + + return null; +} + +function isCommitNoVerifyShortFlag(value) { + if (value === '-n') return true; + if (!value.startsWith('-') || value.startsWith('--') || value === '-') { + return false; + } + // Scan every character of a combined short-option token (e.g. -an, -na). + // A value-consuming flag like -m or -F (case-sensitive: 'm', 'F', 'C', 'c') + // ends the option chain — characters after it are the inline value, not + // further flags, so we must stop scanning there. + const options = value.slice(1); + for (let i = 0; i < options.length; i++) { + const char = options.charAt(i); + if (char === 'n') return true; + if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(char)) return false; + } + return false; +} + /** * Check if a position in the input is inside a shell comment. */ @@ -79,8 +309,7 @@ function findGit(input, start) { * Returns { command, offset } where offset is the position right after the * subcommand keyword, so callers can scope flag checks to only that portion. */ -function detectGitCommand(input) { - let start = 0; +function detectGitCommand(input, start = 0) { while (start < input.length) { const git = findGit(input, start); if (!git) return null; @@ -106,7 +335,7 @@ function detectGitCommand(input) { const after = input[cmdIdx + cmd.length] || ' '; if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; } if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; } - if (/[;|&]/.test(input.slice(git.idx + git.len, cmdIdx))) break; + if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break; if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; } // Verify this token is the first non-flag word after "git" — i.e. the @@ -141,7 +370,13 @@ function detectGitCommand(input) { } if (bestCmd) { - return { command: bestCmd, offset: bestIdx + bestCmd.length }; + return { + command: bestCmd, + offset: bestIdx + bestCmd.length, + gitStart: git.idx, + gitEnd: git.idx + git.len, + commandStart: bestIdx, + }; } start = git.idx + git.len; @@ -149,20 +384,6 @@ function detectGitCommand(input) { return null; } -/** - * Replace the contents of single- and double-quoted strings with empty - * quotes. Used to neutralize commit messages and other quoted args before - * we run the flag-detection regexes — without this, a benign command like - * git commit -m "fix: --no-verify edge case" - * would falsely match `--no-verify` inside the message and block the - * commit. Backslash-escaped quotes inside the string are honored. - */ -function stripQuotedStrings(input) { - return input - .replace(/'(?:[^'\\]|\\.)*'/g, "''") - .replace(/"(?:[^"\\]|\\.)*"/g, '""'); -} - /** * Check if the input contains a --no-verify flag for a specific git command. * Only inspects the portion of the input starting at `offset` (the position @@ -170,12 +391,39 @@ function stripQuotedStrings(input) { * earlier commands in a chain are not falsely matched. */ function hasNoVerifyFlag(input, command, offset) { - const region = stripQuotedStrings(input.slice(offset)); - if (/--no-verify\b/.test(region)) return true; + const segmentEnd = findCommandSegmentEnd(input, offset); + const tokens = tokenizeShellWords(input, offset, segmentEnd); + let skipNext = false; - // For commit, -n is shorthand for --no-verify - if (command === 'commit') { - if (/\s-n(?:\s|$)/.test(region) || /\s-n[a-zA-Z]/.test(region)) return true; + for (const token of tokens) { + const value = token.value; + + if (skipNext) { + skipNext = false; + continue; + } + + if (value === '--') { + break; + } + + if (command === 'commit') { + if (commitOptionConsumesNextValue(value)) { + skipNext = true; + continue; + } + + if (commitOptionContainsInlineValue(value)) { + continue; + } + } + + if (value === '--no-verify') return true; + + // For commit, -n is shorthand for --no-verify. + if (command === 'commit' && isCommitNoVerifyShortFlag(value)) { + return true; + } } return false; @@ -183,34 +431,61 @@ function hasNoVerifyFlag(input, command, offset) { /** * Check if the input contains a -c core.hooksPath= override. - * Quoted strings are stripped first to avoid false positives on commit - * messages or argument values that happen to contain the phrase. */ -function hasHooksPathOverride(input) { - return /-c\s+["']?core\.hooksPath\s*=/.test(stripQuotedStrings(input)); +function hasHooksPathOverride(input, detected) { + const tokens = tokenizeShellWords(input, detected.gitEnd, detected.commandStart); + + for (let i = 0; i < tokens.length; i++) { + const value = tokens[i].value; + // Git config section + variable names are case-insensitive, so a + // bypass attempt like `core.HOOKSPATH=...` or `core.hookspath=...` + // must compare against the lowercased token. + const lowered = value.toLowerCase(); + + if (value === '-c') { + const next = tokens[i + 1] && tokens[i + 1].value; + if (typeof next === 'string' && next.toLowerCase().startsWith(GIT_CONFIG_KEY_PREFIX)) { + return true; + } + i++; + continue; + } + + if (lowered.startsWith(`-c${GIT_CONFIG_KEY_PREFIX}`)) { + return true; + } + } + + return false; } /** * Check a command string for git hook bypass attempts. */ function checkCommand(input) { - const detected = detectGitCommand(input); - if (!detected) return { blocked: false }; + let start = 0; - const { command: gitCommand, offset } = detected; + while (start < input.length) { + const detected = detectGitCommand(input, start); + if (!detected) return { blocked: false }; - if (hasNoVerifyFlag(input, gitCommand, offset)) { - return { - blocked: true, - reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`, - }; - } + const { command: gitCommand, offset } = detected; - if (hasHooksPathOverride(input)) { - return { - blocked: true, - reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`, - }; + if (hasHooksPathOverride(input, detected)) { + return { + blocked: true, + reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`, + }; + } + + if (hasNoVerifyFlag(input, gitCommand, offset)) { + return { + blocked: true, + reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`, + }; + } + + start = findCommandSegmentEnd(input, offset) + 1; } return { blocked: false }; diff --git a/skills/openclaw-persona-forge/SKILL.md b/skills/openclaw-persona-forge/SKILL.md index 9e718cb..949cb06 100644 --- a/skills/openclaw-persona-forge/SKILL.md +++ b/skills/openclaw-persona-forge/SKILL.md @@ -1,14 +1,6 @@ --- name: openclaw-persona-forge -description: |- - 为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡, - 输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。 - 如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。 - 当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。 - 不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。 - 触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、 - 龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、 - lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。 +description: "为 OpenClaw AI Agent 锻造完整的龙虾灵魂方案。根据用户偏好或随机抽卡, 输出身份定位、灵魂描述(SOUL.md)、角色化底线规则、名字和头像生图提示词。 如当前环境提供已审核的生图 skill,可自动生成统一风格头像图片。 当用户需要创建、设计或定制 OpenClaw 龙虾灵魂时使用。 不适用于:微调已有 SOUL.md、非 OpenClaw 平台的角色设计、纯工具型无性格 Agent。 触发词:龙虾灵魂、虾魂、OpenClaw 灵魂、养虾灵魂、龙虾角色、龙虾定位、 龙虾剧本杀角色、龙虾游戏角色、龙虾 NPC、龙虾性格、龙虾背景故事、 lobster soul、lobster character、抽卡、随机龙虾、龙虾 SOUL、gacha。" origin: community --- diff --git a/skills/skill-stocktake/SKILL.md b/skills/skill-stocktake/SKILL.md index d5b7047..b036ce8 100644 --- a/skills/skill-stocktake/SKILL.md +++ b/skills/skill-stocktake/SKILL.md @@ -1,4 +1,5 @@ --- +name: skill-stocktake description: "Use when auditing Gemini skills and commands for quality. Supports Quick Scan (changed skills only) and Full Stocktake modes with sequential subagent batch evaluation." origin: ECC --- diff --git a/tests/hooks/block-no-verify.test.js b/tests/hooks/block-no-verify.test.js index de3ec2b..2b5c3d2 100644 --- a/tests/hooks/block-no-verify.test.js +++ b/tests/hooks/block-no-verify.test.js @@ -141,6 +141,72 @@ if (test('still blocks --no-verify when both quoted message and real flag are pr assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); })) passed++; else failed++; +// --- Shell-words parsing fixes (ECC 0dcde13) --- + +if (test('blocks quoted core.hooksPath override argument', () => { + const r = runHook({ tool_input: { command: 'git -c "core.hooksPath=/dev/null" commit -m "msg"' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); + assert.ok(r.stderr.includes('core.hooksPath'), `stderr should mention core.hooksPath: ${r.stderr}`); +})) passed++; else failed++; + +if (test('allows --no-verify after combined -am message option', () => { + const r = runHook({ tool_input: { command: 'git commit -am "--no-verify"' } }); + assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('allows -n after combined -am message option', () => { + const r = runHook({ tool_input: { command: 'git commit -am "-n"' } }); + assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('allows git bypass phrase discussed in a quoted commit message', () => { + const r = runHook({ tool_input: { command: 'git commit -m "doc: explain git push --no-verify risk"' } }); + assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('still blocks a real quoted --no-verify flag', () => { + const r = runHook({ tool_input: { command: 'git commit "--no-verify" -m "msg"' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); + assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`); +})) passed++; else failed++; + +if (test('still blocks bypass flags in later chained git commands', () => { + const r = runHook({ tool_input: { command: 'git commit -m "msg" && git push --no-verify' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); + assert.ok(r.stderr.includes('git push'), `stderr should mention git push: ${r.stderr}`); +})) passed++; else failed++; + +if (test('blocks -n hidden inside combined short option (-an)', () => { + const r = runHook({ tool_input: { command: 'git commit -an -m "msg"' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); +})) passed++; else failed++; + +if (test('blocks -n hidden inside combined short option (-na)', () => { + const r = runHook({ tool_input: { command: 'git commit -na -m "msg"' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); +})) passed++; else failed++; + +if (test('still allows -mn (n is inside -m message, not a flag)', () => { + const r = runHook({ tool_input: { command: 'git commit -mn' } }); + assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('still allows -tn (n is the -t template path, not a flag)', () => { + const r = runHook({ tool_input: { command: 'git commit -tn -m "msg"' } }); + assert.strictEqual(r.code, 0, `expected exit 0, got ${r.code}: ${r.stderr}`); +})) passed++; else failed++; + +if (test('blocks case-variant core.hooksPath (lowercase)', () => { + const r = runHook({ tool_input: { command: 'git -c core.hookspath=/dev/null commit -m "msg"' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); + assert.ok(/core\.hooksPath/i.test(r.stderr), `stderr should mention core.hooksPath: ${r.stderr}`); +})) passed++; else failed++; + +if (test('blocks case-variant core.hooksPath (uppercase)', () => { + const r = runHook({ tool_input: { command: 'git -c core.HOOKSPATH=/dev/null commit -m "msg"' } }); + assert.strictEqual(r.code, 2, `expected exit 2, got ${r.code}`); +})) passed++; else failed++; + console.log('─'.repeat(50)); console.log(`Passed: ${passed} Failed: ${failed}`);