fix: harden urgent install and gateguard patch

This commit is contained in:
Affaan Mustafa
2026-04-14 19:44:08 -07:00
parent e5225db006
commit 8776c4f8f3
7 changed files with 206 additions and 27 deletions

View File

@@ -177,6 +177,8 @@ Get up and running in under 2 minutes:
/plugin install everything-claude-code /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) ### Step 2: Install Rules (Required)
> WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually: > WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually:

View File

@@ -99,12 +99,14 @@
```bash ```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 /plugin install everything-claude-code
``` ```
> 安装名称说明:较早的帖子里可能还会出现 `ecc@ecc`。那个旧缩写现在已经废弃。Anthropic 的 marketplace/plugin 安装是按规范化插件标识符寻址的,因此 ECC 统一为 `everything-claude-code@everything-claude-code`,这样市场条目、安装命令、`/plugin list` 输出和仓库文档都使用同一个公开名称,不再出现两个名字指向同一插件的混乱。
### 第二步:安装规则(必需) ### 第二步:安装规则(必需)
> WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`,需要手动安装: > WARNING: **重要提示:** Claude Code 插件无法自动分发 `rules`,需要手动安装:
@@ -543,7 +545,7 @@ Claude Code v2.1+ 会**按照约定自动加载**已安装插件中的 `hooks/ho
```bash ```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 /plugin install everything-claude-code

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const CATEGORIES = [ const CATEGORIES = [
@@ -187,7 +188,7 @@ function detectTargetMode(rootDir) {
} }
function findPluginInstall(rootDir) { function findPluginInstall(rootDir) {
const homeDir = process.env.HOME || ''; const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
const pluginDirs = [ const pluginDirs = [
'ecc', 'ecc',
'ecc@ecc', 'ecc@ecc',

View File

@@ -54,7 +54,7 @@ function sanitizeSessionKey(value) {
return sanitized; return sanitized;
} }
return ''; return hashSessionKey('sid', raw);
} }
function hashSessionKey(prefix, value) { function hashSessionKey(prefix, value) {
@@ -177,10 +177,14 @@ function isChecked(key) {
for (const f of files) { for (const f of files) {
if (!f.startsWith('state-') || !f.endsWith('.json')) continue; if (!f.startsWith('state-') || !f.endsWith('.json')) continue;
const fp = path.join(STATE_DIR, f); const fp = path.join(STATE_DIR, f);
try {
const stat = fs.statSync(fp); const stat = fs.statSync(fp);
if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) { if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) {
fs.unlinkSync(fp); fs.unlinkSync(fp);
} }
} catch (_) {
// Ignore files that disappear between readdir/stat/unlink.
}
} }
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
})(); })();
@@ -210,11 +214,43 @@ function isClaudeSettingsPath(filePath) {
function isReadOnlyGitIntrospection(command) { function isReadOnlyGitIntrospection(command) {
const trimmed = String(command || '').trim(); const trimmed = String(command || '').trim();
if (!trimmed || /[;&|><`$()]/.test(trimmed)) { if (!trimmed || /[\r\n;&|><`$()]/.test(trimmed)) {
return false; 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 --- // --- Gate messages ---

View File

@@ -10,7 +10,8 @@ const { spawnSync } = require('child_process');
const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js'); const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js');
const externalStateDir = process.env.GATEGUARD_STATE_DIR; const externalStateDir = process.env.GATEGUARD_STATE_DIR;
const tmpRoot = process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp'; 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 // 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 TEST_SESSION_ID = 'gateguard-test-session';
const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`); const stateFile = path.join(stateDir, `state-${TEST_SESSION_ID}.json`);
@@ -31,12 +32,9 @@ function test(name, fn) {
function clearState() { function clearState() {
try { try {
if (fs.existsSync(stateDir)) { if (fs.existsSync(stateDir)) {
for (const entry of fs.readdirSync(stateDir)) { fs.rmSync(stateDir, { recursive: true, force: true });
if (entry.startsWith('state-') && entry.endsWith('.json')) {
fs.unlinkSync(path.join(stateDir, entry));
}
}
} }
fs.mkdirSync(stateDir, { recursive: true });
} catch (err) { } catch (err) {
console.error(` [clearState] failed to remove state files in ${stateDir}: ${err.message}`); console.error(` [clearState] failed to remove state files in ${stateDir}: ${err.message}`);
} }
@@ -516,8 +514,55 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
// --- 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. // Cleanup only the temp directory created by this test file.
if (!externalStateDir) {
try { try {
if (fs.existsSync(stateDir)) { if (fs.existsSync(stateDir)) {
fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(stateDir, { recursive: true, force: true });
@@ -525,7 +570,6 @@ function runTests() {
} catch (err) { } catch (err) {
console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`); console.error(` [cleanup] failed to remove ${stateDir}: ${err.message}`);
} }
}
console.log(`\n ${passed} passed, ${failed} failed\n`); console.log(`\n ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);

View File

@@ -476,7 +476,7 @@ test('README version row matches package.json', () => {
assert.strictEqual(match[1], expectedVersion); 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 = [ const markdownFiles = [
path.join(repoRoot, 'README.md'), path.join(repoRoot, 'README.md'),
path.join(repoRoot, 'README.zh-CN.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 = []; const offenders = [];
for (const filePath of markdownFiles) { for (const filePath of markdownFiles) {
const source = fs.readFileSync(filePath, 'utf8'); 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)); offenders.push(path.relative(repoRoot, filePath));
} }
} }
@@ -495,7 +495,29 @@ test('user-facing docs do not reference deprecated ecc@ecc plugin identifier', (
assert.deepStrictEqual( assert.deepStrictEqual(
offenders, 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(', ')}`,
); );
}); });

View File

@@ -19,11 +19,13 @@ function cleanup(dirPath) {
} }
function run(args = [], options = {}) { function run(args = [], options = {}) {
const userProfile = options.userProfile || options.homeDir || process.env.USERPROFILE;
const stdout = execFileSync('node', [SCRIPT, ...args], { const stdout = execFileSync('node', [SCRIPT, ...args], {
cwd: options.cwd || path.join(__dirname, '..', '..'), cwd: options.cwd || path.join(__dirname, '..', '..'),
env: { env: {
...process.env, ...process.env,
HOME: options.homeDir || process.env.HOME, HOME: options.homeDir || process.env.HOME,
USERPROFILE: userProfile,
}, },
encoding: 'utf8', encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -132,7 +134,7 @@ function runTests() {
} }
})) passed++; else failed++; })) 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 homeDir = createTempDir('harness-audit-marketplace-home-');
const projectRoot = createTempDir('harness-audit-marketplace-project-'); const projectRoot = createTempDir('harness-audit-marketplace-project-');
@@ -165,6 +167,76 @@ function runTests() {
} }
})) passed++; else failed++; })) 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}`); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);
} }