- 준비: Lab 02의 `my-work/secure-agent.json` (판별 LLM까지 끝낸 것). Lab 02 과제 1을 못 끝냈으면 `cp reference/lab03-example1-filter.json my-work/secure-agent.json` 후 push하고 예제 2부터 (판별 LLM과 출력 필터가 들어 있음)
- 목표: 들어오는 질문만이 아니라 **나가는 답도** 검사한다. 무엇을 왜 막았는지 기록으로 남기고, 관문마다 시간과 기록을 보고, 내가 쓴 어려운 보안 테스트로 **지나치게 막은 것과 덜 막은 것**을 찾아 고친다. 마지막으로 기록을 **개인정보 없이** 감사 로그로 모아 하루 요약을 본다.
| 막기 | 답에 내부 코드 모양(`IT-ADMIN-` 숫자 등)이나 지시문 조각이 있으면 `route: 차단`, `reason: output_leak` |
| 가린 수 | `masked` (예제 2에서 씀) |
| 기대 결과 | `cases/lab03.json` 앞 두 케이스, `lab01-attacks` 9/9 |
### 이해하기
**출구도 관문입니다.** 지금까지는 **들어오는** 질문을 검사했습니다. 그런데 답에는 LLM이 Tool에서 가져온 데이터가 들어갑니다. 예제 2 · Lab 02에서 본인 건이 보이게 되면서, 접수 건의 연락처가 답에 그대로 나갈 수 있게 되었습니다. 본인 것이라도 답은 화면 캡처 · 메신저 전달 · 로그 저장으로 **다른 사람 눈에** 닿습니다.
```mermaid
flowchart LR
A{{"IT Agent<br/>Tool 결과로 답"}} --> F["Output filter<br/>Code"]
F -->|전화 · 이메일 · 주민번호| M["가림 · masked +1"]
F -->|IT-ADMIN- · 지시문 조각| B(["차단 · output_leak"])
M --> O(["답"])
```
**LLM에게 맡기면 안 되나.** "연락처는 가려서 답해"라고 지시하면 대부분 지킵니다. 하지만 영어로 물으면? 번호 사이에 점을 찍어 달라고 하면? **셀 수 있는 검사는 코드가 합니다** (69일차). 전화번호 모양은 정규식으로 정확히 잡을 수 있고, 매번 같은 결과를 냅니다.
**무엇을 남기고 무엇을 가리나.** 전부 가리면 본인이 자기 번호인지 알아볼 수 없습니다.
| 개인정보 | 원래 | 가린 뒤 | 남긴 것 |
| --- | --- | --- | --- |
| 전화번호 | 010-1414-2135 | 010-****-2135 | 앞 3자리 · 끝 4자리 |
| 이메일 | yujin.choi@example.com | y***@example.com | 첫 글자 · 도메인 |
요청문에 꼭 들어갈 것: **① 넣을 자리 ② 가릴 것 셋과 가리는 모양 ③ 가린 수 ④ 막을 표시와 그때의 답 · reason ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/secure-agent.json 의 Build result 와 Send result 사이에 Output filter(Code)를 넣어 줘.
- 전화번호 /01[016789][-. ]?\d{3,4}[-. ]?\d{4}/g → 앞 ____① 자리와 끝 4자리만 남기고 가운데 *, 이메일 → ____② + ***@도메인, 주민번호 /\d{6}[- ]?[1-4]\d{6}/ → 앞 6자리-*******.
가린 수를 ____③ 로.
- 답에 /IT-ADMIN-\d+|출입\s*번호|<question>|systemMessage/i 가 있으면 route 차단, 정해진 안내문, reason ____④ .
기대 결과 cases/lab03.json 앞 두 케이스, lab01-attacks.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 위 조건 표의 `010-****-2135`에서 앞에 남은 숫자 수입니다.
- ② 위 이해하기 표의 이메일 줄 "남긴 것" 칸 앞부분.
- ③ 예제 2의 `security`에 들어갈 가린 개수의 칸 이름입니다 (AGENTS.md 3절 `security`).
- ④ 위 조건 표의 "막기" 줄 (AGENTS.md 3절 `reason` 값에도 있음).
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/secure-agent.json
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py test cases/lab01-attacks.json -v # 이제 9/9인지
```
## 예제 2 · 무엇을 왜 막았는지 기록 (8분)
**만들 것**: 모든 결과에 `security` 기록: 누가 막았나(`blocked_by`: `rule` · `guard` · `output_filter` · 없으면 빈 글자), 이유(`reason`), 가린 개수(`masked`). 겉의 `reason` · `masked`는 `security` 안으로 옮깁니다.
| 조건 | 값 |
| --- | --- |
| 계약 | [AGENTS.md](AGENTS.md) 3절 `security` |
| 기대 결과 | `cases/lab03.json` 6개 전체 |
### 이해하기
**왜 남기나.** 막은 것을 세지 않으면 관문이 일을 하는지 모릅니다. "오늘 규칙이 몇 건, 판별 LLM이 몇 건 막았나", "판별 LLM이 정상 질문을 막은 적은 없나", "연락처를 몇 번 가렸나"는 **기록이 있어야** 셉니다 (과제 2의 보안 요약, 77일차 실행 로그 분석).
**길마다 같은 모양.** 질문은 세 길 중 하나로 끝납니다. 어느 길이든 **같은 모양**의 `security`를 내놓아야 나중에 한꺼번에 셀 수 있습니다.
| 끝난 길 | `blocked_by` | `reason` | `masked` |
| --- | --- | --- | --- |
| 규칙이 막음 | `rule` | 맞은 규칙 | 0 |
| 판별 LLM이 막음 | `guard` | 판별 LLM의 이유 | 0 |
| 출력 필터가 막음 | `output_filter` | `output_leak` | 가린 수 |
| 답함 | `""` (빈 글자) | `""` | 가린 수 |
**누가 막았는지 아는 법.** 규칙과 판별 LLM은 같은 `Blocked` 노드로 들어옵니다. 판별 LLM 쪽 If(`Blocked by guard?`)가 **실행됐고** 막았다면 `guard`, 아니면 `rule`. n8n 표현 `$('노드').isExecuted`로 확인합니다 ([AGENTS.md](AGENTS.md) 6절).
```mermaid
flowchart LR
R{"규칙"} -->|걸림| B["Blocked<br/>security: rule"]
G{"판별 LLM"} -->|공격| B
B -.->|"Blocked by guard? 실행됐나"| B
A{{"IT Agent"}} --> F["Output filter"] --> S["Security record<br/>security: output_filter 또는 빈 글자"]
```
**기록에 무엇을 남기나.** 막은 **문장 전체**를 남기면 그 안의 개인정보도 남습니다. 오늘은 **이유와 개수만** 남깁니다. 문장이 꼭 필요하면 보관 기간 · 볼 수 있는 사람을 정해야 합니다 (과제 2).
**기대 결과.**`lab03` 6/6: 규칙 · 판별 LLM이 막은 기록, 가린 개수 기록, 정상은 `blocked_by: ""`.
<details><summary>▶ 1단계 · 왜 남기나</summary>
막힌 질문이 하루에 몇 건인지, 판별 LLM이 정상 질문을 막은 적은 없는지는 **기록이 있어야** 셉니다. 막은 문장 전체를 남길까요, 이유만 남길까요?
</details>
<details><summary>▶ 2단계 · 길마다 같은 모양</summary>
위 표의 네 줄이 모두 같은 칸 셋을 가집니다. 답한 길의 `security`는 어느 노드에서 만들까요? (출력 필터 뒤)
요청문에 꼭 들어갈 것: **① 차단 길의 security(누가 · 이유 · 0) ② 누가 막았는지 아는 법 ③ 답 길의 security(출력 필터 뒤) ④ 겉의 칸 정리 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
모든 결과에 security {blocked_by, reason, masked} 를 넣어 줘 (AGENTS.md 3절).
- Blocked(Set)에 security 객체: blocked_by 는 ____① 가 실행됐고 blocked 면 'guard', 아니면 ' ____② '. reason 은 guard 의 reason 또는 맞은 규칙. masked 0.
- Output filter 뒤 Security record(Code): route 가 차단이면 blocked_by ' ____③ ', 아니면 ____④ . reason, masked 를 security 로 옮기고 겉에서는 뺀다.
기대 결과 cases/lab03.json 전체.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 판별 LLM의 결과로 막을지 정하는 If 노드의 이름입니다 (Lab 02 과제 1에서 만든 것, 내 파일의 실제 이름).
- ② 판별 LLM이 막지 않았는데 `Blocked`에 왔다면 누가 막은 것인가요? 위 이해하기 표.
- ③ 출력 필터가 답을 막았을 때의 값입니다. AGENTS.md 3절 `security`.
- ④ 아무도 막지 않았을 때의 값입니다. `null`이 아닙니다 (테스트의 "정상은 막지 않음 기록").
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/secure-agent.json
uv run tools/flow.py test cases/lab03.json -v
```
## 예제 3 · 관문마다 시간과 기록 보기 (7분, 에이전트 없이)
관문 다섯 곳에서 끝나는 요청을 하나씩 보내고, `-v`로 응답의 `security`와 시간을 봅니다.
```bash
uv run tools/flow.py test cases/lab03-layers.json -v
```
`SECURITY.md` 7절 표를 채웁니다.
### 이해하기
**겹겹이 막기를 한 장으로.** 오늘 만든 관문이 모두 들어간 지금, 요청마다 **어디서 끝나는지**가 다릅니다.
| 케이스 | 끝나는 곳 | 응답에서 볼 칸 |
| --- | --- | --- |
| ① 빈 질문 | 입력 검증 | HTTP 400, `codes` |
| ② 알려진 공격 | 규칙 | `security.blocked_by: rule`, `reason` |
| ③ 새 공격 | 판별 LLM | `security.blocked_by: guard`, `reason` |
| ④ 내 연락처 | 출력 필터 (가림) | `security.masked`, 답의 `****` |
| ⑤ 정상 질문 | 모두 지나감 | `security.blocked_by: ""` |
**시간으로 보는 관문.** 앞에서 끝날수록 빠르고 쌉니다. 입력 검증 · 규칙은 LLM 0번, 판별 LLM은 1번, 끝까지 가면 판별 + IT Agent(+ Tool)입니다. Lab 01 예제 3 · Lab 02 예제 3에서 잰 숫자와 맞는지 봅니다.
**규칙은 확실한 것만.** 지나치게 막은 규칙을 좁히면, 그 규칙이 막던 공격 일부가 지나갈 수 있습니다. 그래도 괜찮은 까닭은 **판별 LLM이 뒤에서 받치기** 때문입니다. 규칙은 확실한 것만, 애매한 것은 판별 LLM으로. 좁힌 뒤 `lab01-challenge`(알려진 공격 10)를 같이 돌려 확인합니다.
**`--repeat 3`.** 판별 LLM이 들어간 케이스는 실행마다 결과가 흔들릴 수 있습니다. 세 번 모두 통과해야 "고쳤다"고 봅니다.
**기대 결과.** 처음에 실패가 하나 이상 나오면 잘 쓴 테스트입니다. 고친 뒤 내 케이스 · `lab01-challenge` · `lab02-challenge` · `lab03` 모두 통과.
<details><summary>▶ 1단계 · 어디서 어려운 질문이 나오나</summary>
위 표의 다섯 종류에서 둘씩. 케이스 하나는 `{"name", "body": {"question", "user"}, "expect": {...}}`입니다. 500자 · 501자는 같은 문장을 되풀이해 만들고 `len`으로 셉니다.
</details>
<details><summary>▶ 2단계 · 어느 관문을 고칠까</summary>
실패마다 "입력 검증 · 규칙 · 판별 LLM · 데이터 API · 지시문 · 출력 필터" 중 어디서 막혀야 했는지(또는 어디서 잘못 막았는지) 먼저 말합니다. 정상이 막혔다면 응답 `security.blocked_by`와 `reason`이 답을 줍니다.
요청문에 꼭 들어갈 것: **① 고칠 노드와 고칠 규칙 수 ② 막힌 정상 질문과 그것을 막은 규칙 ③ 좁힌 뒤 받쳐 줄 관문 ④ 함께 돌릴 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다 (막힌 정상 질문이 규칙이 아닌 곳에서 막혔다면 그 관문 이름으로 바꿔 씁니다).
```text
my-work/secure-agent.json 의 Injection rules 에서 정상 질문을 막은 규칙 ____① 개를 좁혀 줘.
**한 노드에서 두 곳으로.**`Blocked` → `Send blocked`와 `Log event` **둘 다**로 잇습니다. 답은 보내고, 기록도 남깁니다. 기록 HTTP가 실패해도 사용자 답은 이미 나갔거나 그대로 나가야 하므로 `onError: continueRegularOutput`, `neverError`를 둡니다 (기록 때문에 서비스가 멈추면 안 됨).
**로그에 무엇을 남기나 (데이터 최소화).**
| 남김 | 남기지 않음 | 까닭 |
| --- | --- | --- |
| `user`(직원 이름), `route`, `blocked_by`, `reason`, `masked`, `tools_used` | 질문 · 답 글자 | 질문 · 답 안에 연락처 · 주민번호가 있을 수 있음 |
| | 연락처 | 가렸어도 로그에 또 남길 까닭이 없음 |
"누가 막혔나"는 알아야 하므로 직원 이름은 남깁니다. 개인정보보호법의 "필요한 최소한"은 **목적에 필요한 것만** 모은다는 뜻입니다. 보안 요약의 목적은 "몇 건, 누가, 어느 관문"이므로 질문 글자는 필요 없습니다.
**스스로 점검하는 `pii_in_log`.** 요약 워크플로우가 로그 전체를 글자로 바꿔 전화 · 이메일 · 주민번호 **모양을 셉니다.** 0이 아니면 어딘가에서 개인정보가 로그에 들어간 것입니다. 지켜야 할 규칙을 **테스트 가능한 숫자**로 만든 것입니다.
요청문에 꼭 들어갈 것: **① 로그 노드와 이을 자리 ② 로그 본문 칸과 남기지 않을 것 ③ 기록이 실패해도 ④ 새 요약 워크플로우(웹훅 · 세기 · pii_in_log) ⑤ 테스트와 push**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
1) my-work/secure-agent.json: Security record 와 Blocked 뒤에 Log event(HTTP POST http://data-api:8800/ ____① )를 하나 더 이어 줘 (답을 보내는 노드는 그대로).