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>
82 lines
2.2 KiB
TypeScript
82 lines
2.2 KiB
TypeScript
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 };
|
|
}
|