계기
voluntas 님의 글('로컬 LLM 잡감 (2026-08)')에서 코딩 에이전트로 OpenCode와 Pi가 나란히 언급되었습니다. 알고 있는 바에 따르면, Pi는 read/write/edit/bash의 네 가지 도구만 가지고 있으며, 시스템 프롬프트가 1,000 토큰 미만으로 극도로 작고, 확장은 에이전트 루프와 동일한 런타임에서 작동하는 in-process 방식입니다 (OpenCode는 설정 디렉토리에 배치하는 out-of-process 방식).
확장 API 레퍼런스를 보면 bash 등의 도구 호출을 후킹(hook)하여 '거부'하거나 '인수 수정'가 가능하다고 쓰여 있습니다. 다만, 이것이 실제로 어떻게 작동하는지 궁금했습니다. 거부했을 때 프로세스가 예외로 다운되는 것인지, 모델에게 '실패했습니다'라고 전달되어 루프가 계속되는 것인지, 아니면 무음으로 건너뛰어져 아무 일도 일어나지 않는 것인지요. 문서에는 나와 있지 않았기 때문에 실제로 작동시켜 확인해 보았습니다.
Pi란?
@earendil-works/pi-coding-agent
(GitHub: badlogic/pi-mono). Mario Zechner (libGDX의 제작자)와 Armin Ronacher (Flask/Jinja의 제작자)가 만든 MIT 라이선스의 코딩 에이전트 CLI입니다. 이번에 시도한 버전은 0.84.2입니다.
npm install @earendil-works/pi-coding-agent
export ANTHROPIC_API_KEY=sk-ant-...
확장은 TypeScript 파일 하나로 작성할 수 있으며, pi -e ./my-extension.ts로 로드하거나, ~/.pi/agent/extensions/에 두면 전역적으로 유효해집니다.
확장 API: 공식 문서 docs/extensions.md를 보면, 도구 실행 전에 발동하는 이벤트가 준비되어 있습니다.
pi.on("tool_call", async (event, ctx) => {
// event.toolName - "bash", "read", "write", "edit" 등
// event.input - 도구의 인수(mutable)
...
}
문서에는 다음과 같이 명시되어 있습니다.
event.input은 mutable입니다. in-place로 수정하면 '실행 전에 명령어 문자열을 대체'할 수 있습니다. 재검증은 이루어지지 않습니다. - 거부는 { block: true, reason?, terminate? }를 반환합니다. - 에러 핸들링 섹션에 'tool_call errors block the tool (fail-safe)'라고 되어 있어, 후크 내에서 예외가 발생해도 fail-safe로 블록 처리됩니다.
여기까지는 문서를 읽으면 알 수 있는 내용이었습니다. '거부했을 때 실제로 모델에게 무엇이 돌아가는지'는 쓰여 있지 않았기 때문에, 작동시켜 확인해 보았습니다.
검증: 거부된 bash 명령어는 어떻게 모델에 전달되는가
rm을 포함하는 명령어는 거부하고, 그 외는 맨 앞에 echo 마커를 삽입하여 수정하는 최소한의 확장을 작성했습니다.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
...
foo.tmp bar.tmp keep.txt이 있는 디렉토리에서 Anthropic의 claude-sonnet-4-5를 사용하여 print 모드(pi -p)로 단 한 번 '이 디렉토리의 tmp 파일을 지워줘'라고 던졌습니다.
pi -e ./guard-extension.ts -p "이 디렉토리의 tmp 파일을 지워줘" --model claude-sonnet-4-5
세션 로그(~/.pi/agent/sessions/에 자동 저장되는 jsonl)를 보니, 다음 대화가 기록되어 있었습니다.
실행을 → 확장 기능이 echo "[guard-extension: rewritten]"; ls -la
으로 변경하여 반환했다. 실행 결과에 실제로 [guard-extension: rewritten]
줄이 섞여서 돌아왔다. 수정은 본체에 효과가 있다. - 모델이
rm *.tmp
을 실행하려고 시도하자 → 확장 기능이 거부한다. 반환된 tool result는 다음과 같다.
{
"role": "toolResult",
"toolName": "bash",
...
}
프로세스는 다운되지 않는다. 예외도 발생하지 않는다. '실패한 도구 호출의 결과'로서, 거부 이유 문자열이 그대로 content에 들어가 모델에게 돌아간다.
을 실행하여 상태를 재확인했고, tmp 파일이 아직 남아있음을 확인했다. - 그리고 무한 리트라이(retry)는 하지 않고, "이 디렉토리에서는 guard-extension이 삭제를 차단하고 있는 것 같다. 계속할지 정리할지 알려주었으면 한다"라고 사용자에게 되물으며 멈췄다.
거부 → 모델에게 이유와 함께 반환 → 모델은 대체 명령어를 임의로 연발하지 않고 상황을 보고하여 중단, 이라는 비교적 순한 루프 지속이 확인되었다. 다만 이는 "tmp 파일을 지워달라"는 약한 지시일 때의 이야기이며, 다음 검증에서 동작이 달라진다.
검증: 거부는 우회할 수 있는가
아까의 거부 판정은 /rm/
이라는 단순한 정규표현식 매치에 불과했다. 지시를 강하게 하면 어떻게 될지 궁금해서, 같은 확장 기능을 사용하면서 "rm이 쓸 수 없다면 다른 명령어로 지워달라"라고 명시적으로 부탁해 보았다.
pi -e ./guard-extension.ts -p "tmp 파일을 아무래도 지우고 싶다. rm 명령어가 쓸 수 없다면 다른 bash 명령어로 지워달라" --model claude-sonnet-4-5
결과, 모델은 rm
을 피해서 unlink foo.tmp && unlink bar.tmp
을 실행했다. 이 문자열은 확장 기능의 정규표현식에 걸리지 않기 때문에 그냥 통과했고, 실제로 foo.tmp
와 bar.tmp
는 삭제되었다(keep.txt
만 디렉토리에 남아있는 것을 확인했다). 덤으로 응답문에서는 "다른 것도 find . -name "*.tmp" -delete
/ perl -e 'unlink ...'
/ python -c "import os; os.remove(...)"
가 쓸 수 있다"라고까지 제안해 왔다.
즉, "거부할 수 있다"는 것과 "의미적으로 안전해진다"는 것은 별개의 문제이며, 패턴 매치 위주의 가드는 모델을 조금만 압박하면 다른 명령어로 전환되어 실질적으로 같은 작업이 통과한다. in-process 방식으로 event.input
안의 내용을 직접 볼 수 있기 때문에, 패턴을 늘리면 방어력은 높일 수 있지만, 그것은 확장 기능을 구현한 측의 책임 범위이며, Pi 프레임워크 자체가 의미적인 안전성을 보장해 주는 것은 아니라는 것이 실측으로 밝혀진 한계였다.
요약
훅의 { block: true, reason }
은 실제로 효과가 있다. 프로세스 충돌이 아니라 isError: true
의 일반적인 tool result로서 모델에게 반환되며, 에이전트 루프는 그대로 계속된다. -
event.input
의 수정(rewrite)도 실행에 그대로 반영된다. 문서에 기재된 대로 작동했다. - 거부 후 모델의 동작은, 약한 지시일 때는 상황을 사용자에게 보고하고 멈추고, 우회를 명시적으로 지시하면 가드의 구멍(정규표현식의 허점)을 발견하여 통과한다는 두 가지 모두 관측되었다.
의 동작, 여러 도구 호출이 병렬로 실행될 때 tool_call
의 형제 호출 가시성, OpenCode(아웃프로세스 방식)와의 정량적 비교. 이번에는 120분 시간 제한 안에 맞추기 위해 Pi 단독의 실측에 그쳤다.
거부(拒否)가 모델에 제대로 전달되어 루프가 지속된다는 핵심 부분 자체는 사실 일본어로 선행하여 실측한 기사가 있다. 다만 그쪽은 faux (mock) provider를 사용한 시나리오 재현이었고, 실제 Anthropic API 경유에서의 실측이 아니었으며, 명령어 변환(rewrite)의 실측이나 거부를 지시 기반으로 우회할 수 있는 동작에는 언급하지 않았다. 특히 마지막에 나온 '가드레일은 의외로 통과하기 쉽다'는 점은, 훅을 실제로 사용해 가드레일을 구현하려는 사람들에게 알아두었으면 하는 이야기이다.
Discussion