From 8776c4f8f3a89aacdefaa49e46a1caebb0480442 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 14 Apr 2026 19:44:08 -0700 Subject: [PATCH] fix: harden urgent install and gateguard patch --- README.md | 2 + README.zh-CN.md | 6 +- scripts/harness-audit.js | 3 +- scripts/hooks/gateguard-fact-force.js | 48 +++++++++++++-- tests/hooks/gateguard-fact-force.test.js | 72 ++++++++++++++++++----- tests/plugin-manifest.test.js | 28 ++++++++- tests/scripts/harness-audit.test.js | 74 +++++++++++++++++++++++- 7 files changed, 206 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fe21d176..23be9dc6 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ Get up and running in under 2 minutes: /plugin install everything-claude-code ``` +> Install-name clarification: older posts may still show `ecc@ecc`. That shorthand is deprecated. Anthropic marketplace/plugin installs are keyed by a canonical plugin identifier, so ECC standardized on `everything-claude-code@everything-claude-code` to keep the listing name, install path, `/plugin list`, and repo docs aligned instead of maintaining two different public names for the same plugin. + ### Step 2: Install Rules (Required) > WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually: diff --git a/README.zh-CN.md b/README.zh-CN.md index ae0b6780..b6557064 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,12 +99,14 @@ ```bash # 添加市场 -/plugin marketplace add affaan-m/everything-claude-code +/plugin marketplace add https://github.com/affaan-m/everything-claude-code # 安装插件 /plugin install everything-claude-code ``` +> 安装名称说明:较早的帖子里可能还会出现 `ecc@ecc`。那个旧缩写现在已经废弃。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 统一为 `everything-claude-code@everything-claude-code`,这样市场条目、安装命令、`/plugin list` 输出和仓库文档都使用同一个公开名称,不再出现两个名字指向同一插件的混乱。 + ### 第二步:安装规则(必需) > WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`,需要手动安装: @@ -543,7 +545,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho ```bash # 将此仓库添加为市场 -/plugin marketplace add affaan-m/everything-claude-code +/plugin marketplace add https://github.com/affaan-m/everything-claude-code # 安装插件 /plugin install everything-claude-code diff --git a/scripts/harness-audit.js b/scripts/harness-audit.js index 4eb6b7b2..648e3802 100644 --- a/scripts/harness-audit.js +++ b/scripts/harness-audit.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const fs = require('fs'); +const os = require('os'); const path = require('path'); const CATEGORIES = [ @@ -187,7 +188,7 @@ function detectTargetMode(rootDir) { } function findPluginInstall(rootDir) { - const homeDir = process.env.HOME || ''; + const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || ''; const pluginDirs = [ 'ecc', 'ecc@ecc', diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 1123f97c..9886efc5 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -54,7 +54,7 @@ function sanitizeSessionKey(value) { return sanitized; } - return ''; + return hashSessionKey('sid', raw); } function hashSessionKey(prefix, value) { @@ -177,9 +177,13 @@ function isChecked(key) { for (const f of files) { if (!f.startsWith('state-') || !f.endsWith('.json')) continue; const fp = path.join(STATE_DIR, f); - const stat = fs.statSync(fp); - if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) { - fs.unlinkSync(fp); + try { + const stat = fs.statSync(fp); + if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) { + fs.unlinkSync(fp); + } + } catch (_) { + // Ignore files that disappear between readdir/stat/unlink. } } } catch (_) { /* ignore */ } @@ -210,11 +214,43 @@ function isClaudeSettingsPath(filePath) { function isReadOnlyGitIntrospection(command) { const trimmed = String(command || '').trim(); - if (!trimmed || /[;&|><`$()]/.test(trimmed)) { + if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) { return false; } - return /^(git\s+(status|diff|log|show|branch(?:\s+--show-current)?|rev-parse(?:\s+--abbrev-ref\s+head)?)(\s|$))/i.test(trimmed); + const tokens = trimmed.split(/\s+/); + if (tokens[0] !== 'git' || tokens.length < 2) { + return false; + } + + const subcommand = tokens[1].toLowerCase(); + const args = tokens.slice(2); + + if (subcommand === 'status') { + return args.every(arg => ['--porcelain', '--short', '--branch'].includes(arg)); + } + + if (subcommand === 'diff') { + return args.length <= 1 && args.every(arg => ['--name-only', '--name-status'].includes(arg)); + } + + if (subcommand === 'log') { + return args.every(arg => arg === '--oneline' || /^--max-count=\d+$/.test(arg)); + } + + if (subcommand === 'show') { + return args.length === 1 && !args[0].startsWith('--') && /^[a-zA-Z0-9._:/-]+$/.test(args[0]); + } + + if (subcommand === 'branch') { + return args.length === 1 && args[0] === '--show-current'; + } + + if (subcommand === 'rev-parse') { + return args.length === 2 && args[0] === '--abbrev-ref' && /^head$/i.test(args[1]); + } + + return false; } // --- Gate messages --- diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index a7af8cda..b1925fe2 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -10,7 +10,8 @@ const { spawnSync } = require('child_process'); const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js'); const externalStateDir = process.env.GATEGUARD_STATE_DIR; const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp'; -const stateDir = externalStateDir || fs.mkdtempSync(path.join(tmpRoot, 'gateguard-test-')); +const baseStateDir = externalStateDir || tmpRoot; +const stateDir = fs.mkdtempSync(path.join(baseStateDir, 'gateguard-test-')); // Use a fixed session ID so test process and spawned hook process share the same state file const TEST_SESSION_ID = 'gateguard-test-session'; const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`); @@ -31,12 +32,9 @@ function test(name, fn) { function clearState() { try { if (fs.existsSync(stateDir)) { - for (const entry of fs.readdirSync(stateDir)) { - if (entry.startsWith('state-') && entry.endsWith('.json')) { - fs.unlinkSync(path.join(stateDir, entry)); - } - } + fs.rmSync(stateDir, { recursive: true, force: true }); } + fs.mkdirSync(stateDir, { recursive: true }); } catch (err) { console.error(` [clearState] failed to remove state files in ${stateDir}: ${err.message}`); } @@ -516,15 +514,61 @@ function runTests() { } })) passed++; else failed++; - // Cleanup only the temp directory created by this test file. - if (!externalStateDir) { - try { - if (fs.existsSync(stateDir)) { - fs.rmSync(stateDir, { recursive: true, force: true }); - } - } catch (err) { - console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`); + // --- Test 18: rejects mutating git commands that only share a prefix --- + clearState(); + if (test('does not treat mutating git commands as read-only introspection', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: 'git status && rm -rf /tmp/demo' } + }; + const result = runBashHook(input); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current instruction')); + })) passed++; else failed++; + + // --- Test 19: long raw session IDs hash instead of collapsing to project fallback --- + clearState(); + if (test('uses a stable hash for long raw session ids', () => { + const longSessionId = `session-${'x'.repeat(120)}`; + const input = { + session_id: longSessionId, + tool_name: 'Bash', + tool_input: { command: 'ls -la' } + }; + + const first = runBashHook(input, { + CLAUDE_SESSION_ID: '', + ECC_SESSION_ID: '', + }); + const firstOutput = parseOutput(first.stdout); + assert.strictEqual(firstOutput.hookSpecificOutput.permissionDecision, 'deny'); + + const stateFiles = fs.readdirSync(stateDir).filter(entry => entry.startsWith('state-') && entry.endsWith('.json')); + assert.strictEqual(stateFiles.length, 1, 'long raw session id should still produce a dedicated state file'); + assert.ok(/state-sid-[a-f0-9]{24}\.json$/.test(stateFiles[0]), 'long raw session ids should hash to a bounded sid-* key'); + + const second = runBashHook(input, { + CLAUDE_SESSION_ID: '', + ECC_SESSION_ID: '', + }); + const secondOutput = parseOutput(second.stdout); + if (secondOutput.hookSpecificOutput) { + assert.notStrictEqual(secondOutput.hookSpecificOutput.permissionDecision, 'deny', + 'retry should be allowed when long raw session_id is stable'); + } else { + assert.strictEqual(secondOutput.tool_name, 'Bash'); } + })) passed++; else failed++; + + // Cleanup only the temp directory created by this test file. + try { + if (fs.existsSync(stateDir)) { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + } catch (err) { + console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`); } console.log(`\n ${passed} passed, ${failed} failed\n`); diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index 68bb4118..069afb21 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -476,7 +476,7 @@ test('README version row matches package.json', () => { assert.strictEqual(match[1], expectedVersion); }); -test('user-facing docs do not reference deprecated ecc@ecc plugin identifier', () => { +test('user-facing docs do not use deprecated ecc@ecc install commands', () => { const markdownFiles = [ path.join(repoRoot, 'README.md'), path.join(repoRoot, 'README.zh-CN.md'), @@ -487,7 +487,7 @@ test('user-facing docs do not reference deprecated ecc@ecc plugin identifier', ( const offenders = []; for (const filePath of markdownFiles) { const source = fs.readFileSync(filePath, 'utf8'); - if (source.includes('ecc@ecc')) { + if (/\/plugin\s+(install|list)\s+ecc@ecc\b/.test(source)) { offenders.push(path.relative(repoRoot, filePath)); } } @@ -495,7 +495,29 @@ test('user-facing docs do not reference deprecated ecc@ecc plugin identifier', ( assert.deepStrictEqual( offenders, [], - `Deprecated ecc@ecc identifier must not appear in user-facing docs: ${offenders.join(', ')}`, + `Deprecated ecc@ecc install commands must not appear in user-facing docs: ${offenders.join(', ')}`, + ); +}); + +test('user-facing docs do not use the legacy non-URL marketplace add form', () => { + const markdownFiles = [ + path.join(repoRoot, 'README.md'), + path.join(repoRoot, 'README.zh-CN.md'), + ...collectMarkdownFiles(path.join(repoRoot, 'docs')), + ]; + + const offenders = []; + for (const filePath of markdownFiles) { + const source = fs.readFileSync(filePath, 'utf8'); + if (source.includes('/plugin marketplace add affaan-m/everything-claude-code')) { + offenders.push(path.relative(repoRoot, filePath)); + } + } + + assert.deepStrictEqual( + offenders, + [], + `Legacy non-URL marketplace add form must not appear in user-facing docs: ${offenders.join(', ')}`, ); }); diff --git a/tests/scripts/harness-audit.test.js b/tests/scripts/harness-audit.test.js index 25a8890b..a114aff3 100644 --- a/tests/scripts/harness-audit.test.js +++ b/tests/scripts/harness-audit.test.js @@ -19,11 +19,13 @@ function cleanup(dirPath) { } function run(args = [], options = {}) { + const userProfile = options.userProfile || options.homeDir || process.env.USERPROFILE; const stdout = execFileSync('node', [SCRIPT, ...args], { cwd: options.cwd || path.join(__dirname, '..', '..'), env: { ...process.env, HOME: options.homeDir || process.env.HOME, + USERPROFILE: userProfile, }, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], @@ -132,7 +134,7 @@ function runTests() { } })) passed++; else failed++; - if (test('detects marketplace-installed Claude plugins under marketplaces/', () => { + if (test('detects marketplace-installed Claude plugins under home marketplaces/', () => { const homeDir = createTempDir('harness-audit-marketplace-home-'); const projectRoot = createTempDir('harness-audit-marketplace-project-'); @@ -165,6 +167,76 @@ function runTests() { } })) passed++; else failed++; + if (test('detects marketplace-installed Claude plugins under project marketplaces/', () => { + const homeDir = createTempDir('harness-audit-marketplace-home-'); + const projectRoot = createTempDir('harness-audit-marketplace-project-'); + + try { + fs.mkdirSync(path.join(projectRoot, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'everything-claude-code' }, null, 2) + ); + + fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n'); + fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2)); + fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n'); + fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n'); + fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n'); + fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2)); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2) + ); + + const parsed = JSON.parse(run(['repo', '--format', 'json'], { cwd: projectRoot, homeDir })); + assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('detects marketplace-installed Claude plugins from USERPROFILE fallback on Windows-style setups', () => { + const homeDir = createTempDir('harness-audit-marketplace-home-'); + const projectRoot = createTempDir('harness-audit-marketplace-project-'); + + try { + fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(homeDir, '.claude', 'plugins', 'marketplaces', 'everything-claude-code', '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'everything-claude-code' }, null, 2) + ); + + fs.mkdirSync(path.join(projectRoot, '.github', 'workflows'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'tests'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'AGENTS.md'), '# Project instructions\n'); + fs.writeFileSync(path.join(projectRoot, '.mcp.json'), JSON.stringify({ mcpServers: {} }, null, 2)); + fs.writeFileSync(path.join(projectRoot, '.gitignore'), 'node_modules\n.env\n'); + fs.writeFileSync(path.join(projectRoot, '.github', 'workflows', 'ci.yml'), 'name: ci\n'); + fs.writeFileSync(path.join(projectRoot, 'tests', 'app.test.js'), 'test placeholder\n'); + fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), JSON.stringify({ hooks: ['PreToolUse'] }, null, 2)); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'consumer-project', scripts: { test: 'node tests/app.test.js' } }, null, 2) + ); + + const parsed = JSON.parse(run(['repo', '--format', 'json'], { + cwd: projectRoot, + homeDir: '', + userProfile: homeDir, + })); + assert.ok(parsed.checks.some(check => check.id === 'consumer-plugin-install' && check.pass)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }