LLM 출력은 shell 문자열 아닌 argv로: execFile 배열과 allowlist
에이전트가 만든 값을 exec 문자열에 붙이면 명령 인젠션이 열려요. execFile 배열, verb allowlist, 인자 정규식으로 막는 백엔드 래퍼를 정리했어요.
에이전트가 뽑아낸 브랜치명을 그대로 exec("git checkout " + name)에 넣는 코드를 저도 한 번 짰어요. 그날은 잘 돌았고, 며칠 뒤 로그에서 ; rm -rf가 섞인 값을 보고서야 문제를 알았어요. 값은 LLM이 만들었지만 실행은 제 서버 권한으로 된다는 게 문제의 본질이에요.
문자열이 아니라 배열이어야 하는 이유
exec는 문자열을 셸에 넘겨요. 그래서 ;, |, $( )가 전부 문법으로 해석돼요. 반면 execFile과 spawn은 인자를 배열로 받고 shell 옵션 기본값이 false라서 셸을 거치지 않아요. 값에 ;가 들어 있어도 그냥 글자 하나일 뿐이에요.
직접 바꿔 보니 diff는 15줄 안이었어요.
import { execFile } from "node:child_process";
const ALLOWED = new Set(["status", "log", "diff"]); // verb allowlist
export function runGit(verb, args) {
if (!ALLOWED.has(verb)) throw new Error(`blocked verb: ${verb}`);
for (const a of args) {
if (!/^[\w./-]{1,128}$/.test(a)) throw new Error(`bad arg: ${a}`);
}
return new Promise((ok, no) =>
execFile("git", [verb, ...args], { timeout: 10_000 }, (e, out) => (e ? no(e) : ok(out)))
);
}세 겹이에요. verb는 allowlist 3개로만, 인자는 정규식으로 128자 이내, 실행은 배열이라 셸 해석 자체가 없어요. quote 이스케이프는 마지막 보조수단이고, 첫 방어는 문법을 만들 기회를 주지 않는 거예요.
테스트해 보면 드러나는 것
테스트는 1개만 남겼어요. runGit("status", ["; whoami"])가 throw 하는지 보는 거예요. 적용해 봤더니 정규식 단계에서 먼저 막혔고, 정규식을 지우고 돌려 보니 execFile이 ; whoami를 경로 인자로 넘겨서 git이 경로를 못 찾았다는 에러만 냈어요. 둘 중 하나가 빠져도 셸은 안 열리지만, 둘 다 있어야 로그에 왜 막혔는지가 남아요.
옵션도 두 개 챙겼어요. timeout을 10초로 두면 멈춘 프로세스가 SIGTERM으로 정리되고, maxBuffer 기본값은 1MiB라서 출력이 넘치면 자식 프로세스가 종료돼요. 무한 대기와 메모리 폭주를 두 줄로 막는 셈이에요.
백엔드에서 이 코드의 자리
이건 입력 검증의 한 종류예요. API 파라미터를 zod로 검사하듯, 에이전트 출력도 신뢰 경계 밖에서 온 값으로 다뤄요. 인가 판단과 같은 원칙이기도 해요. LLM이 "이 유저는 관리자"라고 말해도 서버가 세션에서 다시 확인하듯, LLM이 준 명령도 서버가 정한 verb 목록 안에서만 실행돼요.
라이프케어로그에서 플랜비 운영 배치를 자동화할 때 이 래퍼를 먼저 깔았어요. 에이전트가 무엇을 만들어 내든, 서버가 열어 둔 문은 3개뿐이니까요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.