gajae-code · 가드레일: 권한·승인·스폰 게이트
한 줄 요약
gajae-code는 코딩 에이전트가 위험한 행동(파일 삭제, 대량 작업, 무단 쓰기)을 하기 전에 여러 겹의 잠금장치를 통과시킨다. 왜 배우나: AI가 알아서 일하게 두되 “모호하거나 비싸거나 파괴적인 행동”만 골라 막는 안전장치 설계 방법을 배우기 위해서다.
그림
flowchart TD M[모델이 도구를 호출] --> P{"문 1<br/>ACP 권한 팝업<br/>사용자에게 물어볼 도구인가?"} P -- 사용자가 거부 --> X1["차단: 사용자가 거절함"] P -- 허용 / 팝업 대상 아님 --> T{"어떤 도구인가?"} T -- bash 터미널 --> B{"문 2<br/>허용 명령어 목록"} B -- env 끼워넣기 시도 --> X2[차단] B -- 금지 문자·미허가 명령 --> X3["차단: 거부 사유 반환"] B -- 통과 --> BE[제한표식 주입 후 실행] T -- write / edit 쓰기 --> PM{"문 3<br/>계획 모드인가?"} PM -- 계획 파일이 아님 --> X4["차단: 계획 파일만 수정 가능"] PM -- 계획 파일 맞음 --> W[쓰기 실행] T -- task 자식 생성 --> SG{"문 4<br/>5개 이상 또는<br/>reviewer→explore?"} SG -- 정당화 영수증 누락 --> X5["차단: 누락 필드 안내"] SG -- 영수증 완비 --> S[자식 에이전트 생성]
쉽게 풀기
은행 금고로 가는 길에 문이 네 개 있다고 생각하면 쉽다. 각 문은 서로 다른 위험을 막는다.
문 1 — 사용자 허락 팝업 (가장 바깥문) ACP 클라이언트(에이전트를 띄운 앱)가 붙어 있으면, 터미널 실행·삭제·이동 같은 위험한 도구를 쓰기 직전 사용자에게 “이거 해도 될까요?” 하고 묻는다. 사용자가 “거부”하면 그 자리에서 멈춘다. “항상 허용”을 누르면 다음부터는 같은 도구에 대해 안 묻는다(영수증을 한 번 끊어두는 것과 비슷).
문 2 — 읽기 전용 역할 에이전트의 터미널 자물쇠 어떤 하위 에이전트는 터미널을 쓸 수는 있지만, 미리 허락된 명령어만 통과한다. 비유하면 “이 직원은 창고에서 ‘재고 조회’와 ‘재고 입력’만 할 수 있고, ‘창고 비우기’는 못 한다”는 식이다.
- 허용:
gjc state로 상태 읽기/쓰기,gjc ralplan --write로 계획 저장 - 차단: 파일 삭제, 환경변수 바꿔치기, 상태 통째로 지우기(
clear), 작업 떠넘기기(handoff) - 추가 함정 방지: 세미콜론·파이프·백틱 같은 “명령어 합치기/숨기기” 문자도 발견 즉시 막는다. 즉
진짜명령 ; 몰래삭제같은 우회를 차단한다.
문 3 — 계획 모드 쓰기 자물쇠 사람이 계획을 승인하기 전까지는, 에이전트가 건드릴 수 있는 파일은 계획 파일 단 하나뿐이다. 다른 파일을 만들거나 지우거나 이름을 바꾸려 하면 거부된다. “설계도가 결재되기 전에는 자재를 못 만진다”는 규칙이다.
문 4 — 스폰(자식 대량 생성) 게이트 에이전트가 한 번에 여러 하위 작업을 띄울 때, 5개 이상이면 “정당화 영수증(receipt)“을 반드시 첨부해야 한다. 영수증에는 “왜 병렬이어야 하나, 왜 네가 직접 못 하나, 작업들이 정말 서로 독립적인가, 결과는 어떤 모양으로 돌려줄 건가”를 적는다. 영수증이 비어 있으면 자식을 띄우지 않고, 무엇이 빠졌는지 적은 메모를 모델에게 돌려준다. 그러면 모델이 빈칸을 채워 다시 시도한다.
핵심 철학 한 줄: 기본은 차단(deny by default), 정당화가 있어야 통과(gate by justification). 그리고 거부 메시지는 사람이 아니라 모델 본인에게 돌려줘서, 모델이 스스로 고쳐 다시 오게 만든다.
핵심 정리
| 게이트(문) | 막는 위험 | 통과 조건 |
|---|---|---|
| ACP 권한 팝업 | 위험 도구의 무단 실행 | 사용자가 허용 |
| bash 허용목록 | 미허가 셸 명령·우회 | 화이트리스트 prefix + gjc 규칙 |
| 계획 모드 쓰기 | 승인 전 코드 변경 | 대상이 계획 파일 하나뿐 |
| 스폰 게이트 | 비싼 대량 병렬 작업 | 5개 미만 또는 영수증 완비 |
터미널(bash)에서 막는 것들 —
bash-allowed-prefixes.ts
; | & < > ( ): 명령 합치기·리다이렉트·서브셸 → 발견 즉시 차단$ * ? [ ] { } ~: 따옴표 밖 확장 문자 → 차단- 백틱과
$(...): “command substitution is not allowed”- 백슬래시
\: “backslash escapes are not allowed”- 개행
\n \r: “multiple shell commands are not allowed”gjc ralplan:--write토큰이 없으면 거부gjc state:read / write / contract만 허용,clear·handoff는 명시적으로 차단
스폰 영수증(SpawnPlanReceipt) 5개 필드 — 하나라도 비면 거부:
-
whyParallel— 왜 병렬 실행이 필요한가 (문자) -
whyNotLocal— 왜 부모가 직접 처리하지 않는가 (문자) -
independence— 자식 작업들이 서로 독립적이라는 근거 (문자) -
expectedReceiptShape— 자식이 돌려줄 결과의 기대 형태 (문자) -
maxInlineTokens— 인라인 토큰 상한 (숫자,> 0인 유한수)
스키마 상세
bash 검사 반환(
BashAllowedPrefixesCheck):allowed(boolean, 필수),reason(string, 거부 사유 — LLM/UI로 전달). 스폰 결정(SpawnGateDecision):outcome("allowed" | "rejected"),reason(차단 메시지),planRequired(영수증이 요구됐는지),missingFields(거부 시 누락 필드명). 스폰 핵심 상수/함수:DEFAULT_SPAWN_THRESHOLD=4(하드 고정,childCount > 4면 영수증 필수),findMissingPlanFields(plan)→string[],decide(childCount, threshold, plan)(핵심 결정),evaluateSpawnGate({childCount, plan})(threshold=4로decide호출),evaluateReviewerExploreGate(...)(reviewer→explore면 개수 무관 영수증 강제). 공개 도구 권한(agent-session.ts):PERMISSION_REQUIRED_TOOLS = {bash, monitor, edit, delete, move}, 셸 실행류는bash | monitor(명령어를$ ...로 표시), 선택지allow_once / allow_always / reject_once / reject_always, 캐시#acpPermissionDecisions(cacheKey=toolName,*_always는 영속 단축).
실제 예시
예시 1 — 허용목록 검증 핵심 (gjc 명령 추가 규칙)
// packages/coding-agent/src/tools/bash-allowed-prefixes.ts
const STATE_ACTIONS = new Set(["read", "write", "clear", "contract", "handoff"]);
const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixesCheck {
if (words[0] !== "gjc") return { allowed: true };
if (words[1] === "ralplan") {
if (!words.includes("--write")) {
return { allowed: false, reason: "restricted role-agent bash only allows `gjc ralplan --write ...`" };
}
return { allowed: true };
}
if (words[1] === "state") {
const action = parseStateAction(words);
if (!action) {
return { allowed: false, reason: "restricted role-agent bash only allows documented `gjc state` action shapes" };
}
if (!ALLOWED_STATE_ACTIONS.has(action)) {
return { allowed: false, reason: `restricted role-agent bash does not allow \`gjc state ${action}\`` };
}
return { allowed: true };
}
return { allowed: true };
}포인트: gjc state clear와 gjc state handoff는 STATE_ACTIONS엔 있지만 ALLOWED_STATE_ACTIONS엔 없어 명시적으로 거부된다. “아는 명령”과 “허용하는 명령”을 분리한 것이다.
예시 2 — 스폰 게이트 결정 (5개 초과 + 영수증 검사)
// packages/coding-agent/src/task/spawn-gate.ts
export const DEFAULT_SPAWN_THRESHOLD = 4;
export function decide(childCount: number, threshold: number, plan: SpawnPlanReceipt | undefined): SpawnGateDecision {
const planRequired = childCount > threshold;
if (!planRequired) {
return { outcome: "allowed", reason: `batch of ${childCount} is at or below threshold ${threshold}`,
planRequired: false, missingFields: [] };
}
const missingFields = findMissingPlanFields(plan);
if (missingFields.length > 0) {
return { outcome: "rejected",
reason: `batch of ${childCount} exceeds threshold ${threshold} and the spawn-plan receipt is ${
plan === undefined ? "missing" : `incomplete (${missingFields.join(", ")})`}`,
planRequired: true, missingFields };
}
return { outcome: "allowed", reason: `... a complete spawn-plan receipt was provided`,
planRequired: true, missingFields: [] };
}예시 3 — bash.ts에서 허용목록·env 우회 차단 + 강제 주입
// packages/coding-agent/src/tools/bash.ts (#prepareBashExecution)
const allowedPrefixes = this.session.bashAllowedPrefixes;
if (allowedPrefixes && allowedPrefixes.length > 0) {
if (env && Object.keys(env).length > 0) {
throw new ToolError("Restricted role-agent bash does not allow per-command env overrides.");
}
const commandsToCheck = rawCommand === command ? [command] : [rawCommand, command];
for (const commandToCheck of commandsToCheck) {
const allowlist = checkBashAllowedPrefixes(commandToCheck, allowedPrefixes);
if (!allowlist.allowed) throw new ToolError(allowlist.reason ?? "Command blocked ...");
}
}
// ... 나중에 resolvedEnv 구성 시:
...(allowedPrefixes && allowedPrefixes.length > 0 ? { [GJC_RESTRICTED_ROLE_AGENT_BASH_ENV]: "1" } : {}),포인트: 원본(rawCommand)과 cd ... &&를 제거한 뒤(command) 둘 다 검사한다(래퍼로 숨기는 우회 차단). 그리고 제한 세션이면 런타임이 직접 GJC_RESTRICTED_ROLE_AGENT_BASH=1을 주입한다 — 에이전트가 env로 끌 수 없다.
예시 4 — 계획 모드 쓰기 차단
// packages/coding-agent/src/tools/plan-mode-guard.ts
export function enforcePlanModeWrite(session, targetPath, options?) {
const state = session.getPlanModeState?.();
if (!state?.enabled) return;
const resolvedTarget = resolvePlanPath(session, targetPath);
const resolvedPlan = resolvePlanPath(session, state.planFilePath);
if (options?.move) throw new ToolError("Plan mode: renaming files is not allowed.");
if (options?.op === "delete") throw new ToolError("Plan mode: deleting files is not allowed.");
if (resolvedTarget !== resolvedPlan) {
throw new ToolError(`Plan mode: only the plan file may be modified (${state.planFilePath}).`);
}
}게이트가 생명주기에 끼어드는 지점
- bash 허용목록: 에이전트 frontmatter의
bashAllowedPrefixes를discovery/helpers.ts(L278)가 파싱 →ToolSession.bashAllowedPrefixes로 전달(executor.ts:1276에서 자식 세션에 전달). 프롬프트 주입:prompts/tools/bash.md(L14-22)가restrictedAllowedPrefixes가 있으면<restricted-role-agent-mode>블록을 도구 설명에 렌더링. 집행:BashTool.#prepareBashExecution이 실행 전 차단, 통과 시GJC_RESTRICTED_ROLE_AGENT_BASH=1주입 →ralplan-runtime.ts의isRestrictedRoleAgentBash()(L141)가 이를 읽어 동작 분기(예:--artifact를 파일이 아닌 인라인 텍스트로 취급). - 계획 모드 쓰기 잠금:
write/edit/vim등 쓰기 도구가 실행 직전enforcePlanModeWrite호출. - 스폰 게이트: 모델이
task도구에tasks[]와 선택적spawnPlan을 넘기면task/index.ts가evaluateSpawnGate로 5개 이상을 검사하고, 이어evaluateReviewerExploreGate로reviewer→explore특수 케이스를 검사. - 공개 도구 권한 게이트:
#wrapToolForAcpPermission이PERMISSION_REQUIRED_TOOLS도구의execute를 Proxy로 감싸 사용자 응답 후에만 실제 실행으로 위임.
직접 만들 때 최소 템플릿
// my-guardrails.ts — 최소 다층 게이트
const SPAWN_THRESHOLD = 4;
interface SpawnReceipt { whyParallel: string; whyNotLocal: string; independence: string; expectedReceiptShape: string; maxInlineTokens: number; }
function missingFields(r?: SpawnReceipt): string[] {
if (!r) return ["whyParallel","whyNotLocal","independence","expectedReceiptShape","maxInlineTokens"];
const m: string[] = [];
for (const k of ["whyParallel","whyNotLocal","independence","expectedReceiptShape"] as const)
if (typeof r[k] !== "string" || !r[k].trim()) m.push(k);
if (typeof r.maxInlineTokens !== "number" || !(r.maxInlineTokens > 0)) m.push("maxInlineTokens");
return m;
}
function spawnGate(childCount: number, r?: SpawnReceipt) {
if (childCount <= SPAWN_THRESHOLD) return { ok: true };
const miss = missingFields(r);
return miss.length ? { ok: false, miss } : { ok: true };
}
const ALLOW_PREFIXES = ["mycli state", "mycli plan --write"];
const BLOCKED_ACTIONS = new Set(["clear", "handoff"]);
function bashGate(cmd: string, env?: Record<string,string>) {
if (env && Object.keys(env).length) throw new Error("no per-command env override");
if (/[;|&<>()`$]/.test(cmd)) throw new Error("shell control/expansion blocked");
if (!ALLOW_PREFIXES.some(p => cmd.startsWith(p))) throw new Error(`only: ${ALLOW_PREFIXES.join(", ")}`);
const action = cmd.split(/\s+/)[2];
if (action && BLOCKED_ACTIONS.has(action)) throw new Error(`action '${action}' blocked`);
}설계 체크리스트:
- 임계값(
DEFAULT_SPAWN_THRESHOLD=4)을 하드코딩·고정했는가? (에이전트가 못 바꾸게) - 영수증 5필드(문자 4 +
maxInlineTokens>0숫자) 전부 검증하는가? - bash: 원본 +
cd ... &&제거본 둘 다 검사하는가? - bash: per-command env override를 거부하는가?
- 셸 제어/확장 문자, 백틱/
$(...), 백슬래시, 개행을 직접 파싱해 차단하는가? - 허용 액션(
read/write/contract)과 차단 액션(clear/handoff)을 분리했는가? - 제한 세션 표식 env를 런타임이 강제 주입하는가(에이전트가 끌 수 없게)?
- 계획 모드: 계획 파일 외 create/update/delete/move를 throw로 막는가?
- 게이트 거부 시 사람이 아니라 모델에게 누락 필드/사유 텍스트를 돌려주는가?
- 파괴적 공개 도구(bash/monitor/edit/delete/move)에 사용자 권한 팝업을 거는가?
요약 & 셀프체크
요약 3줄:
- gajae-code의 가드레일은 권한 팝업 → bash 허용목록 → 계획 모드 쓰기잠금 → 스폰 게이트의 4단계로, 각 문이 생명주기의 다른 지점에서 다른 위험을 막는다.
- 철학은 “기본 차단, 정당화로 통과”이며, 우회를 막기 위해 원본·정리본을 둘 다 검사하고 제한 표식 env를 런타임이 강제 주입한다.
- 거부 메시지는 사람이 아닌 모델에게 돌려줘서, 모델이 영수증을 채우거나 명령을 고쳐 스스로 재시도하게 만든다.
스스로 답해보기:
gjc state clear가STATE_ACTIONS에 들어 있는데도 거부되는 이유는? (“아는 명령”과 “허용 명령” 집합이 다르기 때문)- bash 명령을 검사할 때
rawCommand와command두 개를 모두 보는 이유는? (cd ... &&같은 래퍼로 숨기는 우회를 막기 위해) - 자식 5개를 띄우려는데 게이트가 막았다. 다음에 무엇을 해야 통과하나? (영수증 5필드를 모두 채워 재시도)