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>
158 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|