diff --git a/Plans.md b/Plans.md index 8bc8662..fa6c44f 100644 --- a/Plans.md +++ b/Plans.md @@ -17,7 +17,7 @@ |---|---|---|---| | 0 | 세이프티 네트 + 실패 감사 + 프로젝트 세팅 | [SPRINT-000](.plans/sprints/SPRINT-000-safety-and-audit.md) | cc:완료 [bac114d] | | 1 | 스켈레톤: XState FSM + orchestrator + .plans/ 스캐폴딩 | [SPRINT-001](.plans/sprints/SPRINT-001-skeleton.md) | cc:완료 [PR#1] | -| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:TODO | +| 2 | Skill 강제 진입 hook + bypass 감지 + 차단 | [SPRINT-002](.plans/sprints/SPRINT-002-enforcement.md) | cc:WIP | | 3 | Sprint Contract + DoD validator (Zod) | [SPRINT-003](.plans/sprints/SPRINT-003-contract.md) | cc:TODO | | 4 | 4자매 핸드오프 엔진 (상태 전이 기반) | [SPRINT-004](.plans/sprints/SPRINT-004-handoff.md) | cc:TODO | | 5 | 재시도 / 타임아웃 / 에스컬레이션 policy | [SPRINT-005](.plans/sprints/SPRINT-005-resilience.md) | cc:TODO | @@ -26,7 +26,7 @@ ## 현재 스프린트 -**Sprint 002 — Skill 강제 진입 + Bypass 감지** (`cc:TODO`) +**Sprint 002 — Skill 강제 진입 + Bypass 감지** (`cc:WIP`) 다음 착수 예정. 상세는 `.plans/sprints/SPRINT-001-skeleton.md` 참조. diff --git a/hooks/post-tool.sh b/hooks/post-tool.sh index 15b51f8..852b0dd 100755 --- a/hooks/post-tool.sh +++ b/hooks/post-tool.sh @@ -1,8 +1,40 @@ #!/usr/bin/env bash -# hanarang-rails post-tool hook (thin shim) -# 현재 no-op — Sprint 002 에서 skill bypass 감지 + revert 로직 주입 예정. -# 입력: stdin 으로 tool use result JSON -# 출력: exit 0 = proceed - +# hanarang-rails post-tool hook +# Appends tool usage to skill trace for audit. +# Input: stdin JSON event from Claude Code +# Exit: always 0 (post-hook should not block) set -euo pipefail + +EVENT=$(cat) +CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}" +TRACE_FILE="$CWD/.rails/skill-trace.jsonl" + +# Ensure directory +mkdir -p "$(dirname "$TRACE_FILE")" + +# Extract fields +TOOL=$(echo "$EVENT" | jq -r '.tool_name // "unknown"' 2>/dev/null || echo "unknown") +SESSION_ID="${CLAUDE_SESSION_ID:-}" + +# Read pipeline ID from context if available +PIPELINE_ID="" +CONTEXT_FILE="$CWD/.rails/skill-context.json" +if [[ -f "$CONTEXT_FILE" ]]; then + PIPELINE_ID=$(jq -r '.pipelineId // ""' "$CONTEXT_FILE" 2>/dev/null || true) +fi + +# Append trace entry +ENTRY=$(jq -n \ + --argjson ts "$(date +%s)000" \ + --arg tool "$TOOL" \ + --arg cwd "$CWD" \ + --arg sessionId "$SESSION_ID" \ + --arg pipelineId "$PIPELINE_ID" \ + '{ts: $ts, tool: $tool, cwd: $cwd, sessionId: $sessionId, pipelineId: $pipelineId, blocked: false, reason: "post-trace"}' \ + 2>/dev/null || true) + +if [[ -n "$ENTRY" ]]; then + echo "$ENTRY" >> "$TRACE_FILE" +fi + exit 0 diff --git a/hooks/pre-tool.sh b/hooks/pre-tool.sh index e6cc25d..463c2d1 100755 --- a/hooks/pre-tool.sh +++ b/hooks/pre-tool.sh @@ -1,8 +1,50 @@ #!/usr/bin/env bash -# hanarang-rails pre-tool hook (thin shim) -# 현재 no-op — Sprint 002 에서 skill-enforcement 로직 주입 예정. -# 입력: stdin 으로 tool use event JSON -# 출력: exit 0 = proceed, exit 2 = block - +# hanarang-rails pre-tool hook +# Blocks Write/Edit/Bash if no valid skill context exists. +# Input: stdin JSON event from Claude Code +# Exit: 0 = allow, 2 = block set -euo pipefail + +# Escape hatch +if [[ "${RAILS_ENFORCE:-on}" == "off" ]]; then + exit 0 +fi + +# Read tool event from stdin +EVENT=$(cat) +TOOL=$(echo "$EVENT" | jq -r '.tool_name // empty' 2>/dev/null || true) + +# Only gate Write, Edit, Bash +case "$TOOL" in + Write|Edit|Bash) ;; + *) exit 0 ;; +esac + +# Find project root +CWD="${CLAUDE_PROJECT_DIR:-$(pwd)}" +CONTEXT_FILE="$CWD/.rails/skill-context.json" + +# Check context exists +if [[ ! -f "$CONTEXT_FILE" ]]; then + echo "[rails-enforce] No skill context. Enter the pipeline via /rails first." >&2 + exit 2 +fi + +# Check context not expired (TTL check) +if command -v jq >/dev/null 2>&1; then + CREATED=$(jq -r '.createdAt // empty' "$CONTEXT_FILE" 2>/dev/null || true) + TTL=$(jq -r '.ttlSeconds // 300' "$CONTEXT_FILE" 2>/dev/null || echo 300) + + if [[ -n "$CREATED" ]]; then + CREATED_EPOCH=$(date -d "$CREATED" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${CREATED%%.*}" +%s 2>/dev/null || echo 0) + NOW_EPOCH=$(date +%s) + AGE=$(( NOW_EPOCH - CREATED_EPOCH )) + + if [[ "$AGE" -gt "$TTL" ]]; then + echo "[rails-enforce] Skill context expired (age: ${AGE}s > ttl: ${TTL}s). Re-enter the skill." >&2 + exit 2 + fi + fi +fi + exit 0 diff --git a/src/cli/index.ts b/src/cli/index.ts index 14cea7f..3c7099d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,10 @@ const main = defineCommand({ start: () => import("./start.js").then((m) => m.default), status: () => import("./status.js").then((m) => m.default), serve: () => import("./serve.js").then((m) => m.default), + "skill-context": () => + import("./skill-context.js").then((m) => m.default), + "skill-trace": () => + import("./skill-trace.js").then((m) => m.default), }, }); diff --git a/src/cli/skill-context.ts b/src/cli/skill-context.ts new file mode 100644 index 0000000..f7b97f2 --- /dev/null +++ b/src/cli/skill-context.ts @@ -0,0 +1,83 @@ +import { defineCommand } from "citty"; +import { + createSkillContext, + readSkillContext, + clearSkillContext, + contextAgeSeconds, + isContextExpired, +} from "../enforcement/skill-context.js"; + +export default defineCommand({ + meta: { + name: "skill-context", + description: "Manage skill enforcement context", + }, + args: { + action: { + type: "positional", + description: "Action: create | show | clear", + required: true, + }, + skillName: { + type: "string", + alias: "s", + description: "Skill name (for create)", + default: "rails", + }, + pipelineId: { + type: "string", + alias: "p", + description: "Pipeline ID (for create)", + default: "", + }, + ttl: { + type: "string", + description: "TTL in seconds (for create)", + default: "300", + }, + }, + async run({ args }) { + const cwd = process.cwd(); + + switch (args.action) { + case "create": { + const ctx = await createSkillContext(cwd, { + skillName: args.skillName, + pipelineId: args.pipelineId, + ttlSeconds: parseInt(args.ttl, 10) || 300, + }); + console.log(`Skill context created:`); + console.log(` skill: ${ctx.skillName}`); + console.log(` pipeline: ${ctx.pipelineId || "(none)"}`); + console.log(` ttl: ${ctx.ttlSeconds}s`); + console.log(` created: ${ctx.createdAt}`); + break; + } + case "show": { + const ctx = await readSkillContext(cwd); + if (!ctx) { + console.log("No skill context found."); + return; + } + const age = contextAgeSeconds(ctx); + const expired = isContextExpired(ctx); + console.log(`Skill context:`); + console.log(` skill: ${ctx.skillName}`); + console.log(` pipeline: ${ctx.pipelineId || "(none)"}`); + console.log(` session: ${ctx.sessionId || "(none)"}`); + console.log(` created: ${ctx.createdAt}`); + console.log(` age: ${age}s / ${ctx.ttlSeconds}s`); + console.log(` expired: ${expired}`); + break; + } + case "clear": { + const cleared = await clearSkillContext(cwd); + console.log(cleared ? "Skill context cleared." : "No context to clear."); + break; + } + default: + console.error(`Unknown action: ${args.action}. Use create | show | clear.`); + process.exitCode = 1; + } + }, +}); diff --git a/src/cli/skill-trace.ts b/src/cli/skill-trace.ts new file mode 100644 index 0000000..c76d6b4 --- /dev/null +++ b/src/cli/skill-trace.ts @@ -0,0 +1,68 @@ +import { defineCommand } from "citty"; +import { readTrace, countBlocked } from "../enforcement/skill-trace.js"; + +export default defineCommand({ + meta: { + name: "skill-trace", + description: "View skill enforcement trace log", + }, + args: { + action: { + type: "positional", + description: "Action: show | blocked", + required: false, + default: "show", + }, + pipelineId: { + type: "string", + alias: "p", + description: "Filter by pipeline ID", + default: "", + }, + limit: { + type: "string", + alias: "n", + description: "Number of entries to show", + default: "20", + }, + }, + async run({ args }) { + const cwd = process.cwd(); + const action = args.action || "show"; + + switch (action) { + case "show": { + const entries = await readTrace(cwd, { + pipelineId: args.pipelineId || undefined, + limit: parseInt(args.limit, 10) || 20, + }); + + if (entries.length === 0) { + console.log("No trace entries found."); + return; + } + + console.log( + `${"TIMESTAMP".padEnd(15)} ${"TOOL".padEnd(10)} ${"BLOCKED".padEnd(8)} REASON`, + ); + console.log("-".repeat(60)); + for (const e of entries) { + const time = new Date(e.ts).toISOString().slice(11, 19); + console.log( + `${time.padEnd(15)} ${e.tool.padEnd(10)} ${String(e.blocked).padEnd(8)} ${e.reason}`, + ); + } + console.log(`\nTotal: ${entries.length} entries`); + break; + } + case "blocked": { + const count = await countBlocked(cwd); + console.log(`Blocked tool calls: ${count}`); + break; + } + default: + console.error(`Unknown action: ${action}. Use show | blocked.`); + process.exitCode = 1; + } + }, +}); diff --git a/src/enforcement/guard.ts b/src/enforcement/guard.ts new file mode 100644 index 0000000..72116db --- /dev/null +++ b/src/enforcement/guard.ts @@ -0,0 +1,81 @@ +import { + readSkillContext, + isContextExpired, + contextAgeSeconds, + type SkillContext, +} from "./skill-context.js"; +import { appendTrace } from "./skill-trace.js"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "guard" }); + +export interface GuardResult { + allowed: boolean; + reason: string; + context: SkillContext | null; +} + +/** + * Check whether the current operation is allowed based on skill context. + * Used by pre-tool hook to gate Write/Edit/Bash calls. + */ +export async function checkGuard( + railsDir: string, + toolName: string, + opts?: { sessionId?: string }, +): Promise { + // Escape hatch + if (process.env["RAILS_ENFORCE"] === "off") { + log.warn({ toolName }, "Enforcement disabled via RAILS_ENFORCE=off"); + await appendTrace(railsDir, { + ts: Date.now(), + tool: toolName, + sessionId: opts?.sessionId ?? "", + blocked: false, + reason: "enforcement-off", + }); + return { allowed: true, reason: "enforcement-off", context: null }; + } + + const ctx = await readSkillContext(railsDir); + + if (!ctx) { + const reason = "No skill context found. Run /rails or rails skill-context create first."; + log.warn({ toolName }, reason); + await appendTrace(railsDir, { + ts: Date.now(), + tool: toolName, + sessionId: opts?.sessionId ?? "", + blocked: true, + reason: "no-context", + }); + return { allowed: false, reason, context: null }; + } + + if (isContextExpired(ctx)) { + const age = contextAgeSeconds(ctx); + const reason = `Skill context expired (age: ${age}s, ttl: ${ctx.ttlSeconds}s). Re-enter the skill.`; + log.warn({ toolName, age, ttl: ctx.ttlSeconds }, reason); + await appendTrace(railsDir, { + ts: Date.now(), + tool: toolName, + sessionId: opts?.sessionId ?? "", + pipelineId: ctx.pipelineId, + blocked: true, + reason: "context-expired", + }); + return { allowed: false, reason, context: ctx }; + } + + // Valid context + await appendTrace(railsDir, { + ts: Date.now(), + tool: toolName, + sessionId: opts?.sessionId ?? "", + pipelineId: ctx.pipelineId, + blocked: false, + reason: "ok", + }); + + return { allowed: true, reason: "ok", context: ctx }; +} diff --git a/src/enforcement/skill-context.ts b/src/enforcement/skill-context.ts new file mode 100644 index 0000000..f3f91fa --- /dev/null +++ b/src/enforcement/skill-context.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "skill-context" }); + +export const SkillContext = z.object({ + skillName: z.string(), + subcommand: z.string().default(""), + pipelineId: z.string().default(""), + contractId: z.string().default(""), + sessionId: z.string().default(""), + createdAt: z.string().datetime(), + ttlSeconds: z.number().int().positive().default(300), +}); + +export type SkillContext = z.infer; + +const CONTEXT_FILENAME = "skill-context.json"; + +function contextPath(railsDir: string): string { + return join(railsDir, ".rails", CONTEXT_FILENAME); +} + +export async function createSkillContext( + railsDir: string, + data: { + skillName: string; + subcommand?: string; + pipelineId?: string; + contractId?: string; + sessionId?: string; + ttlSeconds?: number; + }, +): Promise { + const parsed = SkillContext.parse({ + ...data, + createdAt: new Date().toISOString(), + }); + + const filePath = contextPath(railsDir); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, JSON.stringify(parsed, null, 2), "utf8"); + + log.info({ skillName: parsed.skillName, pipelineId: parsed.pipelineId }, "Skill context created"); + return parsed; +} + +export async function readSkillContext( + railsDir: string, +): Promise { + try { + const raw = await readFile(contextPath(railsDir), "utf8"); + return SkillContext.parse(JSON.parse(raw)); + } catch { + return null; + } +} + +export async function clearSkillContext(railsDir: string): Promise { + try { + await unlink(contextPath(railsDir)); + log.info("Skill context cleared"); + return true; + } catch { + return false; + } +} + +export function isContextExpired(ctx: SkillContext): boolean { + const createdMs = new Date(ctx.createdAt).getTime(); + const nowMs = Date.now(); + const elapsedSeconds = (nowMs - createdMs) / 1000; + return elapsedSeconds > ctx.ttlSeconds; +} + +export function contextAgeSeconds(ctx: SkillContext): number { + const createdMs = new Date(ctx.createdAt).getTime(); + return Math.floor((Date.now() - createdMs) / 1000); +} diff --git a/src/enforcement/skill-trace.ts b/src/enforcement/skill-trace.ts new file mode 100644 index 0000000..f8111c4 --- /dev/null +++ b/src/enforcement/skill-trace.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { appendFile, readFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { childLogger } from "../logger.js"; + +const log = childLogger({ module: "skill-trace" }); + +export const TraceEntry = z.object({ + ts: z.number(), + tool: z.string(), + cwd: z.string().default(""), + sessionId: z.string().default(""), + pipelineId: z.string().default(""), + blocked: z.boolean().default(false), + reason: z.string().default(""), +}); + +export type TraceEntry = z.infer; + +const TRACE_FILENAME = "skill-trace.jsonl"; + +function tracePath(railsDir: string): string { + return join(railsDir, ".rails", TRACE_FILENAME); +} + +export async function appendTrace( + railsDir: string, + entry: { + ts: number; + tool: string; + cwd?: string; + sessionId?: string; + pipelineId?: string; + blocked: boolean; + reason?: string; + }, +): Promise { + const filePath = tracePath(railsDir); + await mkdir(dirname(filePath), { recursive: true }); + + const parsed = TraceEntry.parse(entry); + await appendFile(filePath, JSON.stringify(parsed) + "\n", "utf8"); + + if (parsed.blocked) { + log.warn({ tool: parsed.tool, reason: parsed.reason }, "Tool call blocked"); + } +} + +export async function readTrace( + railsDir: string, + opts?: { pipelineId?: string; limit?: number }, +): Promise { + try { + const raw = await readFile(tracePath(railsDir), "utf8"); + const lines = raw.trim().split("\n").filter(Boolean); + let entries = lines.map((line) => TraceEntry.parse(JSON.parse(line))); + + if (opts?.pipelineId) { + entries = entries.filter((e) => e.pipelineId === opts.pipelineId); + } + + if (opts?.limit) { + entries = entries.slice(-opts.limit); + } + + return entries; + } catch { + return []; + } +} + +export async function countBlocked(railsDir: string): Promise { + const entries = await readTrace(railsDir); + return entries.filter((e) => e.blocked).length; +} diff --git a/tests/enforcement.test.ts b/tests/enforcement.test.ts new file mode 100644 index 0000000..69fd4a7 --- /dev/null +++ b/tests/enforcement.test.ts @@ -0,0 +1,157 @@ +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); + }); +});