feat(wiki): knowledge-wiki — 세션 지식 자동 축적 + 4단 반출 게이트 (v0.1.16) - #34
Conversation
세션에서 배운 것을 일반화된 article 로 컴파일해 사용자 위키 repo 에 쌓는다. 설치 후 첫 세션에서 설정·위키를 자동 생성하므로 사용자 파일 작업이 없고, 기본값은 로컬 축적만이라 remote 를 채우기 전에는 기계 밖으로 나가지 않는다. 유사 기능이 실패하는 지점은 성능이 아니라 경계다. 전역으로 켜져 맥락 무관하게 흡수하면 사용자가 기능을 통째로 끄고, 그때 팀 지식까지 같이 잃는다. 그래서 경계를 프롬프트가 아니라 실행 코드로 고정했다. 반출은 4단 게이트를 통과한 것만 나간다. G1/G4 는 정규식, G2/G3 은 LLM 인데 LLM 에게 통과 판정 권한을 주지 않는다 — G3 은 위반 목록만 반환하고 최종 판정은 G4 의 결정론이 내린다. 금지 항목은 부분 마스킹하지 않고 세션 전체를 폐기한다. 남은 문맥으로 역추론이 가능하고 "어디까지 지웠는지" 증명할 수 없기 때문이다. 정책 정본은 contracts/INGEST-POLICY.md 에 두어 코드와 문서가 같은 계약을 본다.
Hyeongseob91
left a comment
There was a problem hiding this comment.
리뷰 — knowledge-wiki (v0.1.16)
설계 논지에 동의한다. "유사 기능이 실패하는 지점은 성능이 아니라 경계"이고,
경계를 프롬프트가 아니라 실행 코드에 두었다는 것. G3에게 승인 어휘를 주지 않고
최종 판정을 G4 결정론에 맡긴 구조, 부분 마스킹 대신 세션 폐기 —
둘 다 근거가 분명하고 코드가 그대로 그렇게 되어 있다.
다만 실제로 돌려보니 세 가지가 논지대로 동작하지 않는다. 전부 재현했고 아래에 명령을 남긴다.
2건은 머지 전 수정이 필요하다고 본다.
🔴 Blocking 1 — PyYAML 폴백 파서가 리스트를 통째로 버린다 (config.py:69)
include: 같은 최상위 키는 먼저 result[key] = {} 로 초기화되는데(L84),
- item 분기가 setdefault(section_key, []) 를 쓴다(L69). setdefault는 키가 이미 있으면
기존 값을 반환하므로 {} 가 돌아오고, isinstance(bucket, list) 가 False라 항목이 조용히 버려진다.
$ python3 -c "…폴백 파서로 부트스트랩 템플릿 파싱…"
parsed = {'wiki': {...}, 'include': {}, 'exclude': {}}
verdict = (False, 'include 미설정 (쌓을 경로를 지정해야 동작한다)')
PyYAML이 없는 환경 — 이 폴백이 존재하는 바로 그 이유 — 에서는
부트스트랩이 설정을 만들고 "지식 위키를 켰습니다"를 출력한 뒤, 이후 모든 세션이 G0에서 거부된다.
게다가 tenant가 None이면 accumulate.py:122 가 로그 없이 return 0 하므로 로그도 안 남는다.
SKILL.md 진단표는 이 경우 "include 밖/exclude/자기오염"이라며 사용자를 정상으로 보이는 설정 파일로 보낸다.
즉 이 PR이 "이전 세대와 다른 점"으로 든 조용히 죽어서 아무도 모르는 실패 가 그대로 재현된다.
수정:
bucket = result.get(section_key)
if not isinstance(bucket, list):
bucket = []
result[section_key] = bucket
bucket.append(item)🔴 Blocking 2 — 문서화된 전역 kill-switch를 repo 마커가 우회한다 (gates.py:164)
if _disabled(global_conf) and not marker_conf:
return None, "전역 설정에서 비활성"마커가 있으면 전역 enabled: false 를 건너뛴다. 재현:
전역 enabled:false, 마커 없음 -> 거부 ("전역 설정에서 비활성")
전역 enabled:false, 마커 enabled:true -> 통과 — kill-switch 우회
전역 exclude + 마커 enabled:true -> 거부 (exclude는 마커를 이김, 문서대로)
SKILL.md L138은 enabled: false 를 "전체 일시 중지"로 안내한다.
exclude 는 마커보다 강하게 만들어 두고 전역 off-switch만 약한 건 비대칭이고,
"사용자가 언제든 통째로 끌 수 있어야 한다"는 이 기능의 전제와 정면으로 어긋난다.
마커의 역할은 범위 opt-in 이지 kill-switch 해제 가 아니다. _disabled(global_conf) 검사를
마커 처리보다 앞으로 옮기면 된다.
🟠 High 1 — G1 오탐으로 유효 세션이 대량 폐기된다 (gates.py:54, gates.py:59)
최근 세션 15건에 G1을 그대로 돌린 결과:
| 전체 | 4/15 폐기 |
| 15k자(=article 가치가 있을 만한 긴) 세션만 | 3/5 폐기 |
| 폐기 사유 | D6 홈 절대경로 ×3, D2 이메일 ×1 |
매칭된 원문이 문제다:
- D2 이메일 ←
git@github.com. SSH remote를 언급한 모든 세션이 죽는다.
이 PR의 SKILL.md L38 자체가git@github.com:myteam/team-wiki.git를 담고 있어서,
이 스킬을 읽은 세션은 자기 자신 때문에 폐기된다. - D4 사설 IP ← 버전 문자열.
macOS 10.15.7,Kafka 10.2.1이 전부 사설 IP로 잡힌다
(옥텟 3개만 요구해서).10.0.0.0/8같은 문서용 표기도 마찬가지. - D6 ← 어시스턴트가 언급한 파일 경로. Claude Code 세션에서 사실상 상시 등장한다.
구조적으로 보면: G1이 검사하는 원문은 기계 밖으로 나가지 않는다. 나가는 건 G2 출력이고,
그건 G4가 같은 함수로 이미 검사한다. D1(자격증명)은 모델에 넣는 것 자체가 위험하니 G1 폐기가 맞다.
하지만 D2/D4/D6은 "입력이 경로를 언급했다"는 이유로 세션을 버리는 셈이고, 출력 쪽은 G4가 어차피 막는다.
제안 — G1은 D1 + 주민번호로 좁히고 D2/D4/D6은 G4 전용으로. 패턴 자체도 같이:
- 사설 IP는 4옥텟 필수 (
\.\d{1,3}하나 추가) - D2는
git@·noreply@같은 서비스 계정 제외
지금 상태로 머지하면 "폐기 로그가 많은 건 게이트가 일하는 것"이라는 문서 문구에 가려
기능이 거의 아무것도 쌓지 않는데도 정상으로 보인다.
🟠 High 2 — 커서가 세션이 아니라 subdir 기준 (accumulate.py:127)
cursor = wiki / tenant.subdir / ".transcript_cursor" — 기본 설정에서는 모든 repo가
같은 per-user/<user> 를 쓰므로 커서 하나를 전 프로젝트가 공유한다.
repo A 세션(800줄) → 커서=800
repo B 세션(120줄) → start(800) > len(120) → 0으로 리셋, B 전체 읽음 → 커서=120
repo A 재개(900줄) → start=120 → 120~900 재읽기 → 이미 컴파일한 구간으로 중복 article
병렬 세션이면 서로 커서를 덮어써 구간 누락도 난다. transcript 경로 해시로 파일을 나눠야 한다
(.cursors/<sha1(transcript_path)>).
🟡 Medium
M1. 위험 명령 훅 — 따옴표 형태가 전부 통과 (hooks.json:21)
정규식을 hooks.json에서 그대로 뽑아 이스케이프 체인까지 재현해 돌린 결과:
차단 | (베어) 슬래시 타깃, 홈 글롭, sudo 접두
통과 | rm -rf "$HOME"
통과 | rm -rf $HOME/ ← 홈 내용물 삭제
통과 | 따옴표로 감싼 루트 타깃 (single/double 모두)
통과 | 플래그를 타깃 앞에 끼운 형태 (--no-preserve-root)
PR 본문의 "탐지 11/11"은 테스트 셋에 인용 형태가 없어서 나온 수치로 보인다.
grep이라 쉘 인용을 해제할 수 없으니 타깃 앞에 ['\"]? 를 허용하고, $HOME 뒤 / 도 종료로 인정해야 한다.
(오탐 수정 자체는 확실히 개선이다 — 이 리뷰를 쓰는 동안 구 정규식이 내 테스트 명령을 두 번 막았다.)
M2. no_hooks 확보 실패 시 fail-open (accumulate.py:147)
쓰기가 실패하면 no_hooks = None 이 되고, compile._claude 가 --settings 없이
claude --print 를 띄운다. 자식 세션은 같은 Stop 훅을 물고 있으므로 accumulate.py를 다시 실행하고,
그게 또 claude를 띄운다. 다른 실패 경로는 전부 폐기인데 여기만 반대 방향이다 —
no_hooks 를 못 만들면 폐기해야 한다.
M3. 한국어 제목이면 파일명이 전부 -note.md (compile.py:182)
'# LangGraph interrupt 패턴의 재개 조건' -> 'langgraph-interrupt'
'# 캐시 무효화 타이밍 문제' -> '' → 'note'
컴파일 프롬프트가 한국어라 한글 전용 제목이 흔하다. 타임스탬프가 분 단위이고
write_text 라, 같은 분에 끝난 두 세션은 경고 없이 덮어쓴다.
🔵 Low / 관찰
- clone한 팀 위키에는
.gitignore가 안 생긴다._git_init은 부트스트랩 경로 전용이라,
clone된 위키에는.transcript_cursor·.no-hooks.json이 untracked로 남는다.
커밋에는 안 들어간다(git add -- <rel>로 스코프됨) — 워킹카피 노이즈 수준. - 부트스트랩 기본
include: ~(홈 전체). 로컬 전용이면 타당하다. 그런데 나중에remote한 줄만
추가하면 범위는 홈 전체인 채로 push가 켜진다. 이 PR의 논지대로면 여기야말로 코드로 막을 자리다
(remote가 있고 include에 홈 루트가 있으면 거부). 지금은 템플릿 주석의 권고뿐이다.
여러 조직 작업을 한 머신에서 하는 경우 이게 정확히 유출 경로가 된다. - 테스트가 PR에 없다. Test plan은 36건·16/16·12/12·29/29를 주장하는데
.github/scripts/에 knowledge_wiki 테스트가 없다. Blocking 1·2는 정확히 그 공백에서 나오는 종류의 버그다.
최소한 G0 판정 매트릭스와 폴백 파서만이라도 CI에 넣었으면 한다. - 스코프. 기능 + 버전 catch-up(0.1.8→0.1.16) + agent/skill 카운트 정정 + 파이프라인 문서 갱신 +
훅 정규식 수정이 한 커밋에 묶여 있다. 되돌릴 때 기능만 떼어내기 어렵다.
좋았던 것
gates.scan을 G1/G4가 공유해 정본이 하나인 것publish.write_and_push의_ALLOWED_ROOTS2차 방어선 —resolve_tenant를 우회한 호출까지 막는다- 자기 오염 검사(위키가 작업 repo 안이면 거부) — 옛 세대 안티패턴을 정확히 지목했다
git add -- <rel>/git commit -- <rel>로 스테이징을 파일 하나에 묶은 것contracts/INGEST-POLICY.md를 정본으로 두고 코드 주석이 조항을 참조하는 구조
재현
# G1 오탐 — 최근 세션에 게이트 적용
python3 - <<'EOF'
import sys, glob, os
sys.path.insert(0, 'plugins/wigtn-plugins/scripts')
from knowledge_wiki import gates
from knowledge_wiki.accumulate import _read_transcript
for f in sorted(glob.glob(os.path.expanduser('~/.claude/projects/*/*.jsonl')),
key=os.path.getmtime, reverse=True)[:15]:
t = _read_transcript(f, None)
if t.strip():
print(len(t), gates.scan(t) or 'PASS')
EOF판정: 머지 전 Blocking 2건 수정 필요. High 2건은 같은 PR에서 하는 편이 낫다고 보지만
후속으로 빼도 기능은 동작한다. Medium 이하는 별건으로 잡아도 된다.
| item = stripped[2:].strip().strip("'\"") | ||
| if section_key is None: | ||
| continue | ||
| bucket = result.setdefault(section_key, []) |
There was a problem hiding this comment.
Blocking. L84에서 result[key] = {} 로 초기화된 뒤라 setdefault(section_key, []) 는 기존 {} 를 돌려준다. 다음 줄의 isinstance(bucket, list) 가 False → 모든 리스트 항목이 조용히 버려진다.
결과: PyYAML 없는 환경에서 include 가 항상 비고, G0가 모든 세션을 거부한다. tenant가 None이면 로그도 안 남아서 무증상이다.
bucket = result.get(section_key)
if not isinstance(bucket, list):
bucket = []
result[section_key] = bucket
bucket.append(item)| if _disabled(marker_conf): | ||
| return None, "마커에서 비활성" | ||
|
|
||
| if _disabled(global_conf) and not marker_conf: |
There was a problem hiding this comment.
Blocking. 마커가 있으면 전역 enabled: false 를 건너뛴다. SKILL.md L138은 이걸 "전체 일시 중지"로 안내한다 — 문서화된 kill-switch가 실제로는 끄지 않는다.
L185의 exclude는 마커를 이기게 해두었는데 전역 off-switch만 약한 건 비대칭이다. 마커의 역할은 범위 opt-in 이지 kill-switch 해제 가 아니다.
if _disabled(global_conf):
return None, "전역 설정에서 비활성"를 마커 처리보다 앞으로.
| (r"-----BEGIN\s+[A-Z ]*PRIVATE KEY-----", "D1 개인키"), | ||
| (r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^\s:/@]+:[^\s@]{4,}@", "D1 접속 문자열"), | ||
| # D2 — 개인정보 | ||
| (r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "D2 이메일"), |
There was a problem hiding this comment.
이 패턴이 git@github.com 을 잡는다. SSH remote를 언급한 세션이 전부 폐기된다 — 이 PR의 SKILL.md L38(git@github.com:myteam/team-wiki.git)을 읽은 세션 포함.
실측: 최근 긴 세션 5건 중 3건이 D2/D6으로 폐기됐다.
G1이 보는 건 원문이고 원문은 기계 밖으로 안 나간다. 나가는 G2 출력은 G4가 같은 함수로 이미 검사한다. D1은 모델에 넣는 것 자체가 위험하니 G1 폐기가 맞지만, D2/D4/D6은 G4 전용으로 옮기는 게 맞다고 본다. 유지한다면 최소한 git@·noreply@ 는 제외.
| (r"\b01[016-9]-?\d{3,4}-?\d{4}\b", "D2 휴대전화"), | ||
| (r"\+\d{1,3}[\s\-]\d{2,4}[\s\-]\d{3,4}[\s\-]\d{4}", "D2 국제전화"), | ||
| # D4 — 내부 인프라 | ||
| (r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}(?:\.\d{1,3})?\b", "D4 사설 IP"), |
There was a problem hiding this comment.
옥텟을 3개만 요구해서 버전 문자열을 사설 IP로 잡는다.
"macOS 10.15.7 에서 재현됨" -> ['D4 사설 IP']
"Kafka 10.2.1 클러스터" -> ['D4 사설 IP']
(?:\.\d{1,3}){3} 로 4옥텟을 강제하면 해결된다.
| wiki = tenant.wiki_path | ||
|
|
||
| # ── transcript 확보 ─────────────────────────────────────── | ||
| cursor = wiki / tenant.subdir / ".transcript_cursor" |
There was a problem hiding this comment.
커서가 세션이 아니라 subdir 기준이다. 기본 설정에서는 모든 repo가 같은 per-user/<user> 를 쓰므로 커서 하나를 전 프로젝트가 공유한다.
repo A(800줄) → 커서=800
repo B(120줄) → start>len → 0 리셋, 전체 읽음 → 커서=120
repo A 재개(900줄) → 120~900 재읽기 → 중복 article
병렬 세션이면 서로 덮어써 구간 누락도 난다. .cursors/<sha1(transcript_path)> 처럼 transcript 단위로 분리 필요.
| if not no_hooks.exists(): | ||
| try: | ||
| no_hooks.write_text('{"disableAllHooks": true}\n', encoding="utf-8") | ||
| except OSError: |
There was a problem hiding this comment.
여기만 fail-open이다. no_hooks = None 이면 compile._claude 가 --settings 없이 claude --print 를 띄우고, 자식 세션은 같은 Stop 훅을 물고 있어서 accumulate.py를 다시 실행한다 → 또 claude → 재귀.
모듈의 나머지 실패 경로는 전부 폐기다. no_hooks 를 못 만들면 여기서도 return 0 해야 한다.
| for line in article.splitlines(): | ||
| stripped = line.strip() | ||
| if stripped.startswith("# "): | ||
| slug = re.sub(r"[^a-zA-Z0-9\-]+", "-", stripped[2:].lower()).strip("-") |
There was a problem hiding this comment.
한글 전용 제목이면 slug가 빈 문자열이 되어 파일명이 전부 note 로 수렴한다.
'# LangGraph interrupt 패턴의 재개 조건' -> 'langgraph-interrupt'
'# 캐시 무효화 타이밍 문제' -> ''
컴파일 프롬프트가 한국어라 흔한 경우다. 타임스탬프가 분 단위 + write_text 라 같은 분에 끝난 두 세션이 경고 없이 덮어쓴다. slug가 비면 짧은 해시를 붙이는 게 안전하다.
| { | ||
| "type": "command", | ||
| "command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); if echo \"$CMD\" | grep -qE \"rm\\s+-rf\\s+/|git\\s+push\\s+.*--force|git\\s+push\\s+.*-f\\s|git\\s+reset\\s+--hard|drop\\s+database|DROP\\s+TABLE\"; then echo \"BLOCKED: Dangerous command detected. Please confirm before executing: $CMD\" >&2; exit 2; fi'" | ||
| "command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); if echo \"$CMD\" | grep -qE \"rm\\s+(-\\w*[rR]\\w*f\\w*|-\\w*f\\w*[rR]\\w*|-[rR]\\s+-f|-f\\s+-[rR]|--recursive\\s+--force|--force\\s+--recursive)\\s+(/|~|[$]HOME|[$][{]HOME[}])(\\s|$|\\*|/\\*)|git\\s+push\\s+.*--force|git\\s+push\\s+.*-f\\s|git\\s+reset\\s+--hard|[Dd][Rr][Oo][Pp]\\s+([Dd][Aa][Tt][Aa][Bb][Aa][Ss][Ee]|[Tt][Aa][Bb][Ll][Ee]|[Ss][Cc][Hh][Ee][Mm][Aa])\"; then echo \"BLOCKED: Dangerous command detected. Please confirm before executing: $CMD\" >&2; exit 2; fi'" |
There was a problem hiding this comment.
오탐 수정은 확실한 개선이다(구 정규식이 이 리뷰 작성 중 내 테스트 명령을 두 번 막았다). 다만 인용 형태가 전부 빠져나간다 — hooks.json에서 정규식을 그대로 뽑아 확인:
통과 | rm -rf "$HOME"
통과 | rm -rf $HOME/ ← 홈 내용물 삭제
통과 | 따옴표로 감싼 루트 타깃 (' 및 " 모두)
통과 | 타깃 앞에 플래그를 끼운 형태
grep이라 쉘 인용을 해제할 수 없으니 타깃 앞에 ['\"]? 를 허용하고, $HOME 뒤의 / 도 종료 문자로 인정해야 한다. 본문의 "탐지 11/11"은 테스트 셋에 인용 형태가 없어서 나온 수치로 보인다.
리뷰에서 나온 blocking 2건. 둘 다 경계가 문서와 다르게 동작하던 문제다.
1. YAML 폴백 파서가 리스트 항목을 전부 버렸다.
최상위 키는 값 없이 나오면 {} 로 선점되는데, `- item` 분기가 setdefault 를
써서 그 {} 를 그대로 돌려받았다. isinstance(bucket, list) 가 False 라 항목이
조용히 사라졌고, include 가 항상 비어 G0 가 모든 세션을 거부했다.
PyYAML 없는 환경 — 이 폴백이 존재하는 그 이유 — 에서 부트스트랩은 "켰습니다"를
출력한 뒤 아무것도 쌓지 않았다. tenant 가 None 이면 로그도 안 남아 무증상이다.
2. 전역 enabled: false 를 repo 마커가 우회했다.
SKILL.md 는 이걸 "전체 일시 중지"로 안내한다. exclude 는 마커보다 강하게
해두고 off-switch 만 약한 건 비대칭이고, 한 줄로 통째로 끌 수 없으면 사용자는
기능을 지운다 — 이 정책이 존재하는 이유가 그것이다. 마커의 역할은 범위 opt-in
이지 kill-switch 해제가 아니다.
회귀 테스트를 붙였다 (test_knowledge_wiki.py, 33건, 모델 호출 없음).
수정 전 코드에 돌리면 위 두 건에서 정확히 5건이 실패한다. 폴백 강제는
sys.modules 에 None 을 박는 방식 — meta_path finder 는 3.12 에서 조용히
무력화돼 PyYAML 설치된 러너에서 폴백을 안 타고도 통과한다.
|
리뷰의 blocking 2건을 3832c4b 에서 수정했다.
회귀 테스트 남은 항목은 후속으로 넘긴다 — G1 오탐 범위(D2/D4/D6 을 G4 전용으로), 커서 세션 분리, 훅 정규식 인용 형태, |
Summary
세션에서 배운 것을 일반화된 article 로 컴파일해 사용자 자신의 위키 repo 에 쌓는다.
설치 후 첫 세션에서 설정·위키를 자동 생성하므로 사용자 파일 작업이 없고, 기본값은
로컬 축적만이라
remote를 채우기 전에는 기계 밖으로 나가지 않는다.유사 기능이 실패하는 지점은 성능이 아니라 경계다. 전역으로 켜져 맥락 무관하게 흡수하면
사용자가 기능을 통째로 끄고, 그때 팀 지식까지 같이 잃는다. 그래서 경계를 프롬프트가 아니라
실행 코드로 고정했다.
Changes
scripts/knowledge_wiki/— 4단 게이트 파이프라인 (G0 스코프 · G1/G4 결정론 · G2/G3 LLM)contracts/INGEST-POLICY.md— 반입 정책 정본. 코드와 문서가 같은 계약을 본다skills/knowledge-wiki/SKILL.md— 설정·진단·끄는 법hooks/hooks.json— Stop 훅 등록(async) + 위험 명령 정규식 오탐/미탐 수정wiki.remote자동 clone설계 요점
LLM 에게 통과 판정 권한을 주지 않는다. G3 은 위반 목록만 반환하고, 최종 판정은 G4 의
결정론이 내린다. LLM 에게 "이거 괜찮아?"를 물으면 대부분 "괜찮다"고 하기 때문이다.
부분 마스킹하지 않고 세션 전체를 폐기한다. 남은 문맥으로 역추론이 가능하고
"어디까지 지웠는지" 증명할 수 없다. 항목 하나 잃는 비용과 유출 비용은 비대칭이다.
auto-push 는
per-user/·ouroboros/까지.shared/승격은 사람이 PR 로 한다.부수 수정 — 위험 명령 훅
기존 정규식이 미탐 7/11, 오탐 3/11 이었다.
-fr), 분리 플래그(-r -f), 롱플래그 형태가 모두 통과/tmp/...,/var/...등)drop+database조합과 대문자DROP+TABLE조합만 잡아 나머지를 놓침수정 후 탐지 11/11 · 오탐 0/11.
Test plan
validate_plugin.py통과 (manifest·카운트·버전 일치)check_contracts.py통과 (130건)test_gate.sh통과 (36건)