Files
hanarang-rails/tests/enforcement.test.ts
이랑이 ae86d95155 feat(sprint-002): Skill 강제 진입 + Bypass 감지
Sprint 002 전체 구현 — F1 (자매 skill bypass) 해결:

Enforcement core:
- src/enforcement/skill-context.ts — 스킬 컨텍스트 생성/읽기/삭제/만료 체크
- src/enforcement/skill-trace.ts — 도구 사용 추적 (JSONL append)
- src/enforcement/guard.ts — pre-tool 가드 (context 유무 + 만료 + escape hatch)

Hooks (실제 로직):
- hooks/pre-tool.sh — Write/Edit/Bash 게이트 (context 없으면 exit 2)
- hooks/post-tool.sh — 도구 사용 trace 자동 기록

CLI:
- rails skill-context {create|show|clear}
- rails skill-trace {show|blocked}

Tests (13 신규, 22 total pass):
- skill-context: CRUD + 만료 감지
- skill-trace: append + read + blocked count
- guard: no-context 차단, valid 허용, expired 차단, RAILS_ENFORCE=off escape hatch

검증: tsc --noEmit ✓ | vitest 22/22 ✓ | build ✓ | rails --help ✓

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:56:58 +09:00

158 lines
4.5 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
createSkillContext,
readSkillContext,
clearSkillContext,
isContextExpired,
} from "../src/enforcement/skill-context.js";
import { appendTrace, readTrace, countBlocked } from "../src/enforcement/skill-trace.js";
import { checkGuard } from "../src/enforcement/guard.js";
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "rails-test-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
describe("skill-context", () => {
it("creates and reads context", async () => {
const ctx = await createSkillContext(testDir, {
skillName: "rails",
pipelineId: "01TEST",
ttlSeconds: 300,
});
expect(ctx.skillName).toBe("rails");
expect(ctx.pipelineId).toBe("01TEST");
const read = await readSkillContext(testDir);
expect(read).not.toBeNull();
expect(read!.skillName).toBe("rails");
});
it("returns null when no context exists", async () => {
const read = await readSkillContext(testDir);
expect(read).toBeNull();
});
it("clears context", async () => {
await createSkillContext(testDir, { skillName: "rails", ttlSeconds: 300 });
const cleared = await clearSkillContext(testDir);
expect(cleared).toBe(true);
const read = await readSkillContext(testDir);
expect(read).toBeNull();
});
it("detects expired context", () => {
const ctx = {
skillName: "rails",
subcommand: "",
pipelineId: "",
contractId: "",
sessionId: "",
createdAt: new Date(Date.now() - 400_000).toISOString(), // 400s ago
ttlSeconds: 300,
};
expect(isContextExpired(ctx)).toBe(true);
});
it("detects valid context", () => {
const ctx = {
skillName: "rails",
subcommand: "",
pipelineId: "",
contractId: "",
sessionId: "",
createdAt: new Date().toISOString(),
ttlSeconds: 300,
};
expect(isContextExpired(ctx)).toBe(false);
});
});
describe("skill-trace", () => {
it("appends and reads trace entries", async () => {
await appendTrace(testDir, {
ts: Date.now(),
tool: "Write",
blocked: false,
reason: "ok",
});
await appendTrace(testDir, {
ts: Date.now(),
tool: "Bash",
blocked: true,
reason: "no-context",
});
const entries = await readTrace(testDir);
expect(entries).toHaveLength(2);
expect(entries[1]!.blocked).toBe(true);
});
it("counts blocked entries", async () => {
await appendTrace(testDir, { ts: Date.now(), tool: "Write", blocked: false, reason: "ok" });
await appendTrace(testDir, { ts: Date.now(), tool: "Edit", blocked: true, reason: "no-ctx" });
await appendTrace(testDir, { ts: Date.now(), tool: "Bash", blocked: true, reason: "expired" });
expect(await countBlocked(testDir)).toBe(2);
});
it("returns empty array when no trace file", async () => {
expect(await readTrace(testDir)).toEqual([]);
});
});
describe("guard", () => {
it("blocks when no context exists", async () => {
const result = await checkGuard(testDir, "Write");
expect(result.allowed).toBe(false);
expect(result.reason).toContain("No skill context");
});
it("allows when valid context exists", async () => {
await createSkillContext(testDir, {
skillName: "rails",
ttlSeconds: 300,
});
const result = await checkGuard(testDir, "Write");
expect(result.allowed).toBe(true);
expect(result.reason).toBe("ok");
});
it("blocks when context is expired", async () => {
await createSkillContext(testDir, {
skillName: "rails",
ttlSeconds: 1, // 1 second TTL
});
// Wait just over 1 second
await new Promise((r) => setTimeout(r, 1100));
const result = await checkGuard(testDir, "Edit");
expect(result.allowed).toBe(false);
expect(result.reason).toContain("expired");
});
it("allows when RAILS_ENFORCE=off", async () => {
process.env["RAILS_ENFORCE"] = "off";
try {
const result = await checkGuard(testDir, "Bash");
expect(result.allowed).toBe(true);
expect(result.reason).toBe("enforcement-off");
} finally {
delete process.env["RAILS_ENFORCE"];
}
});
it("records blocked calls in trace", async () => {
await checkGuard(testDir, "Write");
const entries = await readTrace(testDir);
expect(entries.some((e) => e.blocked && e.tool === "Write")).toBe(true);
});
});