근묵자흑
Kubescape MCP × LLM Agentic RCA 본문
위협(flagged CVE·worst-image·런타임 질문)이 들어오면, LLM이 Kubescape MCP의 5개
read-only 도구를 ReAct 루프로 반복 호출하며 스스로 조사한다. 조기 포기를 막는
Loop Guard가 절차만 개입한다. 이 글은 MCP 호출 오케스트레이션과 에이전트 루프
아키텍처를 실제 하니스 코드 기준으로 정리한다.
01. 무슨 위협으로 조사가 시작되나 — 8 시나리오
"보안 피드가 CVE-2021-38297을 플래그했다", "어떤 이미지가 제일 위험한가" 같은 위협/질문이 트리거다. 8개 시나리오(S1–S8)는 난이도·함정·경계를 달리해 에이전트가 스스로 조사하도록 설계됐다. 각 시나리오는 Loop Guard 모드가 다르다.
| 지표 | 값 |
|---|---|
| 위협 시나리오 | 8 |
| aggregate (전수 집계) | 2 |
| targeted (추적) | 2 |
| off (함정·경계) | 4 |
| ID | 위협 / 질문 | 난이도 | 정답 형태 | Guard |
|---|---|---|---|---|
| S1 worst_image | 모든 이미지 중 CRITICAL 취약점이 가장 많은 이미지는? | multi-hop | image + count | aggregate |
| S2 top_fixable | CVSS 최고이면서 fix가 있는 단일 취약점은? | multi-hop | cve+image+fix | aggregate |
| S3 cve_triage | 피드가 플래그한 CVE가 이 클러스터에 있나? 영향 이미지·fix는? | targeted | cve+image+fix | targeted |
| S4 함정 | CVE-2099-99999(존재하지 않는 가짜)에 영향받나? |
trap | NONE이어야 정답 | off |
| S5 depth_chain | CVE의 전체 체인: 이미지→취약 패키지→fix→조치 | depth | 4단계 체인 | targeted |
| S6 경계·runtime | 그 CVE를 실제 실행한 프로세스(PID)·부모는? | boundary | OUT_OF_SCOPE | off |
| S7 경계·exploit | 이 CVE가 지금 실제 공격당하고 있나? (IP·타임스탬프) | boundary | OUT_OF_SCOPE | off |
| S8 경계·blast | 지금 침해된 파드와 네트워크 blast radius는? | boundary | OUT_OF_SCOPE | off |
02. 전체 구성도 — 누가 무엇을 호출하나
하니스가 ① 정답(oracle) 스냅샷을 만들고 ② 에이전트 루프를 돌린다. 에이전트는 Ollama LLM과 MCP 브리지 양쪽과 대화하고, 브리지는 kubescape mcpserver 서브프로세스를 통해 Kubescape 스토리지의 스캔 결과(CRD)를 읽는다.
flowchart TB
threat([위협/질문<br/>flagged CVE · worst image · runtime Q]):::ext
subgraph HARNESS["harness_v3.py (러너)"]
direction TB
oracle["oracle.py<br/>결정론적 정답 스냅샷<br/>build_snapshot()"]:::ora
scorer["score()<br/>정답 대조 · grounding"]:::ora
loop["agent_v3.run_agent_v3()<br/>ReAct 루프 + Loop Guard"]:::agent
end
llm["Ollama LLM<br/>qwen2.5:3b/7b · qwen3:8b · qwen3.5:9b<br/>temp=0 · num_ctx=24576"]:::llm
bridge["mcp_bridge.McpServer<br/>JSON-RPC over stdio (직접 구현)"]:::bridge
mcp["kubescape mcpserver<br/>(서브프로세스, stdio)<br/>serverInfo v0.0.1"]:::mcp
store[("Kubescape storage<br/>VulnerabilityManifest /<br/>WorkloadConfigurationScan CRD")]:::store
scan["kubevuln 스캐너<br/>이미지 취약점 스캔"]:::scan
threat --> loop
oracle -. 정답 .-> scorer
loop <-->|"messages / tool_calls"| llm
loop <-->|"call_tool(name,args)"| bridge
bridge <-->|"tools/call (JSON-RPC)"| mcp
mcp -->|"CRD read"| store
scan --> store
oracle -->|"build_snapshot 도 MCP로 읽음"| bridge
loop --> scorer
- oracle = 채점용 진실: 같은 MCP 데이터를 결정론적으로 집계해 정답을 미리 계산 → LLM 답을 대조. LLM에는 절대 안 들어감.
- 브리지는 의존성 0:
mcppip 패키지 없이 newline-delimited JSON-RPC를 손으로 구현 → 시스템 Python 3.9에서 동작. 한 세션 내내 같은 프로세스 재사용. - tools 스키마 변환:
mcp_tools_to_openai()가 MCP tool 정의를 Ollama function-tool 스키마로 변환해 LLM에 전달.
03. 기초부터 — 프로세스·stdio·JSON-RPC가 뭔가
"브리지가 kubescape mcpserver를 stdio로 띄우고 JSON-RPC로 통신한다"는 문장을 처음부터 풀어본다. 네트워크도 포트도 없이, 두 프로그램이 파이프로 연결돼 JSON 한 줄씩 주고받는 게 전부다.
1. 표준 입출력 (stdio) — 모든 프로그램엔 입·출력 통로가 있다
실행되는 모든 프로그램은 기본 통로 3개를 갖는다: stdin(들어오는 입력) · stdout(나가는 출력) · stderr(에러). 평소엔 키보드(stdin)와 터미널 화면(stdout)에 연결돼 있다.
2. 프로세스를 "띄운다" — 자식 프로그램을 켜고 통로를 잡는다
브리지(파이썬)가 subprocess.Popen(["kubescape","mcpserver"])로 kubescape를 자식 프로세스로 실행한다. 이때 자식의 stdin/stdout을 파이프(pipe)로 가로채 부모가 직접 쓰고 읽는다.
3. JSON-RPC — "이 함수를 이 인자로 불러줘"를 JSON으로
원격 함수 호출(Remote Procedure Call)을 JSON으로 표현하는 약속. 요청엔 method(함수)·params(인자)·id(번호표), 응답엔 같은 id와 result가 담긴다.
4. 줄단위 프레이밍 — 한 줄 = 한 메시지
파이프엔 바이트가 끊김 없이 흐르므로 "메시지 경계"를 정해야 한다. 여기선 newline-delimited: JSON 한 개를 ...}\n처럼 줄바꿈으로 끝내 한 메시지로 친다.
받는 쪽은 readline()으로 한 줄을 읽으면 곧 한 메시지. 그래서 JSON 안엔 줄바꿈을 넣지 않는다.
파이프 한 장으로
부모 — 브리지 자식 — MCP 서버
mcp_bridge.py (python) kubescape mcpserver
McpServer 객체 (서브프로세스)
──── 부모가 씀 → 자식 stdin ───────────► (요청 JSON + "\n")
◄─── 자식 stdout → 부모가 읽음 ───────── (응답 JSON + "\n")
네트워크 소켓이 아니라 같은 머신 안 두 프로세스 사이의 파이프다. stderr는 따로 로그로 흘린다.
통신 한 사이클 — 부모가 stdin에 쓰고, stdout에서 같은 id를 읽는다
// 1) 부모 → 자식 stdin : JSON 한 줄 (\n 으로 끝)
stdin> {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_vulnerability_manifests","arguments":{"level":"both"}}}\n
// 2) 자식이 처리 (Kubescape storage CRD 조회) 후 → 자식 stdout : 응답 한 줄
stdout< {"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"{…JSON 문자열…}"}]}}
// 3) 부모: readline() 으로 받은 줄을 파싱 → id==5 매칭 확인 → result.content[].text 를 "다시" json.loads
parse> ok=True, payload={"vulnerability_manifests":{"manifests":[ … busybox, nginx, … ]}}
왜 result.text를 "두 번" 파싱하나
MCP 응답의 result.content[].text는 사람이 읽는 텍스트 칸이다. Kubescape는 그 칸에 JSON을 문자열로 넣어 보낸다(이중 인코딩). 그래서 ① 바깥 JSON-RPC를 파싱하고 ② 그 안의 text 문자열을 다시 json.loads해야 진짜 데이터가 나온다.
왜 HTTP·포트가 아니라 stdio인가 (transport)
MCP는 stdio와 HTTP/SSE 두 전송을 지원한다. 로컬 도구엔 stdio가 편하다: 포트·방화벽·인증 설정이 없고, 클라이언트가 죽으면 서버 프로세스도 같이 정리되며, 외부에 노출되지 않아(같은 머신 파이프) 안전하다. 대신 원격/다중 클라이언트엔 HTTP를 쓴다.
04. MCP 호출 오케스트레이션 — JSON-RPC over stdio
브리지는 kubescape mcpserver를 stdio로 띄우고 initialize → notifications/initialized → tools/list로 핸드셰이크한 뒤, 매 tool 호출마다 tools/call JSON-RPC 요청을 보내고 id로 응답을 매칭한다.
sequenceDiagram
autonumber
participant A as agent_v3 (루프)
participant B as McpServer (브리지)
participant K as kubescape mcpserver
participant S as Kubescape storage (CRD)
Note over B,K: 세션 시작 (1회)
B->>K: subprocess.Popen("kubescape mcpserver", stdio)
B->>K: initialize {protocolVersion:2024-11-05, clientInfo:rca-harness}
K-->>B: serverInfo "Kubescape MCP Server" v0.0.1
B->>K: notifications/initialized
A->>B: list_tools()
B->>K: tools/list
K-->>B: 5 tools (+ inputSchema)
B-->>A: mcp_tools_to_openai(tools)
Note over A,S: 조사 루프 — tool 호출마다 반복
loop 매 tool_call
A->>B: call_tool(name, args)
B->>K: tools/call {name, arguments, id:n}
K->>S: VulnerabilityManifest / ConfigScan 읽기
S-->>K: 스캔 데이터
K-->>B: result.content[].text (JSON 문자열)
B->>B: id 매칭 · JSON 파싱 · isError 확인
B-->>A: (ok, parsed) ※ max_tool_bytes 초과 시 truncate
end
Note over A,B: 세션 종료
A->>B: stop() → stdin close / terminate
실제 핸드셰이크 (mcp_transcript.json)
→ initialize {protocolVersion:"2024-11-05"}
← serverInfo: "Kubescape MCP Server" v0.0.1
→ notifications/initialized
→ tools/list
← 5 tools:
list_vulnerability_manifests
list_vulnerabilities_in_manifest
list_vulnerability_matches_for_cve
list_configuration_security_scan_manifests
get_configuration_security_scan_manifest
5개 도구의 역할 (조사 깊이 순)
① list_vulnerability_manifests
→ 스캔된 이미지 목록 (항상 먼저)
② list_vulnerabilities_in_manifest
→ 한 이미지의 전체 CVE (큼)
③ list_vulnerability_matches_for_cve
→ 한 이미지의 한 CVE 상세 (작음)
artifact = 취약 패키지+버전, fix
④⑤ *_configuration_security_scan_*
→ 설정(config) 스캔 결과
◆ read-only · scan-only
5개 도구는 전부 정적 취약점/설정 스캔만 노출한다. 실행 중 프로세스·PID·라이브 공격·네트워크 blast radius 같은 런타임 정보는 없다
05. 에이전트 루프 — LLM이 스스로 도구를 호출한다
바로 이 부분이 "LLM에게 tool을 쥐여주고 스스로 조사 루프를 돌게 한" 구조다. run_agent_v3()는 ReAct 루프(max_steps=24): LLM이 tool_calls를 발행 → 브리지가 실행 → 결과를 role:tool 메시지로 되먹임 → LLM이 다음 행동 결정. 도구 호출이 없으면 종료 시도. 마지막에 별도 synth 단계가 강제로 JSON 스키마를 뽑는다.
flowchart TB
start([위협 프롬프트 + 5 tools schema]):::start
sys[system prompt<br/>INVESTIGATION PROTOCOL<br/>enumerate · sweep · drill · conclude]:::sys
llm[LLM step ↺<br/>Ollama chat temp=0<br/>루프가 매 회 여기로 되돌아온다]:::llm
dec1{tool_calls 있나?}
exec[execute<br/>mcp.call_tool 실행<br/>결과를 role:tool 로 되먹임]:::tool
book[guard 장부<br/>probed · err_streak · list_calls 갱신]:::book
dec2{Loop Guard 개입?}
dec3{complete?<br/>aggregate: probed≥N · targeted: hit/probed≥N · off: 항상}
synth[synth<br/>format=json 강제<br/>root_cause · evidence · summary]:::synth
done([done · final_json]):::start
start --> sys --> llm --> dec1
dec1 -->|no 종료시도| dec3
dec1 -->|yes| exec --> book --> dec2
dec2 -->|err3 / repeat2+ / relist → nudge| llm
dec2 -->|개입 없음| llm
dec2 -->|err5+ → force_synth| synth
dec3 -->|미완 & forced<3 → force_continue| llm
dec3 -->|complete / cap / off| synth --> done
▣ 왜 Loop Guard인가 (round-2 관찰)
가드 없는 단일 에이전트는 조사형 시나리오를 항상 같은 방식으로 실패했다 — 도구를 2~3번 부르고 "NONE"이라 조기 포기(환각이 아니라 포기, grounding은 1.0 유지). 아무것도 모델을 "모든 이미지를 다 훑어라"로 밀어붙이지 않았다. Aurora식 Loop Guard는 정확히 이 모양을 위한 것: 조기 종료·반복 호출·연속 에러를 감지해 절차적 nudge를 주입하고, 그래도 멈추면 강제 종료한다.
06. Loop Guard — 3-warn / 5-force 사다리
가드는 시나리오별 guard_mode(aggregate/targeted/off)에 따라 "완료" 기준을 다르게 본다. 미완인데 종료하려 하면 절차 nudge를 주입(최대 3회), 연속 에러는 3회 경고·5회 강제 종료한다. 핵심 불변식: nudge에는 절차(k/N 카운트, "남은 매니페스트를 훑어라")만 들어가고 정답(CVE·이미지·패키지)은 절대 안 들어간다.
flowchart LR
subgraph MODE["guard_mode (시나리오별)"]
direction TB
agg["aggregate (S1·S2)<br/>complete = probed_agg ≥ N<br/>(모든 이미지 전체 CVE 집계)"]:::agg
tgt["targeted (S3·S5)<br/>complete = hit_found<br/>또는 probed_tgt ≥ N"]:::tgt
off["off (S4·S6·S7·S8)<br/>iteration 강제 없음<br/>조기 종료가 정답"]:::off
end
subgraph LADDER["에스컬레이션 (aggregate/targeted만)"]
direction TB
w1["반복 호출 repeat 2+<br/>→ repeat_nudge"]:::n
w2["연속 에러 3회<br/>→ err_nudge"]:::n
w3["list 재호출 3회+ · probed 0<br/>→ relist_nudge"]:::n
f1["미완 종료 시도<br/>→ force_continue (×3)"]:::f
f2["연속 에러 5회+<br/>→ force_synth (중단)"]:::fx
end
agg --> LADDER
tgt --> LADDER
| 상수 | 값 | 의미 |
|---|---|---|
| MAX_FORCE | 3 | 조기종료 차단 횟수 상한 |
| ERR_NUDGE_AT | 3 | 연속 에러 경고 시점 |
| ERR_FORCE_AT | 5 | 연속 에러 강제 종료 시점 |
| MAX_LIST | 2 | 재나열 한계 |
▲ 측정을 정직하게 유지하는 불변식
가드가 정답 힌트를 흘리면 "조사 능력 측정"이 아니라 "정답 누설"이 된다. 그래서 nudge 문구는 철저히 절차적이다:
"7개 중 3개만 훑었다, 나머지를 list_vulnerability_matches_for_cve로 호출하라"— CVE id도, 이미지명도, 패키지도 넣지 않는다. 덕분에 v2(가드 없음) vs v3(가드)의 A/B 비교가 공정하게 성립한다.
07. S3 추적 트레이스
qwen2.5:7b가 S3(피드가 플래그한 CVE 추적)를 도는 실제 트레이스(results_v3_4model.json). list로 이미지 목록을 받고 → 여러 이미지를 순차 sweep → 조기 종료를 시도하자 가드가 force_continue_targeted로 1회 더 밀어붙였다.
# S3_cve_triage · qwen2.5:7b · guard=targeted · 8 tool calls · 1 guard event
# STEP 1 ENUMERATE
1 list_vulnerability_manifests ok → 이미지 목록 수신
# STEP 2 SWEEP — 이미지들을 하나씩 (이름은 실제 kubescape 스캔 대상)
2 list_vulnerabilities_in_manifest ok manifest #1
3 list_vulnerabilities_in_manifest ok manifest #2
4 list_vulnerabilities_in_manifest ok manifest #3
5 list_vulnerabilities_in_manifest ok manifest #4
┌─ [ORCHESTRATOR] force_continue_targeted @step5 (forced=1)
└─ "4개만 probe하고 결론냈다. 남은 매니페스트를 전부 matches_for_cve로 확인하라" (정답 힌트 없음)
# STEP 3 DRILL — 특정 CVE 매칭 시도
6 list_vulnerability_matches_for_cve ok manifest #1 / CVE
7 list_vulnerability_matches_for_cve err (빈 결과)
8 list_vulnerability_matches_for_cve err
# STEP 4 — synth 단계가 JSON 스키마 강제
final_json = { root_cause:{id:"NONE", image:"…", fix_version:"NONE"},
evidence_ids:[], summary:"CVE … not found in scanned images" }
이 한 판에서 드러나는 구조적 사실:
- 가드는 절차만 밀었다: step5의 nudge는 "몇 개 남았으니 계속 호출하라"였고, 어떤 CVE/이미지도 알려주지 않았다.
- 완료 게이트가 종료를 통제: targeted 모드라
hit_found도probed_tgt≥N도 아니어서 조기 종료가 1회 반려됐다(forced=1). - S3는 고변동: 같은 7b가 다른 데이터셋에선 전체 체인을 정답으로 뽑기도 한다 — 가드는 flip을 가능하게 할 뿐 보장하지 않는다.
- synth 분리: 조사 단계가 아무리 장황해도 마지막
format=json단계가 항상 동일 스키마를 강제 → 채점 가능.
◇ 결론 — "스스로 조사하는 루프"의 실체
LLM은 system prompt의 INVESTIGATION PROTOCOL을 받아 도구를 직접 골라 반복 호출하며 조사한다. Loop Guard는 그 루프 위에 얇게 올라간 오케스트레이션 레이어로, 능력이 좋은 모델(7b·8b·9b)에서는 거의 개입하지 않고(n_forced=0), 약한 모델(3b)에서는 강하게 밀어도 완주시키지 못한다. 즉 루프 구조는 능력을 끌어내되 만들어내진 못한다.
'AIOps' 카테고리의 다른 글
| Kubescape 런타임 보안(eBPF/Inspektor Gadget)과 MCP AI 연동 동작 방식 (0) | 2026.05.30 |
|---|---|
| Coroot 코드로 읽는 LLM RCA 컨텍스트 — 한 인시던트가 Cloud 전송까지 가는 경로 (0) | 2026.05.24 |
| AIOps 스터디 6주차 — coroot의 데이터 흐름과 RCA 책임 분담 (0) | 2026.05.16 |
| AIOps 스터디 5주차 - RCA 평가지표 정리 (1) | 2026.05.10 |
| AIOps 스터디 5주차 - RCA (0) | 2026.05.09 |
