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>
This commit is contained in:
2026-04-10 13:56:58 +09:00
parent 813c65077a
commit ae86d95155
10 changed files with 635 additions and 12 deletions

View File

@@ -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` 참조.

View File

@@ -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

View File

@@ -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

View File

@@ -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),
},
});

83
src/cli/skill-context.ts Normal file
View File

@@ -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;
}
},
});

68
src/cli/skill-trace.ts Normal file
View File

@@ -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;
}
},
});

81
src/enforcement/guard.ts Normal file
View File

@@ -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<GuardResult> {
// 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 };
}

View File

@@ -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<typeof SkillContext>;
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<SkillContext> {
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<SkillContext | null> {
try {
const raw = await readFile(contextPath(railsDir), "utf8");
return SkillContext.parse(JSON.parse(raw));
} catch {
return null;
}
}
export async function clearSkillContext(railsDir: string): Promise<boolean> {
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);
}

View File

@@ -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<typeof TraceEntry>;
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<void> {
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<TraceEntry[]> {
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<number> {
const entries = await readTrace(railsDir);
return entries.filter((e) => e.blocked).length;
}

157
tests/enforcement.test.ts Normal file
View File

@@ -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);
});
});