Protect jangbu wrapper installs from incomplete payloads

The promoted upstream subskills need to be discoverable without making home installs destructive or incomplete. The wrapper installer now copies its support payload into both home skill roots, allows installed-wrapper reruns, and refuses to overwrite unrelated top-level jangbu-* skills unless explicitly overridden.

Constraint: PR #181 review requires top-level subskill discovery under both Claude and agents roots.

Constraint: Home installs must remain re-runnable without a source checkout.

Rejected: Continue using generic sync_dir for promoted skills | it silently deletes unrelated user-authored skills.

Confidence: high

Scope-risk: narrow

Directive: Do not bypass the promoted-skill ownership check without preserving unrelated home skill directories.

Tested: node --test scripts/skill-docs.test.js --test-name-pattern='korean-jangbu-for'

Tested: bash -n korean-jangbu-for/scripts/install.sh

Tested: bash korean-jangbu-for/scripts/install.sh plus installed ~/.claude and ~/.agents wrapper reruns

Tested: bash ~/.claude/skills/korean-jangbu-for/upstream/scripts/install.sh and verify.sh with Python 3.11 shim

Tested: npm run ci

Tested: Architect verification APPROVED

Not-tested: Live CODEF collection requiring user BYOK credentials and external authentication
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-29 00:50:37 +09:00
commit 11b1150110
4 changed files with 141 additions and 3 deletions

View file

@ -32,6 +32,8 @@
`~/.claude/skills/korean-jangbu-for/upstream/``~/.agents/skills/korean-jangbu-for/upstream/` 양쪽에 pinned SHA 로 업스트림을 설치한다. 동시에 업스트림 `skills/jangbu-*``~/.claude/skills/<skill-name>``~/.agents/skills/<skill-name>` top-level 경로로 등록해 slash skill discovery 가 중첩 `upstream/` 탐색에 의존하지 않게 한다. `/korean-jangbu-for` 는 wrapper top-level skill 로 유지한다.
홈 디렉터리 wrapper 에는 재실행 가능한 `scripts/install.sh`, `scripts/upstream.pin`, `LICENSE.upstream`, `DISCLAIMER.md`, `NOTICE` 까지 함께 복사된다. Promoted `jangbu-*` top-level 스킬은 wrapper 가 이전에 설치한 managed copy 만 자동 갱신하며, 같은 이름의 unrelated/user-authored 스킬이 있으면 덮어쓰지 않고 중단한다. 의도적으로 교체해야 하는 경우에만 `KOREAN_JANGBU_FOR_OVERWRITE_SKILLS=1` 로 재실행한다.
```bash
bash korean-jangbu-for/scripts/install.sh
```

View file

@ -48,6 +48,8 @@ metadata:
업스트림을 `~/.claude/skills/korean-jangbu-for/upstream/``~/.agents/skills/korean-jangbu-for/upstream/` 양쪽에 pinned SHA 로 체크아웃한다. 또한 업스트림 `skills/jangbu-*` 를 양쪽 홈 디렉터리의 top-level skill 로 등록해 `/jangbu-connect`, `/jangbu-import`, `/jangbu-tag`, `/jangbu-tax`, `/jangbu-dash`, `/jangbu-jongso` 라우팅이 agent-compatible 런타임에서도 발견되게 한다. `/korean-jangbu-for` 는 이 wrapper 의 top-level skill 로 유지한다. 레포 내부에는 업스트림 payload 를 커밋하지 않는다.
홈 디렉터리 wrapper 에는 `SKILL.md`, `scripts/install.sh`, `scripts/upstream.pin`, `LICENSE.upstream`, `DISCLAIMER.md`, `NOTICE` 를 함께 설치한다. Promoted `jangbu-*` top-level 경로에 사용자가 만든 다른 스킬이 이미 있으면 installer 는 덮어쓰지 않고 중단한다. wrapper 가 이전에 설치한 managed 스킬만 자동 갱신하며, 의도적으로 교체해야 할 때만 `KOREAN_JANGBU_FOR_OVERWRITE_SKILLS=1` 을 설정한다.
```bash
bash korean-jangbu-for/scripts/install.sh
```

View file

@ -17,8 +17,10 @@ set -euo pipefail
UPSTREAM_REPO="${KOREAN_JANGBU_FOR_UPSTREAM_REPO:-https://github.com/kimlawtech/korean-jangbu-for.git}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WRAPPER_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PIN_FILE="${SCRIPT_DIR}/upstream.pin"
SKILL_NAME="korean-jangbu-for"
MANAGED_MARKER="k-skill wrapper attribution and disclaimer"
if [[ ! -f "${PIN_FILE}" ]]; then
echo "[korean-jangbu-for] upstream.pin not found at ${PIN_FILE}" >&2
@ -58,6 +60,52 @@ sync_dir() {
fi
}
copy_file_if_different() {
local source_file="$1"
local target_file="$2"
if [[ -e "${target_file}" ]] && [[ "$(cd "$(dirname "${source_file}")" && pwd -P)/$(basename "${source_file}")" == "$(cd "$(dirname "${target_file}")" && pwd -P)/$(basename "${target_file}")" ]]; then
return 0
fi
cp "${source_file}" "${target_file}"
}
install_wrapper_payload() {
local target_dir="$1"
mkdir -p "${target_dir}/scripts"
copy_file_if_different "${WRAPPER_DIR}/SKILL.md" "${target_dir}/SKILL.md"
copy_file_if_different "${WRAPPER_DIR}/LICENSE.upstream" "${target_dir}/LICENSE.upstream"
copy_file_if_different "${WRAPPER_DIR}/DISCLAIMER.md" "${target_dir}/DISCLAIMER.md"
copy_file_if_different "${WRAPPER_DIR}/NOTICE" "${target_dir}/NOTICE"
copy_file_if_different "${WRAPPER_DIR}/scripts/install.sh" "${target_dir}/scripts/install.sh"
copy_file_if_different "${WRAPPER_DIR}/scripts/upstream.pin" "${target_dir}/scripts/upstream.pin"
chmod +x "${target_dir}/scripts/install.sh"
}
is_managed_promoted_skill() {
local target_dir="$1"
local skill_file="${target_dir}/SKILL.md"
[[ -f "${skill_file}" ]] && grep -q "${MANAGED_MARKER}" "${skill_file}"
}
sync_promoted_skill() {
local source_dir="$1"
local target_dir="$2"
if [[ -e "${target_dir}" || -L "${target_dir}" ]]; then
if [[ "${KOREAN_JANGBU_FOR_OVERWRITE_SKILLS:-}" != "1" ]] && ! is_managed_promoted_skill "${target_dir}"; then
echo "[korean-jangbu-for] refusing to overwrite unrelated skill: ${target_dir}" >&2
echo " Set KOREAN_JANGBU_FOR_OVERWRITE_SKILLS=1 to replace this top-level skill." >&2
exit 1
fi
fi
sync_dir "${source_dir}" "${target_dir}"
}
append_response_policy() {
local skill_file="$1"
@ -110,8 +158,7 @@ for HOME_SKILL_DIR in "${HOME_DIRS[@]}"; do
if [[ -L "${HOME_SKILL_DIR}" ]]; then
rm -f "${HOME_SKILL_DIR}"
fi
mkdir -p "${HOME_SKILL_DIR}"
cp "${SCRIPT_DIR}/../SKILL.md" "${HOME_SKILL_DIR}/SKILL.md"
install_wrapper_payload "${HOME_SKILL_DIR}"
sync_dir "${CLONE_DIR}" "${HOME_UPSTREAM}"
@ -133,7 +180,7 @@ for HOME_SKILL_DIR in "${HOME_DIRS[@]}"; do
exit 1
fi
sync_dir "${UPSTREAM_SKILL_DIR}" "${HOME_UPSTREAM_SKILL_DIR}"
sync_promoted_skill "${UPSTREAM_SKILL_DIR}" "${HOME_UPSTREAM_SKILL_DIR}"
append_response_policy "${HOME_UPSTREAM_SKILL_DIR}/SKILL.md"
echo "[korean-jangbu-for] registered upstream skill /${UPSTREAM_SKILL} -> ${HOME_UPSTREAM_SKILL_DIR}"

View file

@ -3070,6 +3070,19 @@ test("korean-jangbu-for installer registers upstream subskills for Claude and ag
`${root} should replace conflicting upstream korean-jangbu-for symlinks with a wrapper directory`,
);
for (const requiredPath of [
"scripts/install.sh",
"scripts/upstream.pin",
"LICENSE.upstream",
"DISCLAIMER.md",
"NOTICE",
]) {
assert.ok(
fs.existsSync(path.join(skillRoot, "korean-jangbu-for", requiredPath)),
`${root} should install wrapper support payload ${requiredPath}`,
);
}
for (const skillName of upstreamSubskills) {
const installedSubskillPath = path.join(skillRoot, skillName, "SKILL.md");
assert.ok(
@ -3087,6 +3100,80 @@ test("korean-jangbu-for installer registers upstream subskills for Claude and ag
assert.match(installedSubskill, /세무신고/);
}
}
for (const installedRoot of [".claude", ".agents"]) {
childProcess.execFileSync("bash", [path.join(homeDir, installedRoot, "skills", "korean-jangbu-for", "scripts", "install.sh")], {
cwd: repoRoot,
env: {
...process.env,
HOME: homeDir,
KOREAN_JANGBU_FOR_UPSTREAM_REPO: upstreamDir,
KOREAN_JANGBU_FOR_UPSTREAM_SHA: upstreamSha,
},
stdio: "pipe",
});
}
});
test("korean-jangbu-for installer refuses to overwrite unrelated promoted subskills", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "korean-jangbu-for-collision-"));
const homeDir = path.join(tmpDir, "home");
const upstreamDir = path.join(tmpDir, "upstream");
const installPath = path.join(repoRoot, "korean-jangbu-for", "scripts", "install.sh");
const upstreamSubskills = [
"jangbu-connect",
"jangbu-dash",
"jangbu-import",
"jangbu-jongso",
"jangbu-tag",
"jangbu-tax",
];
for (const skillName of upstreamSubskills) {
fs.mkdirSync(path.join(upstreamDir, "skills", skillName), { recursive: true });
fs.writeFileSync(
path.join(upstreamDir, "skills", skillName, "SKILL.md"),
`---\nname: ${skillName}\n---\n\n# ${skillName}\n`,
);
}
fs.mkdirSync(path.join(upstreamDir, "scripts"), { recursive: true });
fs.writeFileSync(path.join(upstreamDir, "scripts", "verify.sh"), "#!/usr/bin/env bash\nexit 0\n");
childProcess.execFileSync("git", ["init"], { cwd: upstreamDir, stdio: "ignore" });
childProcess.execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: upstreamDir });
childProcess.execFileSync("git", ["config", "user.name", "Test"], { cwd: upstreamDir });
childProcess.execFileSync("git", ["add", "."], { cwd: upstreamDir });
childProcess.execFileSync("git", ["commit", "-m", "seed upstream skills"], { cwd: upstreamDir, stdio: "ignore" });
const upstreamSha = childProcess.execFileSync("git", ["rev-parse", "HEAD"], { cwd: upstreamDir, encoding: "utf8" }).trim();
const unrelatedSkillDir = path.join(homeDir, ".claude", "skills", "jangbu-tax");
fs.mkdirSync(unrelatedSkillDir, { recursive: true });
fs.writeFileSync(
path.join(unrelatedSkillDir, "SKILL.md"),
"---\nname: jangbu-tax\n---\n\n# user-authored jangbu-tax\n",
);
assert.throws(
() =>
childProcess.execFileSync("bash", [installPath], {
cwd: repoRoot,
env: {
...process.env,
HOME: homeDir,
KOREAN_JANGBU_FOR_UPSTREAM_REPO: upstreamDir,
KOREAN_JANGBU_FOR_UPSTREAM_SHA: upstreamSha,
},
stdio: "pipe",
}),
/refusing to overwrite unrelated skill/,
);
assert.match(
fs.readFileSync(path.join(unrelatedSkillDir, "SKILL.md"), "utf8"),
/user-authored jangbu-tax/,
"unrelated existing subskill should be preserved after installer refusal",
);
});
test("korean-jangbu-for feature doc documents source-first use and mandatory attribution", () => {