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 cleargjc state handoffSTATE_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}).`);
	}
}

게이트가 생명주기에 끼어드는 지점

  1. bash 허용목록: 에이전트 frontmatter의 bashAllowedPrefixesdiscovery/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.tsisRestrictedRoleAgentBash()(L141)가 이를 읽어 동작 분기(예: --artifact를 파일이 아닌 인라인 텍스트로 취급).
  2. 계획 모드 쓰기 잠금: write/edit/vim 등 쓰기 도구가 실행 직전 enforcePlanModeWrite 호출.
  3. 스폰 게이트: 모델이 task 도구에 tasks[]와 선택적 spawnPlan을 넘기면 task/index.tsevaluateSpawnGate로 5개 이상을 검사하고, 이어 evaluateReviewerExploreGatereviewer→explore 특수 케이스를 검사.
  4. 공개 도구 권한 게이트: #wrapToolForAcpPermissionPERMISSION_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줄:

  1. gajae-code의 가드레일은 권한 팝업 → bash 허용목록 → 계획 모드 쓰기잠금 → 스폰 게이트의 4단계로, 각 문이 생명주기의 다른 지점에서 다른 위험을 막는다.
  2. 철학은 “기본 차단, 정당화로 통과”이며, 우회를 막기 위해 원본·정리본을 둘 다 검사하고 제한 표식 env를 런타임이 강제 주입한다.
  3. 거부 메시지는 사람이 아닌 모델에게 돌려줘서, 모델이 영수증을 채우거나 명령을 고쳐 스스로 재시도하게 만든다.

스스로 답해보기:

  • gjc state clearSTATE_ACTIONS에 들어 있는데도 거부되는 이유는? (“아는 명령”과 “허용 명령” 집합이 다르기 때문)
  • bash 명령을 검사할 때 rawCommandcommand 두 개를 모두 보는 이유는? (cd ... && 같은 래퍼로 숨기는 우회를 막기 위해)
  • 자식 5개를 띄우려는데 게이트가 막았다. 다음에 무엇을 해야 통과하나? (영수증 5필드를 모두 채워 재시도)

연결

GJ_개요 · _분석축_루브릭