From 16961df25088cfc7ad76f247511ecf215f47c8e0 Mon Sep 17 00:00:00 2001 From: HeiGeAi <221471965+HeiGeAi@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:40:22 +0800 Subject: [PATCH 1/2] fix: harden installer conflict handling --- .github/workflows/ci.yml | 2 ++ CHANGELOG.md | 8 +++++ README.md | 2 ++ SECURITY.md | 9 +++++ package.json | 5 +-- scripts/install.sh | 42 ++++++++++++++++------ tests/install.test.mjs | 76 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md create mode 100644 tests/install.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38918b9..db87db0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,5 @@ jobs: run: node build/validate.mjs - name: 构建三工具产物 run: node build/build.mjs + - name: 安装冲突回归 + run: node --test tests/*.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..829354b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [0.1.1] - 2026-07-31 + +- 安装前预检同名 agent,默认拒绝冲突且不写入任何文件。 +- 需要覆盖时必须显式追加 `--force`。 +- 即使使用 `--force`,仍拒绝目标文件、agent 目录或工具目录 symlink。 +- 新增安装回归测试、CI 门禁与私密安全报告入口。 diff --git a/README.md b/README.md index dd34028..67b7e74 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,8 @@ bash scripts/install.sh codex # 复制到 ~/.codex/prompts/,用 / bash scripts/install.sh openclaw # 复制到 ~/.openclaw/agents/ ``` +安装器遇到同名文件会在写入前退出,避免静默覆盖。确认需要覆盖时,在命令末尾追加 `--force`。 + ## 目录结构 ``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ef5ac62 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +请不要在公开 Issue 中披露未修复的安全漏洞。 + +请通过 GitHub 的私密漏洞报告功能提交问题: + +https://github.com/HeiGeAi/200-agent/security/advisories/new + +报告中请包含复现步骤、影响范围和建议修复方式。维护者会在确认后协调披露时间。 diff --git a/package.json b/package.json index c246a0b..5726daa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "200-agent", - "version": "0.1.0", + "version": "0.1.1", "description": "200 个面向中国市场的 AI agent,单一源转译适配 Claude Code / Codex / OpenClaw", "type": "module", "private": false, @@ -8,7 +8,8 @@ "scripts": { "build": "node build/build.mjs", "validate": "node build/validate.mjs", - "check": "node build/validate.mjs && node build/build.mjs" + "test": "node --test tests/*.test.mjs", + "check": "node build/validate.mjs && node build/build.mjs && node --test tests/*.test.mjs" }, "engines": { "node": ">=18" } } diff --git a/scripts/install.sh b/scripts/install.sh index a62ceb7..3d442f3 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,30 +1,52 @@ #!/usr/bin/env bash -# 把构建产物安装到对应工具目录。用法: bash scripts/install.sh [claude-code|codex|openclaw] +# 把构建产物安装到对应工具目录。用法: bash scripts/install.sh [claude-code|codex|openclaw] [--force] set -euo pipefail cd "$(dirname "$0")/.." [ -d dist ] || { echo "未发现 dist/,先运行: npm run build"; exit 1; } TARGET="${1:-claude-code}" +OPTION="${2:-}" +[ -z "${3:-}" ] || { echo "参数过多"; exit 1; } +[ -z "$OPTION" ] || [ "$OPTION" = "--force" ] || { echo "未知选项: $OPTION"; exit 1; } case "$TARGET" in claude-code) + SRC="dist/claude-code/agents" DEST="$HOME/.claude/agents" - mkdir -p "$DEST" - cp -v dist/claude-code/agents/*.md "$DEST"/ - echo "已安装 200 agent 到 $DEST" ;; codex) + SRC="dist/codex/prompts" DEST="$HOME/.codex/prompts" - mkdir -p "$DEST" - cp -v dist/codex/prompts/*.md "$DEST"/ - echo "已安装到 $DEST,用 / 调用" ;; openclaw) + SRC="dist/openclaw/agents" DEST="$HOME/.openclaw/agents" - mkdir -p "$DEST" - cp -v dist/openclaw/agents/*.md "$DEST"/ - echo "已安装到 $DEST" ;; *) echo "未知目标: $TARGET (支持 claude-code | codex | openclaw)"; exit 1 ;; esac + +TOOL_HOME="${DEST%/*}" +if [ -L "$TOOL_HOME" ] || [ -L "$DEST" ]; then + echo "拒绝写入符号链接目录: $DEST" >&2 + exit 1 +fi + +if [ -d "$DEST" ]; then + for SOURCE in "$SRC"/*.md; do + TARGET_PATH="$DEST/${SOURCE##*/}" + if [ -L "$TARGET_PATH" ]; then + echo "拒绝写入符号链接: $TARGET_PATH" >&2 + exit 1 + fi + if [ "$OPTION" != "--force" ] && [ -e "$TARGET_PATH" ]; then + echo "检测到同名文件冲突: $DEST/${SOURCE##*/}" >&2 + echo "未写入任何文件。确认覆盖时请追加 --force。" >&2 + exit 1 + fi + done +fi + +mkdir -p "$DEST" +cp -v "$SRC"/*.md "$DEST"/ +echo "已安装 200 agent 到 $DEST" diff --git a/tests/install.test.mjs b/tests/install.test.mjs new file mode 100644 index 0000000..cc9ffdc --- /dev/null +++ b/tests/install.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const INSTALL = path.join(ROOT, 'scripts', 'install.sh'); +const SOURCE = path.join(ROOT, 'dist', 'claude-code', 'agents'); + +test('install refuses conflicts before writing unless --force is explicit', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), '200-agent-install-')); + const destination = path.join(home, '.claude', 'agents'); + const names = fs.readdirSync(SOURCE).filter((name) => name.endsWith('.md')).sort(); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(path.join(destination, names[0]), 'keep me\n'); + + const blocked = spawnSync('bash', [INSTALL, 'claude-code'], { + cwd: ROOT, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + assert.notEqual(blocked.status, 0); + assert.match(`${blocked.stdout}${blocked.stderr}`, /冲突/); + assert.equal(fs.readFileSync(path.join(destination, names[0]), 'utf8'), 'keep me\n'); + assert.equal(fs.existsSync(path.join(destination, names[1])), false); + + const forced = spawnSync('bash', [INSTALL, 'claude-code', '--force'], { + cwd: ROOT, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + assert.equal(forced.status, 0, forced.stderr); + assert.equal(fs.readdirSync(destination).filter((name) => name.endsWith('.md')).length, 200); + assert.notEqual(fs.readFileSync(path.join(destination, names[0]), 'utf8'), 'keep me\n'); +}); + +test('--force never follows a destination symlink outside the agent directory', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), '200-agent-symlink-')); + const destination = path.join(home, '.claude', 'agents'); + const name = fs.readdirSync(SOURCE).find((item) => item.endsWith('.md')); + const outside = path.join(home, 'outside.md'); + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync(outside, 'outside\n'); + fs.symlinkSync(outside, path.join(destination, name)); + + const result = spawnSync('bash', [INSTALL, 'claude-code', '--force'], { + cwd: ROOT, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /符号链接/); + assert.equal(fs.readFileSync(outside, 'utf8'), 'outside\n'); +}); + +test('--force rejects a symlinked agent directory before writing', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), '200-agent-dir-symlink-')); + const toolHome = path.join(home, '.claude'); + const destination = path.join(toolHome, 'agents'); + const outside = path.join(home, 'outside'); + fs.mkdirSync(toolHome, { recursive: true }); + fs.mkdirSync(outside); + fs.symlinkSync(outside, destination, 'dir'); + + const result = spawnSync('bash', [INSTALL, 'claude-code', '--force'], { + cwd: ROOT, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /符号链接/); + assert.equal(fs.readdirSync(outside).length, 0); +}); From 055f43829742cb64a27c420f4c767dd6eef92e32 Mon Sep 17 00:00:00 2001 From: HeiGeAi <221471965+HeiGeAi@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:49:46 +0800 Subject: [PATCH 2/2] fix: reject symlinked installer sources --- scripts/install.sh | 13 +++++++++++ tests/install.test.mjs | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 3d442f3..8566541 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -32,6 +32,19 @@ if [ -L "$TOOL_HOME" ] || [ -L "$DEST" ]; then exit 1 fi +SOURCE_PARENT="${SRC%/*}" +SOURCE_ROOT="${SOURCE_PARENT%/*}" +if [ -L "$SOURCE_ROOT" ] || [ -L "$SOURCE_PARENT" ] || [ -L "$SRC" ]; then + echo "拒绝从符号链接源目录复制: $SRC" >&2 + exit 1 +fi +for SOURCE in "$SRC"/*.md; do + if [ -L "$SOURCE" ]; then + echo "拒绝从符号链接源文件复制: $SOURCE" >&2 + exit 1 + fi +done + if [ -d "$DEST" ]; then for SOURCE in "$SRC"/*.md; do TARGET_PATH="$DEST/${SOURCE##*/}" diff --git a/tests/install.test.mjs b/tests/install.test.mjs index cc9ffdc..4d0b072 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -9,6 +9,15 @@ const ROOT = path.resolve(import.meta.dirname, '..'); const INSTALL = path.join(ROOT, 'scripts', 'install.sh'); const SOURCE = path.join(ROOT, 'dist', 'claude-code', 'agents'); +function sourceFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), '200-agent-source-')); + const install = path.join(root, 'scripts', 'install.sh'); + fs.mkdirSync(path.dirname(install), { recursive: true }); + fs.copyFileSync(INSTALL, install); + fs.chmodSync(install, 0o755); + return { root, install }; +} + test('install refuses conflicts before writing unless --force is explicit', () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), '200-agent-install-')); const destination = path.join(home, '.claude', 'agents'); @@ -74,3 +83,44 @@ test('--force rejects a symlinked agent directory before writing', () => { assert.match(`${result.stdout}${result.stderr}`, /符号链接/); assert.equal(fs.readdirSync(outside).length, 0); }); + +test('installer refuses a symlinked source agent file before copying', () => { + const { root, install } = sourceFixture(); + const home = path.join(root, 'home'); + const source = path.join(root, 'dist', 'claude-code', 'agents'); + const outside = path.join(root, 'outside.md'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(outside, 'outside\n'); + fs.symlinkSync(outside, path.join(source, 'agent.md')); + + const result = spawnSync('bash', [install, 'claude-code', '--force'], { + cwd: root, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /符号链接/); + assert.equal(fs.existsSync(path.join(home, '.claude', 'agents', 'agent.md')), false); +}); + +test('installer refuses a symlinked source agent directory before copying', () => { + const { root, install } = sourceFixture(); + const home = path.join(root, 'home'); + const sourceParent = path.join(root, 'dist', 'claude-code'); + const outside = path.join(root, 'outside'); + fs.mkdirSync(sourceParent, { recursive: true }); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'agent.md'), 'outside\n'); + fs.symlinkSync(outside, path.join(sourceParent, 'agents'), 'dir'); + + const result = spawnSync('bash', [install, 'claude-code', '--force'], { + cwd: root, + env: { ...process.env, HOME: home }, + encoding: 'utf8', + }); + + assert.notEqual(result.status, 0); + assert.match(`${result.stdout}${result.stderr}`, /符号链接/); + assert.equal(fs.existsSync(path.join(home, '.claude', 'agents', 'agent.md')), false); +});