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:
4
Plans.md
4
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` 참조.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
83
src/cli/skill-context.ts
Normal 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
68
src/cli/skill-trace.ts
Normal 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
81
src/enforcement/guard.ts
Normal 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 };
|
||||
}
|
||||
81
src/enforcement/skill-context.ts
Normal file
81
src/enforcement/skill-context.ts
Normal 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);
|
||||
}
|
||||
75
src/enforcement/skill-trace.ts
Normal file
75
src/enforcement/skill-trace.ts
Normal 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
157
tests/enforcement.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user