feat(core,cli): harden install engine and extend skill lint and interview eval
- update only touches the recorded project root; project installs do not follow symlinks or junctions; repeated --force rotates .orig backups; uninstall never deletes outside the recorded root - run npm/npx without a shell on Windows and pin optional tool versions - skip the update check in CI and non-interactive output - lint: cross-document section pointers, preflight row ids, gate rule numbers, per-document size budgets, gate copy equality - eval: scenarios S19-S21, route and read-cost rejudging, interpretation card judging, temp-dir previews and table-form cards no longer fail
This commit is contained in:
parent
ee3bef2ce5
commit
9bcb45cbd3
122 changed files with 5836 additions and 831 deletions
207
packages/core/test/safety.test.ts
Normal file
207
packages/core/test/safety.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { assertNoLinksBelow, backup, removeFileAndPrune, sha256, writeAtomic } from "../src/fsx.ts";
|
||||
import { applyPlan, envForRecord, planForRecord, planInstall, removeInstall } from "../src/installer.ts";
|
||||
import { readManifest, writeManifest } from "../src/manifest.ts";
|
||||
import type { InstallRecord, SkillSource } from "../src/types.ts";
|
||||
|
||||
// 보안 감사(2026-10-01) 재현 시나리오를 회귀 테스트로 고정한다. 모든 쓰기는 임시 디렉터리 안에서만 한다.
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "designpaca-safety-"));
|
||||
after(async () => {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const skill: SkillSource = {
|
||||
version: "9.9.9",
|
||||
skillMd: ["---", "name: designpaca", 'description: "테스트용"', "---", "", "# 본문", ""].join("\n"),
|
||||
files: new Map([["references/tokens.md", "# 토큰\n"]]),
|
||||
body: "# 본문",
|
||||
description: "테스트용",
|
||||
};
|
||||
|
||||
async function fresh(name: string): Promise<{ home: string; cwd: string; outside: string }> {
|
||||
const dir = path.join(tmp, name);
|
||||
const home = path.join(dir, "home");
|
||||
const cwd = path.join(dir, "proj");
|
||||
const outside = path.join(dir, "outside");
|
||||
for (const d of [home, cwd, outside]) await fs.mkdir(d, { recursive: true });
|
||||
return { home, cwd, outside };
|
||||
}
|
||||
|
||||
async function listAll(dir: string): Promise<string[]> {
|
||||
try {
|
||||
return (await fs.readdir(dir, { recursive: true })).map(String).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 디렉터리 링크 — Windows 는 관리자 권한 없이 만들 수 있는 정션을 쓴다 */
|
||||
async function linkDir(target: string, link: string): Promise<void> {
|
||||
await fs.symlink(target, link, process.platform === "win32" ? "junction" : "dir");
|
||||
}
|
||||
|
||||
/** 파일 심링크는 Windows 에서 권한이 없으면 만들 수 없다 — 그때는 false (해당 검증만 생략) */
|
||||
async function tryLinkFile(target: string, link: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.symlink(target, link, "file");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test("writeAtomic: 임시 파일을 남기지 않고, 예측 가능한 <p>.<pid>.tmp 를 쓰지 않는다", async () => {
|
||||
const { outside, cwd } = await fresh("atomic");
|
||||
const victim = path.join(outside, "bashrc");
|
||||
await fs.writeFile(victim, "원래 내용\n", "utf8");
|
||||
const p = path.join(cwd, "AGENTS.md");
|
||||
// 예전 구현이 쓰던 이름에 링크를 심어둬도 따라가지 않는다
|
||||
await tryLinkFile(victim, `${p}.${process.pid}.tmp`);
|
||||
|
||||
await writeAtomic(p, "새 내용\n");
|
||||
assert.equal(await fs.readFile(p, "utf8"), "새 내용\n");
|
||||
assert.equal(await fs.readFile(victim, "utf8"), "원래 내용\n");
|
||||
const leftovers = (await fs.readdir(cwd)).filter((n) => n.endsWith(".tmp") && !n.includes(String(process.pid)));
|
||||
assert.deepEqual(leftovers, []);
|
||||
});
|
||||
|
||||
test("backup: 기존 .orig 를 덮지 않고 .orig.1 로 회전한다", async () => {
|
||||
const { cwd } = await fresh("backup");
|
||||
const p = path.join(cwd, "SKILL.md");
|
||||
await fs.writeFile(p, "첫 수정\n", "utf8");
|
||||
assert.equal(await backup(p), `${p}.orig`);
|
||||
await fs.writeFile(p, "두 번째 수정\n", "utf8");
|
||||
assert.equal(await backup(p), `${p}.orig.1`);
|
||||
assert.equal(await fs.readFile(`${p}.orig`, "utf8"), "첫 수정\n");
|
||||
assert.equal(await fs.readFile(`${p}.orig.1`, "utf8"), "두 번째 수정\n");
|
||||
});
|
||||
|
||||
test("removeFileAndPrune: 기준 밖 파일은 지우지 않는다 (접두사가 같은 형제 디렉터리 포함)", async () => {
|
||||
const { cwd } = await fresh("prune");
|
||||
const root = path.join(cwd, "a", "b");
|
||||
const sibling = path.join(cwd, "a", "bc");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.mkdir(sibling, { recursive: true });
|
||||
const victim = path.join(sibling, "important.txt");
|
||||
await fs.writeFile(victim, "x", "utf8");
|
||||
await assert.rejects(() => removeFileAndPrune(victim, root));
|
||||
await fs.access(victim);
|
||||
});
|
||||
|
||||
test("assertNoLinksBelow: 기준 아래의 정션·심링크를 거부한다", async () => {
|
||||
const { cwd, outside } = await fresh("links");
|
||||
await linkDir(outside, path.join(cwd, ".claude"));
|
||||
await assert.rejects(() => assertNoLinksBelow(cwd, path.join(cwd, ".claude", "skills", "x", "SKILL.md")));
|
||||
await assert.rejects(() => assertNoLinksBelow(cwd, path.join(cwd, "..", "escape.md")));
|
||||
await assertNoLinksBelow(cwd, path.join(cwd, ".cursor", "skills", "x", "SKILL.md"));
|
||||
});
|
||||
|
||||
test("project 설치는 정션으로 연결된 .claude 를 따라 저장소 밖에 쓰지 않는다", async () => {
|
||||
const { home, cwd, outside } = await fresh("junction-install");
|
||||
await linkDir(outside, path.join(cwd, ".claude"));
|
||||
const plan = await planInstall("claude-code", "project", skill, { home, cwd });
|
||||
await assert.rejects(() => applyPlan(plan, skill.version, { env: { home } }), /링크/);
|
||||
assert.deepEqual(await listAll(outside), []);
|
||||
assert.equal((await readManifest(home)).installs.length, 0);
|
||||
});
|
||||
|
||||
test("심링크인 AGENTS.md 에는 주입하지 않는다 (링크 대상 내용을 저장소로 끌어오지 않는다)", async () => {
|
||||
const { home, cwd, outside } = await fresh("symlink-agents");
|
||||
const secret = path.join(outside, "secret.env");
|
||||
await fs.writeFile(secret, "SECRET=abc\n", "utf8");
|
||||
if (!(await tryLinkFile(secret, path.join(cwd, "AGENTS.md")))) return; // 파일 심링크 권한 없음
|
||||
const plan = await planInstall("agents-md", "project", skill, { home, cwd });
|
||||
await assert.rejects(() => applyPlan(plan, skill.version, { env: { home } }), /링크/);
|
||||
assert.equal(await fs.readFile(secret, "utf8"), "SECRET=abc\n");
|
||||
assert.ok((await fs.lstat(path.join(cwd, "AGENTS.md"))).isSymbolicLink());
|
||||
// 계획 전체를 먼저 검사하므로 .designpaca/ 도 만들어지지 않는다
|
||||
await assert.rejects(() => fs.access(path.join(cwd, ".designpaca")));
|
||||
});
|
||||
|
||||
test("설치 기록 없이 이미 있는 다른 내용의 파일은 덮기 전에 .orig 로 남긴다", async () => {
|
||||
const { home, cwd } = await fresh("no-record");
|
||||
const root = path.join(cwd, ".claude", "skills", "designpaca");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(path.join(root, "SKILL.md"), "user own skill\n", "utf8");
|
||||
const plan = await planInstall("claude-code", "project", skill, { home, cwd });
|
||||
const res = await applyPlan(plan, skill.version, { env: { home } });
|
||||
assert.deepEqual(res.backedUp, [path.join(root, "SKILL.md.orig")]);
|
||||
assert.equal(await fs.readFile(path.join(root, "SKILL.md.orig"), "utf8"), "user own skill\n");
|
||||
await removeInstall(res.record, { env: { home } });
|
||||
});
|
||||
|
||||
test("envForRecord: 기록 root 에서 프로젝트·홈을 역산하고, 어댑터가 만들 수 없는 root 는 거부한다", async () => {
|
||||
const { home, cwd } = await fresh("env-record");
|
||||
const plan = await planInstall("agents-md", "project", skill, { home, cwd });
|
||||
const rec: InstallRecord = {
|
||||
target: "agents-md",
|
||||
scope: "project",
|
||||
root: plan.root,
|
||||
version: "0.0.1",
|
||||
installedAt: "",
|
||||
files: [],
|
||||
};
|
||||
assert.deepEqual(await envForRecord(rec), { cwd });
|
||||
const userPlan = await planInstall("claude-code", "user", skill, { home, cwd });
|
||||
assert.deepEqual(await envForRecord({ ...rec, target: "claude-code", scope: "user", root: userPlan.root }), {
|
||||
home,
|
||||
});
|
||||
await assert.rejects(() => envForRecord({ ...rec, root: path.join(cwd, "somewhere") }));
|
||||
const blocked = await planForRecord({ ...rec, root: path.join(cwd, "somewhere") }, skill);
|
||||
assert.ok(blocked.blocked);
|
||||
// 기록 root 그대로 다시 계획된다 — cwd 가 어디든 상관없다
|
||||
const again = await planForRecord(rec, skill);
|
||||
assert.equal(again.root, plan.root);
|
||||
});
|
||||
|
||||
test("변조된 매니페스트로 uninstall 이 root 밖 파일을 지우지 않는다", async () => {
|
||||
const { home, cwd, outside } = await fresh("tampered");
|
||||
const victim = path.join(outside, "sub", "important.txt");
|
||||
await fs.mkdir(path.dirname(victim), { recursive: true });
|
||||
await fs.writeFile(victim, "중요\n", "utf8");
|
||||
|
||||
// (1) 어댑터가 만들 수 없는 root
|
||||
const bogus: InstallRecord = {
|
||||
target: "claude-code",
|
||||
scope: "project",
|
||||
root: outside,
|
||||
version: "0.0.1",
|
||||
installedAt: "",
|
||||
files: [{ path: victim, sha256: sha256("중요\n") }],
|
||||
};
|
||||
// (2) 정상 root 지만 파일 경로가 root 밖 / 주입 문서가 아닌 마커 파일
|
||||
const plan = await planInstall("claude-code", "project", skill, { home, cwd });
|
||||
const sneaky: InstallRecord = {
|
||||
...bogus,
|
||||
root: plan.root,
|
||||
files: [
|
||||
{ path: victim, sha256: sha256("중요\n") },
|
||||
{ path: path.join(outside, "x.md"), sha256: "", marker: "designpaca" },
|
||||
],
|
||||
};
|
||||
await writeManifest({ schema: 1, installs: [bogus, sneaky] }, home);
|
||||
|
||||
for (const rec of [bogus, sneaky]) {
|
||||
const res = await removeInstall(rec, { env: { home }, force: true });
|
||||
assert.equal(res.removed.length, 0);
|
||||
assert.equal(res.refused.length, rec.files.length);
|
||||
}
|
||||
await fs.access(victim);
|
||||
// 거부한 파일은 기록에 남긴다(조용히 사라지지 않는다)
|
||||
assert.equal((await readManifest(home)).installs.length, 2);
|
||||
});
|
||||
|
||||
test("손상된 매니페스트는 덮기 전에 .corrupt-* 로 보존한다", async () => {
|
||||
const { home } = await fresh("corrupt");
|
||||
const file = path.join(home, ".designpaca", "manifest.json");
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, "{ 깨진 json", "utf8");
|
||||
assert.equal((await readManifest(home)).installs.length, 0);
|
||||
await writeManifest({ schema: 1, installs: [] }, home);
|
||||
const kept = (await fs.readdir(path.dirname(file))).filter((n) => n.startsWith("manifest.json.corrupt-"));
|
||||
assert.equal(kept.length, 1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue