관리 메뉴

근묵자흑

Kubescape MCP × LLM Agentic RCA 본문

AIOps

Kubescape MCP × LLM Agentic RCA

Luuuuu 2026. 6. 6. 19:46

위협(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 LLMMCP 브리지 양쪽과 대화하고, 브리지는 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: mcp pip 패키지 없이 newline-delimited JSON-RPC를 손으로 구현 → 시스템 Python 3.9에서 동작. 한 세션 내내 같은 프로세스 재사용.
  • tools 스키마 변환: mcp_tools_to_openai()가 MCP tool 정의를 Ollama function-tool 스키마로 변환해 LLM에 전달.

03. 기초부터 — 프로세스·stdio·JSON-RPC가 뭔가

"브리지가 kubescape mcpserverstdio로 띄우고 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(번호표), 응답엔 같은 idresult가 담긴다.

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_foundprobed_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)에서는 강하게 밀어도 완주시키지 못한다. 즉 루프 구조는 능력을 끌어내되 만들어내진 못한다.