diff --git a/.github/scripts/test_danger_hook.sh b/.github/scripts/test_danger_hook.sh new file mode 100644 index 0000000..88a267f --- /dev/null +++ b/.github/scripts/test_danger_hook.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# 위험 명령 훅 회귀 테스트 (hooks.json PreToolUse). +# +# 정규식을 눈으로 읽고 판단하지 않는다 - hooks.json 에서 명령을 그대로 뽑아 +# 실제 JSON -> shell -> grep 이스케이프 체인을 태우고 exit code만 본다. +# 이 정규식은 두 번 연속 실측 없이 수정됐다가 미탐/오탐을 남겼다. +# +# 위험 명령을 실행하지 않는다. 훅에 문자열로 건네 판정만 받는다. +# +# 실행: bash .github/scripts/test_danger_hook.sh + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +HOOKS="$REPO_ROOT/plugins/wigtn-plugins/hooks/hooks.json" +PASS=0; FAIL=0 + +CMD=$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$HOOKS") +if [ -z "$CMD" ] || [ "$CMD" = "null" ]; then + echo "hooks.json 에서 PreToolUse 명령을 읽지 못했다"; exit 1 +fi + +verdict() { # $1 = 검사할 커맨드 문자열 -> exit code + printf '{"tool_input":{"command":%s}}' "$(printf '%s' "$1" | jq -Rs .)" \ + | sh -c "$CMD" >/dev/null 2>&1 + echo $? +} + +blocked() { # $1 = 커맨드 (차단되어야 함) + local got; got=$(verdict "$1") + if [ "$got" = "2" ]; then echo " ok 차단 | $1"; PASS=$((PASS+1)) + else echo " FAIL 통과됨(미탐) | $1"; FAIL=$((FAIL+1)); fi +} + +allowed() { # $1 = 커맨드 (통과해야 함) + local got; got=$(verdict "$1") + if [ "$got" = "0" ]; then echo " ok 통과 | $1"; PASS=$((PASS+1)) + else echo " FAIL 차단됨(오탐) | $1"; FAIL=$((FAIL+1)); fi +} + +echo "== 미탐: 홈/루트 전체 삭제는 형태와 무관하게 막는다 ==" +blocked 'rm -rf /' +blocked 'rm -rf /*' +blocked 'rm -rf ~' +blocked 'rm -rf ~/' +blocked 'rm -rf ~/*' +blocked 'sudo rm -rf /' +blocked 'rm -fr /' +blocked 'rm -r -f /' +blocked 'rm -f -r /' +blocked 'rm --recursive --force /' +echo "-- 인용 형태 (구 정규식이 전부 통과시키던 구간) --" +blocked 'rm -rf "$HOME"' +blocked "rm -rf '\$HOME'" +blocked 'rm -rf $HOME' +blocked 'rm -rf $HOME/' +blocked 'rm -rf ${HOME}' +blocked 'rm -rf "${HOME}"' +blocked 'rm -rf "/"' +blocked "rm -rf '/'" +blocked 'rm -rf "$HOME"/*' +echo "-- 플래그가 앞에 끼는 형태 --" +blocked 'rm --no-preserve-root -rf /' +blocked 'rm -v -rf /' + +echo +echo "== 미탐: 그 외 파괴적 명령 ==" +blocked 'git push --force origin main' +blocked 'git push -f origin main' +blocked 'git reset --hard HEAD~3' +blocked 'DROP DATABASE prod' +blocked 'drop database prod' +blocked 'DROP TABLE users' +blocked 'Drop Schema app' + +echo +echo "== 오탐: 정상 작업을 막지 않는다 ==" +allowed 'rm -rf /tmp/build-cache' +allowed 'rm -rf /var/folders/xx/T/scratch' +allowed 'rm -rf ~/Dev/project/dist' +allowed 'rm -rf "$HOME/Library/Caches/app"' +allowed 'rm -rf $HOME/tmp' +allowed 'rm -rf node_modules' +allowed 'rm -rf ./dist' +allowed 'rm -rf build/' +allowed 'rm -rf "$HOME_BACKUP"' +allowed 'rm file.txt' +allowed 'git push origin main' +allowed 'git reset --soft HEAD~1' +allowed 'SELECT * FROM users' +allowed 'echo "how to drop a database safely"' + +echo +if [ "$FAIL" -gt 0 ]; then + echo "결과: $PASS 통과 / $FAIL 실패" + exit 1 +fi +echo "결과: $PASS 통과 / 0 실패" diff --git a/.github/scripts/test_knowledge_wiki.py b/.github/scripts/test_knowledge_wiki.py index 45f5eed..45999c0 100644 --- a/.github/scripts/test_knowledge_wiki.py +++ b/.github/scripts/test_knowledge_wiki.py @@ -12,6 +12,7 @@ from __future__ import annotations +import json import os import subprocess import sys @@ -102,15 +103,12 @@ def test_scope_gate() -> None: os.environ["WIGTN_WIKI_CONFIG"] = str(cfg) def write_config(*, enabled: str = "true", subdir: str = "per-user/me", - exclude: str = "", wiki_path: Path = wiki) -> None: - text = ( - f"enabled: {enabled}\n" - "wiki:\n" - f" path: {wiki_path}\n" - f" subdir: {subdir}\n" - "include:\n" - f" - {tmp}\n" - ) + exclude: str = "", wiki_path: Path = wiki, + include: Path | None = None, remote: str = "") -> None: + text = f"enabled: {enabled}\nwiki:\n path: {wiki_path}\n subdir: {subdir}\n" + if remote: + text += f" remote: {remote}\n" + text += f"include:\n - {include or tmp}\n" if exclude: text += f"exclude:\n - {exclude}\n" cfg.write_text(text, encoding="utf-8") @@ -171,6 +169,43 @@ def verdict() -> bool: nested.mkdir(parents=True) check("node_modules 하위 → 거부", gates.resolve_tenant(str(nested))[0] is None, True) + # ── push 경계 (#40) — 범위가 넓으면 축적은 하되 push 는 보류 ── + def tenant() -> object: + return gates.resolve_tenant(str(work))[0] + + write_config(remote="git@example.com:team/wiki.git") + t = tenant() + check("remote + 좁은 include → push 허용", getattr(t, "push", None), True) + + # 픽스처가 홈 밖(임시 디렉터리)이므로 루트를 넓은 범위의 대표로 쓴다. + # 홈 자체에 대한 판정은 아래 broad_scope_reason 단위 검사에서 본다. + write_config(remote="git@example.com:team/wiki.git", include=Path("/")) + t = tenant() + check("remote + 루트 전체 include → push 보류", getattr(t, "push", None), False) + check(" 보류 사유가 기록된다", bool(getattr(t, "push_hold", "")), True) + check(" 축적 자체는 계속된다", t is not None, True) + + write_config(include=Path("/")) + check("remote 없으면 범위가 넓어도 보류 아님", getattr(tenant(), "push", None), True) + + write_config(remote="git@example.com:team/wiki.git", include=Path("/")) + marker("enabled: true\n") + check("마커로 명시 opt-in 한 repo 는 보류 대상 아님", getattr(tenant(), "push", None), True) + marker(None) + + from knowledge_wiki import config as kw_config + + check( + "broad_scope_reason: 홈 전체는 넓다", + bool(kw_config.broad_scope_reason({"include": [str(Path.home())]})), + True, + ) + check( + "broad_scope_reason: 홈 하위는 넓지 않다", + kw_config.broad_scope_reason({"include": [str(Path.home() / "Dev")]}), + "", + ) + os.environ.pop("WIGTN_WIKI_CONFIG", None) @@ -181,7 +216,8 @@ def test_deny_patterns() -> None: section("G1/G4 결정론 패턴") from knowledge_wiki import gates - must_catch = [ + # 입력·출력 양쪽에서 반드시 잡아야 하는 것 (모델에 넣는 것 자체가 위험) + hard = [ ("AWS 키", "AKIAIOSFODNN7EXAMPLE 를 썼다"), ("provider 키", "sk-abcdefghijklmnopqrstuvwxyz012345"), ("GitHub 토큰", "ghp_abcdefghijklmnopqrstuvwxyz0123456789"), @@ -190,15 +226,33 @@ def test_deny_patterns() -> None: ("주민번호", "901231-1234567"), ("Bearer", "Authorization: Bearer abcdefghijklmnopqrstuvwxyz"), ] - for name, text in must_catch: - check(f"탐지: {name}", bool(gates.scan(text)), True) + for name, text in hard: + check(f"G1 탐지: {name}", bool(gates.scan_input(text)), True) + check(f"G4 탐지: {name}", bool(gates.scan(text)), True) + + # 반출물에서만 막는다. 원문에 있다고 세션을 버리지 않는다 (#35). + output_only = [ + ("홈 절대경로", "프로젝트 파일 /Users/someone/proj/a.ts 를 수정"), + ("개인 이메일", "문의는 person.name@example.com 으로"), + ("사설 IP", "게이트웨이는 192.168.0.1 이다"), + ("내부 호스트", "build.internal 에서 받는다"), + ] + for name, text in output_only: + check(f"G1 통과(원문 허용): {name}", gates.scan_input(text), []) + check(f"G4 탐지(반출 차단): {name}", bool(gates.scan(text)), True) - must_pass = [ + # 오탐 — 어느 계층에서도 잡히면 안 된다 + clean = [ ("일반 기술 서술", "이 라이브러리는 재시도 시 지수 백오프를 쓴다."), ("의사코드", "if retries > 3: raise TimeoutError"), + ("SSH remote", "remote 는 git@github.com:team/wiki.git 이다"), + ("noreply 주소", "커밋 트레일러의 noreply@users.example.com"), + ("macOS 버전", "macOS 10.15.7 에서 재현됨"), + ("Kafka 버전", "Kafka 10.2.1 클러스터에서 측정"), + ("의존성 버전", "라이브러리 172.20.1 릴리스 노트"), ] - for name, text in must_pass: - check(f"통과: {name}", gates.scan(text), []) + for name, text in clean: + check(f"오탐 없음: {name}", gates.scan(text), []) # ═════════════════════════════════════════════════════════════ @@ -219,12 +273,102 @@ def test_publish_boundary() -> None: True, ) + # 덮어쓰기 금지 (#39) — 같은 이름이 또 오면 접미사를 붙인다 + publish.write_and_push(tmp, "per-user/me", "dup.md", "first", push=False) + ok, rel = publish.write_and_push(tmp, "per-user/me", "dup.md", "second", push=False) + check("같은 파일명 재게시 성공", ok, True) + check(" 덮어쓰지 않고 새 이름을 쓴다", "dup-2.md" in rel, True) + check( + " 먼저 쓴 내용이 남아 있다", + (tmp / "per-user/me/dup.md").read_text(encoding="utf-8"), + "first", + ) + + +def test_slug() -> None: + """#39 — 파일명 slug 는 절대 비지 않는다.""" + section("파일명 slug") + from knowledge_wiki.compile import slugify + + check("ASCII 제목", slugify("# Debouncing async writes"), "debouncing-async-writes") + check( + "혼합 제목은 ASCII 부분을 남긴다", + slugify("# LangGraph interrupt 패턴의 재개 조건"), + "langgraph-interrupt", + ) + korean = slugify("# 캐시 무효화 타이밍 문제") + check("한글 전용 제목도 빈 값이 아니다", bool(korean), True) + check(" note 로 수렴하지 않는다", korean != "note", True) + check( + " 결정론 — 같은 제목이면 같은 slug", + slugify("# 캐시 무효화 타이밍 문제"), + korean, + ) + check( + " 다른 제목이면 다른 slug", + slugify("# 재시도 백오프 설계") != korean, + True, + ) + + +_STATE_PROBE = r""" +import json, sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from knowledge_wiki import accumulate, config + +cursor = accumulate._cursor_for("/somewhere/session-abc.jsonl") +other = accumulate._cursor_for("/somewhere/session-xyz.jsonl") +print(json.dumps({ + "cursor": str(cursor) if cursor else "", + "distinct": bool(cursor and other and cursor != other), + "stable": str(accumulate._cursor_for("/somewhere/session-abc.jsonl")) == str(cursor), + "no_hooks": str(accumulate._no_hooks_settings() or ""), + "state": str(config.state_dir() or ""), +})) +""" + + +def test_state_isolation() -> None: + """#36 — 커서·중첩 설정은 위키 repo 밖(머신 로컬 상태)에 있어야 한다.""" + section("상태 격리 (위키 repo 오염 방지)") + + home = Path(tempfile.mkdtemp()) + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home)} + env.pop("WIGTN_WIKI_CONFIG", None) + scripts = str(REPO_ROOT / "plugins" / "wigtn-plugins" / "scripts") + + result = subprocess.run( + [sys.executable, "-c", _STATE_PROBE, scripts], + capture_output=True, text=True, env=env, timeout=60, + ) + if result.returncode != 0: + FAILURES.append(f"상태 격리 프로브 실행 실패\n {result.stderr.strip()[:300]}") + return + + data = json.loads(result.stdout.strip().splitlines()[-1]) + state = home / ".wigtn" / "state" + check("state_dir 이 홈 아래 .wigtn/state", data["state"], str(state)) + check("커서가 state 아래", data["cursor"].startswith(str(state / "cursors")), True) + check("transcript 마다 커서가 다르다", data["distinct"], True) + check("같은 transcript 는 같은 커서", data["stable"], True) + check("no-hooks 설정도 state 아래", data["no_hooks"], str(state / "no-hooks.json")) + + # 위키 repo 에는 article 말고 아무것도 생기지 않는다 + wiki = home / ".wigtn" / "wiki" + strays = ( + sorted(p.name for p in wiki.rglob(".transcript_cursor")) if wiki.exists() else [] + ) + (sorted(p.name for p in wiki.rglob(".no-hooks.json")) if wiki.exists() else []) + check("위키 repo 에 상태 파일이 생기지 않는다", strays, []) + def main() -> int: test_fallback_parser() test_scope_gate() test_deny_patterns() test_publish_boundary() + test_slug() + test_state_isolation() print() if FAILURES: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 342bdaa..317627a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -34,6 +34,9 @@ jobs: - name: Knowledge-wiki boundary tests (scope gate, deny patterns, publish limits) run: python3 .github/scripts/test_knowledge_wiki.py + - name: Dangerous-command hook tests (real JSON -> shell -> grep chain) + run: bash .github/scripts/test_danger_hook.sh + - name: Prompt surface measurement (reported, non-blocking) run: | bash .github/scripts/measure.sh HEAD >> "$GITHUB_STEP_SUMMARY" || true diff --git a/plugins/wigtn-plugins/contracts/INGEST-POLICY.md b/plugins/wigtn-plugins/contracts/INGEST-POLICY.md index c86ccd7..6e01279 100644 --- a/plugins/wigtn-plugins/contracts/INGEST-POLICY.md +++ b/plugins/wigtn-plugins/contracts/INGEST-POLICY.md @@ -56,6 +56,25 @@ > D5 주의: **의사코드로 다시 쓴 것은 허용**한다. 복사가 금지지 개념이 금지가 아니다. +### 반입 금지는 두 계층이다 + +위 표는 **반출물(article) 기준**이다. 원문(transcript)에 같은 기준을 그대로 적용하지 않는다. + +| 계층 | 대상 | 적용 항목 | 위반 시 | +|---|---|---|---| +| **입력** (G1) | transcript 원문 | D1 자격증명 + 주민등록번호 | 세션 폐기 | +| **출력** (G4) | 컴파일된 article | D1~D8 전체 | 세션 폐기 | + +**원문은 기계 밖으로 나가지 않는다.** 반출되는 것은 G2가 새로 쓴 article이고, +그건 G4가 전체 패턴으로 검사한다. 그래서 "대화 중에 파일 경로를 언급했다"는 이유로 +세션을 버릴 필요가 없다 — 보호는 G4가 그대로 하고 회수만 잃는다. + +D1과 주민등록번호를 입력 계층에 남기는 이유는 다르다. 시크릿은 모델에 넣는 순간 +산출물에서 되살아날 수 있고, 시크릿이 담긴 세션은 드물어서 폐기 비용이 낮다. + +> 실측: 이 분리 전에는 긴 세션 16건 중 8건이 G1에서 폐기됐다. 사유는 전부 +> 홈 절대경로·`git@github.com`·내부 호스트명이었다. 분리 후 같은 세션에서 0건이다. + --- ## 3. 4단 게이트 — 코드가 집행하는 순서 @@ -64,8 +83,9 @@ [G0] 스코프 게이트 전역 설정 include 밖 → 종료 (아무것도 안 함) │ exclude·deny 경로 → 마커가 있어도 거부 ▼ -[G1] 결정론 차단 정규식으로 D1·D2·D4·D6 탐지 +[G1] 결정론 차단 정규식으로 **입력 계층**(D1 + 주민번호) 탐지 │ 1건이라도 hit → 세션 전체 폐기 (마스킹 X) + │ D2·D4·D6은 여기서 안 본다 (§2 반입 금지 두 계층) ▼ [G2] LLM 컴파일 transcript → 일반화된 article 초안 │ 원문 인용 금지 · 출력 스키마 강제 @@ -76,8 +96,8 @@ │ ⚠️ 통과 판정 권한 없음 — 발견 시 폐기 지시만 │ 호출 실패·파싱 실패 → 폐기 (fail-closed) ▼ -[G4] 결정론 재검사 G3 통과분에 G1 정규식 재적용 - │ LLM이 되살렸을 가능성 차단 +[G4] 결정론 재검사 G3 통과분에 **출력 계층 전체** 정규식 적용 + │ LLM이 되살렸을 가능성 차단 · 최종 통과 판정 ▼ per-user/ 에 커밋 + push ``` @@ -105,6 +125,19 @@ LLM에게 "이거 괜찮아?"를 물으면 대부분 "괜찮다"고 한다. 자동 push는 개인 영역까지다. 팀 공유 지식으로의 승격은 **사람의 명시적 행위**다. +### push는 범위가 좁을 때만 켜진다 + +`remote`가 설정돼 있어도 `include`가 홈/루트 전체면 **push를 보류한다.** +축적과 로컬 커밋은 계속하고, 로그에 사유를 남긴다. + +`remote` 한 줄을 추가하는 행위의 사용자 인지는 "팀 공유를 켰다"인데, +`include`가 홈 전체면 실제로 일어나는 일은 "이 기계의 **모든** repo 지식이 원격으로 +나가기 시작했다"이다. 한 기계에서 여러 조직 일을 하면 그게 곧 유출 경로다. + +축적까지 끄지 않는 이유는, 그러면 `remote`를 추가한 사용자가 "아무 일도 안 일어나는" +상태를 만나기 때문이다 — 이 파이프라인이 피하려는 실패 모드와 같은 모양이다. +`include`를 좁히는 순간 push가 켜진다. + --- ## 5. 스코프 판정 순서 (G0 상세) diff --git a/plugins/wigtn-plugins/hooks/hooks.json b/plugins/wigtn-plugins/hooks/hooks.json index 1f8315e..845eec0 100644 --- a/plugins/wigtn-plugins/hooks/hooks.json +++ b/plugins/wigtn-plugins/hooks/hooks.json @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "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'" + "command": "bash -c 'INPUT=$(cat); CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); PROBE=$(echo \"$CMD\" | tr -d \"\\42\\47\"); if echo \"$PROBE\" | grep -qE \"rm( +-[A-Za-z-]+){0,3} +(-\\w*[rR]\\w*f\\w*|-\\w*f\\w*[rR]\\w*|-[rR] +-f|-f +-[rR]|--recursive +--force|--force +--recursive) +(/|~|[$]HOME|[$][{]HOME[}])(/?([[:space:]]|$)|/?\\*)|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'" } ] }, diff --git a/plugins/wigtn-plugins/scripts/knowledge_wiki/accumulate.py b/plugins/wigtn-plugins/scripts/knowledge_wiki/accumulate.py index cd96ad2..cc11dba 100644 --- a/plugins/wigtn-plugins/scripts/knowledge_wiki/accumulate.py +++ b/plugins/wigtn-plugins/scripts/knowledge_wiki/accumulate.py @@ -15,6 +15,7 @@ from __future__ import annotations +import hashlib import json import sys from datetime import datetime, timezone @@ -41,6 +42,43 @@ def _log(wiki_path: Path | None, message: str) -> None: pass +def _cursor_for(transcript_path: str) -> Path | None: + """transcript 단위 커서 경로. 없으면 None (전체를 다시 읽는다). + + 커서는 "몇 번째 줄까지 읽었나"인데 *어느* transcript 의 줄인지가 같이 기록돼야 한다. + 위키 subdir 에 하나만 두면 기본 설정(모든 repo → 같은 per-user/)에서 전 프로젝트가 + 커서를 공유해 재읽기(중복 article)와 구간 누락이 난다. + """ + from knowledge_wiki import config as kw_config + + state = kw_config.state_dir() + if state is None or not transcript_path: + return None + cursors = state / "cursors" + try: + cursors.mkdir(parents=True, exist_ok=True) + except OSError: + return None + return cursors / hashlib.sha1(transcript_path.encode("utf-8")).hexdigest()[:16] + + +def _no_hooks_settings() -> Path | None: + """중첩 ``claude`` 호출에서 훅을 끄는 설정 파일. 확보 실패 시 None.""" + from knowledge_wiki import config as kw_config + + state = kw_config.state_dir() + if state is None: + return None + path = state / "no-hooks.json" + if path.is_file(): + return path + try: + path.write_text('{"disableAllHooks": true}\n', encoding="utf-8") + except OSError: + return None + return path + + def _read_transcript(path: str, cursor: Path | None) -> str: """transcript 의 마지막 구간을 읽는다. 커서가 있으면 델타만.""" if not path: @@ -124,28 +162,25 @@ def main() -> int: wiki = tenant.wiki_path # ── transcript 확보 ─────────────────────────────────────── - cursor = wiki / tenant.subdir / ".transcript_cursor" - try: - cursor.parent.mkdir(parents=True, exist_ok=True) - except OSError: - cursor = None # type: ignore[assignment] - - conversation = _read_transcript(hook_input.get("transcript_path") or "", cursor) + transcript_path = hook_input.get("transcript_path") or "" + conversation = _read_transcript(transcript_path, _cursor_for(transcript_path)) if not conversation.strip(): return 0 # ── G1 결정론 차단 (원문) ───────────────────────────────── - hits = gates.scan(conversation) + # 입력 계층만 본다. D2/D4/D6 은 원문에 있어도 반출되지 않는다 - 반출되는 건 + # G2 가 새로 쓴 article 이고 그건 G4 가 전체 패턴으로 검사한다. + hits = gates.scan_input(conversation) if hits: _log(wiki, f"G1 폐기 [{tenant.project}]: {', '.join(hits)}") return 0 - no_hooks = wiki / tenant.subdir / ".no-hooks.json" - if not no_hooks.exists(): - try: - no_hooks.write_text('{"disableAllHooks": true}\n', encoding="utf-8") - except OSError: - no_hooks = None # type: ignore[assignment] + # 중첩 claude 호출이 이 훅을 다시 밟지 않게 하는 설정. 확보 못 하면 폐기한다 - + # 여기서 fail-open 하면 자식 세션이 같은 Stop 훅을 물고 재귀한다. + no_hooks = _no_hooks_settings() + if no_hooks is None: + _log(wiki, f"폐기 [{tenant.project}]: 중첩 호출 차단 설정 확보 실패 (fail-closed)") + return 0 # ── G2 LLM 컴파일 ───────────────────────────────────────── article, reason = kw_compile.compile_article(conversation, no_hooks) @@ -167,12 +202,16 @@ def main() -> int: return 0 # ── 게시 ────────────────────────────────────────────────── - stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M") - slug = kw_compile.slugify(article) or "note" - filename = f"{stamp}-{slug}.md" + # 초까지 넣는다. 분 단위면 같은 분에 끝난 두 세션이 같은 이름을 노린다. + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + filename = f"{stamp}-{kw_compile.slugify(article)}.md" footer = f"\n\n\n" - ok, detail = publish.write_and_push(wiki, tenant.subdir, filename, article + footer) + ok, detail = publish.write_and_push( + wiki, tenant.subdir, filename, article + footer, push=tenant.push + ) + if not tenant.push and tenant.push_hold: + detail = f"{detail} — push 보류: {tenant.push_hold}" _log(wiki, ("게시" if ok else "게시 실패") + f" [{tenant.project}]: {detail}") return 0 diff --git a/plugins/wigtn-plugins/scripts/knowledge_wiki/compile.py b/plugins/wigtn-plugins/scripts/knowledge_wiki/compile.py index dc499dd..58263d0 100644 --- a/plugins/wigtn-plugins/scripts/knowledge_wiki/compile.py +++ b/plugins/wigtn-plugins/scripts/knowledge_wiki/compile.py @@ -11,6 +11,7 @@ from __future__ import annotations +import hashlib import json import re import subprocess @@ -175,11 +176,23 @@ def audit_article(article: str, no_hooks: Path | None) -> tuple[bool, str]: def slugify(article: str) -> str: - """제목에서 파일명 slug 추출. ASCII 가 없으면 호출부가 타임스탬프로 대체.""" + """제목에서 파일명 slug 추출. **항상 비어 있지 않은 값을 반환한다.** + + 컴파일 프롬프트가 한국어라 한글 전용 제목이 흔하다. ASCII 만 남기면 빈 문자열이 + 되어 파일명이 전부 같은 값으로 수렴하고, 분 단위 타임스탬프와 겹치면 서로 덮어쓴다. + 비ASCII 제목은 제목 해시로 대체한다 - 결정론이라 같은 제목이면 같은 이름이다. + """ + title = "" for line in article.splitlines(): stripped = line.strip() if stripped.startswith("# "): - slug = re.sub(r"[^a-zA-Z0-9\-]+", "-", stripped[2:].lower()).strip("-") - slug = re.sub(r"-{2,}", "-", slug) - return slug[:60] - return "" + title = stripped[2:].strip() + break + if not title: + return "note" + + slug = re.sub(r"[^a-zA-Z0-9\-]+", "-", title.lower()) + slug = re.sub(r"-{2,}", "-", slug).strip("-")[:60].strip("-") + if slug: + return slug + return "note-" + hashlib.sha1(title.encode("utf-8")).hexdigest()[:8] diff --git a/plugins/wigtn-plugins/scripts/knowledge_wiki/config.py b/plugins/wigtn-plugins/scripts/knowledge_wiki/config.py index 59f12c9..14ff905 100644 --- a/plugins/wigtn-plugins/scripts/knowledge_wiki/config.py +++ b/plugins/wigtn-plugins/scripts/knowledge_wiki/config.py @@ -114,6 +114,25 @@ def load() -> tuple[dict[str, Any], Path | None]: DEFAULT_HOME = Path.home() / ".wigtn" DEFAULT_WIKI = DEFAULT_HOME / "wiki" _NOTICE_FLAG = DEFAULT_HOME / ".notice-shown" +_STATE_DIR = DEFAULT_HOME / "state" + + +def state_dir() -> Path | None: + """머신 로컬 상태 디렉터리. **위키 repo 밖이다.** + + 커서와 중첩 호출 차단 설정은 위키 *콘텐츠* 가 아니라 이 기계의 상태다. + 위키 안에 두면 clone 한 팀 repo 워킹카피가 지저분해지고(팀 repo 에 .gitignore 를 + 써 넣는 건 요청하지 않은 공유 저장소 변경이다), 커서가 위키 경로에 묶여 + 프로젝트끼리 섞인다. + + Returns: + 디렉터리 경로 / 만들 수 없으면 None (호출부가 fail-closed 처리) + """ + try: + _STATE_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + return None + return _STATE_DIR _DEFAULT_TEMPLATE = """# wigtn knowledge-wiki — 자동 생성됨 # @@ -128,7 +147,8 @@ def load() -> tuple[dict[str, Any], Path | None]: # remote: git@github.com:myteam/team-wiki.git # 지식을 쌓을 경로. 기본 = 홈 전체 (로컬 전용이라 허용). -# remote 를 채워 push 를 켤 때는 이 범위를 좁히는 것을 권한다. +# 범위가 홈/루트 전체인 동안에는 remote 가 있어도 push 하지 않는다 (로컬 축적만). +# 좁히면 그때 push 가 켜진다 - 예: - {home}/Dev include: - {home} @@ -278,6 +298,35 @@ def _is_under(child: Path, parent: Path) -> bool: return True +def remote_of(conf: dict[str, Any]) -> str: + """설정에서 ``wiki.remote`` 추출. 없으면 빈 문자열.""" + wiki = conf.get("wiki") + wiki_conf: dict[str, Any] = wiki if isinstance(wiki, dict) else {} + return str(wiki_conf.get("remote") or "").strip() + + +def broad_scope_reason(conf: dict[str, Any]) -> str: + """``include`` 가 push 를 켜기엔 너무 넓으면 사유를, 아니면 빈 문자열. + + 로컬 축적과 팀 공유는 위험도가 다르다. ``remote`` 한 줄을 추가하는 행위의 + 사용자 인지는 "팀 공유를 켰다"인데, ``include`` 가 홈 전체면 실제로 일어나는 일은 + "이 기계의 **모든** repo 지식이 원격으로 나가기 시작했다"이다. 한 기계에서 여러 + 조직 일을 하면 그게 곧 유출 경로다. + + 막는 방식은 **push 보류**지 축적 중단이 아니다. 축적까지 끄면 remote 를 추가한 + 사용자가 "아무 일도 안 일어나는" 상태를 만나게 된다 - 이 파이프라인이 피하려는 + 실패 모드와 같은 모양이다. + """ + try: + home = Path.home().resolve() + except (OSError, RuntimeError): + return "" + for included in _as_paths(conf.get("include")): + if included == home or included == Path(included.anchor): + return f"include 범위가 홈/루트 전체 ({included}) - 좁히면 push 가 켜진다" + return "" + + def scope_verdict(conf: dict[str, Any], repo_root: Path) -> tuple[bool, str]: """전역 설정의 include/exclude 로 이 repo 가 대상인지 판정한다. diff --git a/plugins/wigtn-plugins/scripts/knowledge_wiki/gates.py b/plugins/wigtn-plugins/scripts/knowledge_wiki/gates.py index 51a1e2b..19c9f78 100644 --- a/plugins/wigtn-plugins/scripts/knowledge_wiki/gates.py +++ b/plugins/wigtn-plugins/scripts/knowledge_wiki/gates.py @@ -33,6 +33,8 @@ class Tenant: wiki_path: Path # 위키 repo 로컬 경로 subdir: str # per-user/ 처럼 auto-push 허용 영역 project: str # article 분류용 라벨 + push: bool = True # False = 로컬 커밋까지만 + push_hold: str = "" # push 를 보류한 사유 (로그용) # ───────────────────────────────────────────────────────────── @@ -40,7 +42,18 @@ class Tenant: # ───────────────────────────────────────────────────────────── # D3(고객 식별)·D5(코드 원문)·D7(사업 정보)·D8(NDA)은 정규식으로 못 잡는다. # 그건 G2 의 "일반화 강제" 구조와 G3 의 의미 감사가 담당한다. -DENY_PATTERNS: list[tuple[str, str]] = [ +# +# **패턴은 두 계층이다.** G1 은 원문을, G4 는 반출될 article 을 본다. 원문은 +# 기계 밖으로 나가지 않으므로 두 계층의 기준이 같을 이유가 없다. +# +# 입력 계층(G1) — 모델에 넣는 것 자체가 위험한 것만. 발견 시 세션 폐기. +# 출력 계층(G4) — 반출물 기준. 입력 계층 + 문맥에 흔한 식별자들. +# +# 전부 G1 에 걸면 "파일 경로를 언급했다"는 이유로 세션을 버리게 되고, +# 보호는 그대로인데(G4 가 어차피 막는다) 회수만 잃는다. + +# 시크릿은 모델에 넣는 순간 되살아날 수 있고, 그런 세션은 드물어 폐기 비용이 낮다. +_INPUT_PATTERNS: list[tuple[str, str]] = [ # D1 — 자격증명 (r"(?:api[_-]?key|apikey|access[_-]?token|secret[_-]?key)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{16,}", "D1 API 키"), (r"AKIA[0-9A-Z]{16}", "D1 AWS 액세스 키"), @@ -50,13 +63,29 @@ class Tenant: (r"Bearer\s+[A-Za-z0-9_\-\.]{20,}", "D1 Bearer 토큰"), (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 이메일"), + # D2 — 되돌릴 수 없는 개인 식별자 (r"\b\d{6}\s*-\s*[1-4]\d{6}\b", "D2 주민등록번호"), +] + +# article 에만 적용한다. 원문에 있다고 세션을 버리지 않는다. +_OUTPUT_ONLY_PATTERNS: list[tuple[str, str]] = [ + # D2 — 연락처. git@·noreply@ 같은 서비스 계정은 개인정보가 아니다 + # (SSH remote 를 언급한 모든 세션이 죽던 원인). + ( + r"(? list[str]: - """금지 패턴 탐지. 위반 라벨 목록을 반환한다 (빈 리스트 = 깨끗). +def _compile(patterns: list[tuple[str, str]]) -> list[tuple[re.Pattern[str], str]]: + return [(re.compile(p, re.IGNORECASE), label) for p, label in patterns] - G1(원문)과 G4(LLM 산출물) 양쪽에서 같은 함수를 쓴다 - 단일 정본. - """ + +_COMPILED_INPUT = _compile(_INPUT_PATTERNS) +_COMPILED_ALL = _compile(DENY_PATTERNS) + + +def _hits(text: str, compiled: list[tuple[re.Pattern[str], str]]) -> list[str]: if not text: return [] - hits: list[str] = [] - for pattern, label in _COMPILED: - if pattern.search(text): - hits.append(label) + found = [label for pattern, label in compiled if pattern.search(text)] # 중복 라벨 제거 (순서 보존) - return list(dict.fromkeys(hits)) + return list(dict.fromkeys(found)) + + +def scan(text: str) -> list[str]: + """G4 — 반출될 article 검사. 위반 라벨 목록 (빈 리스트 = 깨끗). + + 최종 통과 판정은 항상 여기가 내린다. LLM 출력을 신뢰하지 않는다. + """ + return _hits(text, _COMPILED_ALL) + + +def scan_input(text: str) -> list[str]: + """G1 — 원문 검사. 모델에 넣는 것 자체가 위험한 항목만 본다. + + 여기서 통과해도 반출이 승인된 게 아니다. 반출 판정은 ``scan`` (G4) 이 한다. + """ + return _hits(text, _COMPILED_INPUT) # ───────────────────────────────────────────────────────────── @@ -217,5 +262,22 @@ def resolve_tenant(cwd: str) -> tuple[Tenant | None, str]: if wiki_resolved == root_resolved or config._is_under(wiki_resolved, root_resolved): return None, f"위키가 작업 repo 안에 있음 (자기 오염): {wiki_path}" + # push 경계. 마커가 있으면 그 repo 를 명시적으로 지목한 것이므로 범위가 넓지 않다 - + # include 로 통째로 쓸어담는 경우에만 보류한다. remote 가 없으면 어차피 로컬이라 무의미. + push, hold = True, "" + if marker is None and config.remote_of(source_conf): + hold = config.broad_scope_reason(global_conf) + push = not hold + project = str(marker_conf.get("project") or global_conf.get("project") or repo_root.name) - return Tenant(repo_root=repo_root, wiki_path=wiki_path, subdir=normalized, project=project), "" + return ( + Tenant( + repo_root=repo_root, + wiki_path=wiki_path, + subdir=normalized, + project=project, + push=push, + push_hold=hold, + ), + "", + ) diff --git a/plugins/wigtn-plugins/scripts/knowledge_wiki/publish.py b/plugins/wigtn-plugins/scripts/knowledge_wiki/publish.py index ec4159b..8c9a767 100644 --- a/plugins/wigtn-plugins/scripts/knowledge_wiki/publish.py +++ b/plugins/wigtn-plugins/scripts/knowledge_wiki/publish.py @@ -31,6 +31,21 @@ def _git(args: list[str], cwd: Path) -> tuple[int, str]: return result.returncode, (result.stdout + result.stderr).strip() +def _free_path(target: Path) -> Path | None: + """이미 있으면 접미사를 붙여 비어 있는 경로를 찾는다. + + 덮어쓰기는 하지 않는다 - 잃어버린 쪽은 로그에도 "게시 성공"으로 남아서 + 사라진 걸 알 방법이 없다. + """ + if not target.exists(): + return target + for n in range(2, 100): + candidate = target.with_name(f"{target.stem}-{n}{target.suffix}") + if not candidate.exists(): + return candidate + return None + + def write_and_push( wiki_path: Path, subdir: str, @@ -52,12 +67,14 @@ def write_and_push( target_dir = wiki_path / normalized try: target_dir.mkdir(parents=True, exist_ok=True) - target = target_dir / filename + target = _free_path(target_dir / filename) + if target is None: + return False, f"파일명 충돌 회피 실패: {filename}" target.write_text(content, encoding="utf-8") except OSError as exc: return False, f"파일 쓰기 실패: {exc}" - rel = f"{normalized}/{filename}" + rel = f"{normalized}/{target.name}" code, out = _git(["rev-parse", "--git-dir"], wiki_path) if code != 0: @@ -74,7 +91,7 @@ def write_and_push( stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d") code, out = _git( - ["commit", "-m", f"docs(wiki): {filename[:-3]} ({stamp})", "--", rel], + ["commit", "-m", f"docs(wiki): {target.stem} ({stamp})", "--", rel], wiki_path, ) if code != 0: diff --git a/plugins/wigtn-plugins/skills/knowledge-wiki/SKILL.md b/plugins/wigtn-plugins/skills/knowledge-wiki/SKILL.md index bbe96cf..15c831e 100644 --- a/plugins/wigtn-plugins/skills/knowledge-wiki/SKILL.md +++ b/plugins/wigtn-plugins/skills/knowledge-wiki/SKILL.md @@ -52,7 +52,9 @@ exclude: # 고객사·NDA repo. include 와 repo 마커보다 강하 - ~/Dev/client-nda ``` -**push 를 켤 때는 `include` 를 좁히는 것을 권합니다.** 로컬 축적과 팀 공유는 위험도가 다릅니다. +**`remote` 가 있어도 `include` 가 홈/루트 전체면 push 는 보류됩니다.** +축적과 로컬 커밋은 계속되고, 로그에 사유가 남습니다. `include` 를 좁히면 그때 push 가 켜집니다. +로컬 축적과 팀 공유는 위험도가 다르고, 그 경계는 권고가 아니라 코드가 집행합니다. `subdir` 은 `per-user/` 또는 `ouroboros/` 아래만 허용됩니다. `shared/` 는 사람이 PR 로 승격합니다. @@ -78,10 +80,10 @@ exclude 된 경로, 그리고 전역으로 꺼 둔 상태는 마커가 있어도 ``` G0 스코프 게이트 include 밖·exclude·자기오염이면 즉시 종료 -G1 결정론 차단 키·PII·내부 IP·절대경로 정규식 → 1건이라도 hit면 세션 폐기 +G1 결정론 차단 원문에 키·자격증명·주민번호 → 1건이라도 hit면 세션 폐기 G2 LLM 컴파일 transcript → 일반화 article (원문 인용 금지) G3 LLM 감사 별도 호출로 반출 위반 탐지 (통과 판정 권한 없음) -G4 결정론 재검사 G3 출력에 G1 재적용 (LLM을 신뢰하지 않는다) +G4 결정론 재검사 article에 전체 패턴 적용 — 이메일·사설 IP·절대경로까지 ↓ per-user/ 커밋 + push ``` @@ -121,6 +123,7 @@ tail -20 /.knowledge-wiki.log | `G2 폐기: 기록 가치 없음` | 단순 작업 세션 | 정상 동작 | | `G3 폐기: 위반 탐지 D3` | 고객 식별 정보 등이 남아 있었다 | 정상 동작 | | `G4 폐기` | LLM이 금지 항목을 되살렸다 | 정상 동작 | +| `게시 … push 보류: include 범위가 홈/루트 전체` | 로컬에는 쌓였고 push만 안 했다 | `include` 를 좁히면 켜진다 | | `게시 실패` | git 문제 | 위키 repo 권한·remote 확인 | **폐기 로그가 많은 건 문제가 아니다.** 게이트가 일하고 있다는 뜻이다.