#!/usr/bin/env python3 """PreToolUse guard — SECONDARY defense-in-depth check (NOT the primary boundary). 주의(정직성): 이 hook은 allow-by-default regex 기반의 2차 방어선일 뿐이다. 진짜 경계 (PRIMARY boundary)는 Claude Code 네이티브 permission 시스템(.claude/settings.json 의 `permissions.deny/ask/allow`)과 managed policy다. regex denylist는 원리상 우회 가능하므로 (셸 조합·인용·변형) 보안/품질 경계로 신뢰해서는 안 된다. 이 파일은 매트릭스가 default-deny 하는 외부 side-effect(원격 push/PR, 배포, secret 읽기, DB 쓰기, Slack)와 파괴적 명령, 그리고 보고서 불변성(.report.yaml overwrite)을 **추가로** 막는 심층방어(defense-in-depth) 레이어다. finding #11(deep): **어떤 side-effect 카테고리를 막을지는 하드코딩이 아니라 tool-permission-matrix.yaml (default-policy.external-side-effects) SoT에서 읽는다**(DENIED_CATEGORIES). 매트릭스를 고치면 guard 동작이 바뀐다(예: slack을 approval-required로 바꾸면 guard가 하드블록하지 않고 네이티브 ask/permission에 위임). 매트릭스 부재/파싱실패면 fail-safe로 전부 denied. 단 git-push·rm-rf·보고서 불변성은 카테고리 토글 밖 — 구조·안전 규칙이라 매트릭스와 무관하게 항상 강제한다. P1-E 하드닝(finding #11): 확인된 우회들을 닫는다 — - `git -C . push` 등 플래그 변형 push를 토큰 파싱으로 탐지(고정 `git push` 정규식이 아님). - `.env`/secret 읽기를 cat/less/head/tail/grep/cp/scp 고정목록이 아니라 명령 전체에서 탐지 (python/node/ruby/env/xargs/redirection 경유 포함). - Bash redirection/tee/dd 로 기존 `completion-records/**/*.report.yaml` 을 덮어쓰는 우회 차단. - (신규) 언어레벨 write(python open('w')·write_text·node writeFile·shutil.copy/move·os.replace/rename) 로 기존 report 를 덮어쓰는 우회도 차단(_lang_write_to_report). - (신규) Read/Grep/Glob 이 `.env`/자격증명 경로를 명시적으로 타깃하면 2차 차단(네이티브 Read deny 가 1차, Grep/Glob 은 네이티브 커버가 약해 여기서 보조). settings.json PreToolUse matcher 에 Read|Grep|Glob 추가. - NotebookEdit 의 `notebook_path` 에도 불변-보고서 검사 적용(예전엔 file_path만 봄). - 파싱 불가/malformed hook JSON 은 fail-closed(exit 2) — 보안 가드는 fail-open 하지 않는다. 주의: 정규식 denylist 는 원리상 우회 가능하다(이 파일 상단 참조). 위 신규 차단도 2차 심층방어일 뿐 1차 경계가 아니다 — 1차는 settings.json permissions.deny/ask(secret Read·rm·push·deploy) 이다. Input: Claude Code PreToolUse JSON on stdin: {"tool_name": "...", "tool_input": {...}} exit 0 = allow, exit 2 = block(사유는 stderr). """ import json import os import re import shlex import sys # 보고서 불변성: completion-records의 .report.yaml은 한 번 생성되면 덮어쓰기/수정 금지. # 새 결과는 new_report.py로 새 버전 파일을 만든다(감사 추적 보존). IMMUTABLE_RE = re.compile(r"completion-records/.*\.report\.yaml$") # finding #11(deep): 어떤 side-effect 카테고리를 default-deny 하는지는 하드코딩이 아니라 # tool-permission-matrix.yaml(default-policy.external-side-effects) SoT에서 읽는다. # 매트릭스를 고치면 guard 동작이 바뀐다. 매트릭스 부재/파싱실패면 **fail-safe: 전부 denied**로 본다 # (가드가 조용히 느슨해지지 않게). git-push·rm-rf·보고서불변성은 카테고리 토글 밖(항상 강제). _ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) _MATRIX = os.path.join(_ROOT, "org-os", "00-role-registry", "tool-permission-matrix.yaml") _ALL_SIDE_EFFECTS = {"slack", "github-pr-create", "deploy", "secret-read", "db-write"} def _denied_categories(): """매트릭스 default-policy가 'denied'로 둔 side-effect 카테고리 집합. 실패 시 전부 denied(fail-safe).""" try: import yaml # noqa: E402 se = (((yaml.safe_load(open(_MATRIX, encoding="utf-8")) or {}) .get("tool-permission-matrix") or {}).get("default-policy") or {} ).get("external-side-effects") or {} denied = {k for k, v in se.items() if str(v).strip() == "denied"} return denied or set(_ALL_SIDE_EFFECTS) except Exception: return set(_ALL_SIDE_EFFECTS) DENIED_CATEGORIES = _denied_categories() def _category_active(cat): """이 카테고리를 지금 차단해야 하나? side-effect 카테고리는 매트릭스 default-deny일 때만. destructive/git-push/immutable-report 등 구조·안전 카테고리는 매트릭스와 무관하게 항상 강제.""" if cat in _ALL_SIDE_EFFECTS: return cat in DENIED_CATEGORIES return True # git 글로벌 플래그 중 별도 인자를 소비하는 것(다음 토큰까지 건너뛰어야 subcommand를 찾는다). GIT_FLAGS_TAKING_ARG = { "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--super-prefix", "--config-env", } def _exists(path): root = os.environ.get("CLAUDE_PROJECT_DIR", "") p = path if os.path.isabs(path) else os.path.join(root, path) return os.path.exists(p) # (regex, category, reason) — matched against a Bash command string (IGNORECASE). # NOTE: git push 는 아래 _git_subcommands() 토큰 파서로 별도 처리(플래그 변형 우회 방지). BASH_DENY = [ (r"\bgh\s+pr\s+create\b", "github-pr-create", "PR 생성은 기본 금지(tool-permission-matrix). 승인 필요."), (r"\b(kubectl|terraform\s+apply|serverless\s+deploy|docker\s+push|helm\s+upgrade)\b", "deploy", "배포는 기본 금지. 승인 필요."), # secret(.env): 특정 read 명령에 국한하지 않고 명령 전체에서 .env 파일 참조를 탐지한다 # (python/node/ruby/env/xargs/redirection 경유 우회 차단). .env.example/.sample/.template/.dist 는 제외. (r"\.env\b(?!\.(?:example|sample|template|dist)\b)", "secret-read", "secret(.env) 접근은 기본 금지(cat/python/node/redirection 등 모든 경로)."), (r"(id_rsa|\.aws/credentials|\.ssh/|secrets?/|/etc/shadow)", "secret-read", "자격증명/secret 접근은 기본 금지."), (r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f\b|\brm\s+-[a-zA-Z]*f[a-zA-Z]*r\b", "destructive", "rm -rf 파괴적 명령 차단."), (r"(slack\.com/api|hooks\.slack\.com|curl[^\n]*slack)", "slack", "Slack 전송은 기본 금지(알림 채널은 hook 경유)."), (r"\b(psql|mysql|mongo)\b[^\n]*(INSERT|UPDATE|DELETE|DROP|TRUNCATE)", "db-write", "DB 쓰기는 기본 금지."), ] # file paths that must not be written/edited FILE_DENY = [ (r"(^|/)\.env(\.|$)", "secret-read", "secret 파일 쓰기 금지."), (r"(id_rsa|\.aws/credentials|\.ssh/)", "secret-read", "자격증명 파일 쓰기 금지."), ] # ---------------------------------------------------------------- spawn gate (P0-2) # Org OS 워커/패밀리 spawn 은 유효한 context-package 없이 시작 금지(CLAUDE.md 불변식). # helper/built-in 서브에이전트는 면제(읽기전용 탐색·계획 등). 판별: 생성된 에이전트 카드 # (.claude/agents/.md)가 있고 helper 목록에 없으면 Org OS 워커다. HELPER_AGENT_TYPES = { "explore", "plan", "general-purpose", "claude", "claude-code-guide", "statusline-setup", "code-simplifier", "output-style-setup", "fork", } _PKG_REF_RE = re.compile(r"context-package(?:-path)?:\s*([^\s`'\"]+)", re.IGNORECASE) _PKG_SHA_RE = re.compile(r"context-package-sha256:\s*([0-9a-fA-F]{64})", re.IGNORECASE) def _is_orgos_worker(agent_type): at = str(agent_type or "").strip().lower() if not at or at in HELPER_AGENT_TYPES: return False return os.path.exists(os.path.join(_ROOT, ".claude", "agents", at + ".md")) def _check_spawn(tool_input, hook_payload=None): """finding P0-2: Org OS 워커 spawn 은 context-package 참조(경로+sha256) 없이는 금지. 참조가 있으면 파일 실존·해시 일치·validate 통과를 강제한다(위장/미검증/swap 패키지 차단). helper(explore/general-purpose/plan 등)는 면제(None,None 반환).""" agent_type = (tool_input.get("subagent_type") or tool_input.get("subagentType") or tool_input.get("agent_type") or "") if not _is_orgos_worker(agent_type): return None, None prompt = str(tool_input.get("prompt") or "") mref = _PKG_REF_RE.search(prompt) msha = _PKG_SHA_RE.search(prompt) if not mref or not msha: return ("context-package-required", f"Org OS 워커 '{agent_type}' spawn 은 context-package 참조가 필수다(불변식). " "`context_package.py --compile` 로 발급→placeholder 채움→`context_package.py ` 검증 후, " "출력된 `context-package:`/`context-package-sha256:` 2줄을 spawn 프롬프트에 포함하라.") rel = mref.group(1).strip().strip("`'\"") pkg_path = rel if os.path.isabs(rel) else os.path.join(_ROOT, rel) if not os.path.exists(pkg_path): return ("context-package-required", f"context-package 참조 경로가 실존하지 않는다: {rel} (발급된 .pkg.yaml 을 가리켜야 함).") try: sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import context_package as cp import yaml as _yaml except Exception as e: # 검증기 로드 불가 -> fail-closed(미검증 spawn 허용 안 함) return ("context-package-required", f"context-package 검증기 로드 실패로 spawn 을 허용하지 않는다(fail-closed): {e}") actual = cp.sha256_file(pkg_path) if actual != msha.group(1).strip().lower(): return ("context-package-required", f"context-package-sha256 불일치 — 검증 후 패키지가 바뀌었다(swap 차단). " f"기대 {msha.group(1)[:12]}… / 실제 {str(actual)[:12]}…") try: with open(pkg_path, encoding="utf-8") as f: pkg = _yaml.safe_load(f) except Exception as e: return ("context-package-required", f"context-package 파싱 실패: {e}") violations = cp.validate(pkg) if violations: return ("context-package-required", "context-package 가 유효하지 않다 — spawn 금지:\n" + "\n".join(f" - {v}" for v in violations[:8])) package_role = str(pkg.get("target-role-agent") or "").strip().lower() if package_role != str(agent_type).strip().lower(): return ("context-package-required", f"spawn agent_type({agent_type})와 context-package target-role-agent({package_role})가 다르다.") # Native SubagentStart may omit the prompt. Bridge this exact validated package through # an append-only pending binding so per-task policy remains enforceable inside the worker. try: import spawn_bindings payload = hook_payload if isinstance(hook_payload, dict) else {} session_id = payload.get("session_id") or payload.get("sessionId") spawn_bindings.record_pending(str(agent_type), rel, actual, session_id=session_id) except Exception: pass return None, None def _registry_record(agent_id): """Latest registered concrete subagent identity, if the hook payload exposes agent_id.""" if not agent_id: return None try: sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _workspace as W path = os.path.join(W.state_dir(), "subagent-registry.jsonl") found = None for line in open(path, encoding="utf-8"): try: row = json.loads(line) except Exception: continue if row.get("agent_id") == agent_id: found = row return found except Exception: return None def _load_bound_package(record): if not isinstance(record, dict) or not record.get("context_package"): return None, "report-producing worker has no bound context package" try: import yaml import context_package as cp rel = str(record["context_package"]) path = rel if os.path.isabs(rel) else os.path.join(_ROOT, rel) actual = cp.sha256_file(path) if not actual or actual != record.get("context_package_sha256"): return None, "bound context-package hash mismatch" pkg = yaml.safe_load(open(path, encoding="utf-8")) or {} errors = cp.validate(pkg) if errors: return None, "bound context-package no longer validates" role = str(pkg.get("target-role-agent") or "").lower() if record.get("agent_type") and role != str(record.get("agent_type")).lower(): return None, "bound context-package role does not match active agent" return pkg, None except Exception as exc: return None, f"bound context-package unavailable: {exc}" def _within(path, roots): try: resolved = os.path.realpath(path if os.path.isabs(path) else os.path.join(_ROOT, path)) return any(os.path.commonpath([resolved, root]) == root for root in roots) except Exception: return False def _task_policy_check(tool_name, tool_input, hook_payload): """Enforce the active context package's task tool/path boundary. Main-session calls usually have no agent_id and are unaffected. Org OS subagents are bound at spawn and fail closed if that exact package cannot be recovered. """ payload = hook_payload if isinstance(hook_payload, dict) else {} agent_id = payload.get("agent_id") or payload.get("agentId") or payload.get("subagent_id") record = _registry_record(agent_id) if not record: return None, None if not record.get("report_producing"): return None, None pkg, error = _load_bound_package(record) if error: return "task-policy", error allowed_tools = {str(value) for value in pkg.get("allowed-tools", []) or []} if tool_name not in allowed_tools: return ("task-tool-allowlist", f"{tool_name}은 active context-package allowed-tools에 없다: {sorted(allowed_tools)}") if tool_name in ("Write", "Edit", "NotebookEdit"): raw_roots = list(pkg.get("allowed-paths", []) or []) target = pkg.get("target-repo") if target and (os.path.isabs(str(target)) or os.path.exists(os.path.join(_ROOT, str(target)))): raw_roots.append(str(target)) try: import _workspace as W raw_roots.append(W.records_dir()) except Exception: pass roots = [os.path.realpath(value if os.path.isabs(value) else os.path.join(_ROOT, value)) for value in raw_roots] path = _path_for(tool_name, tool_input) if not roots or not _within(path, roots): return ("task-path-allowlist", f"write path {path!r} is outside active context-package allowed-paths") return None, None def _git_subcommands(cmd): """Bash 명령에서 각 `git` 호출의 subcommand를 뽑는다(글로벌 플래그 -C/-c/--git-dir 등은 건너뜀). `git push`, `git -C . push`, `git -c user.name=x push`, `git --git-dir=/r push`, `... && git push` 를 모두 push 로 인식한다. `git commit -m "push"` 는 subcommand=commit 이므로 오탐하지 않는다. 인용 불균형 등으로 tokenize 실패 시엔 coarse 폴백(git+push 동시 존재).""" try: tokens = shlex.split(cmd, posix=True) except ValueError: if re.search(r"\bgit\b", cmd) and re.search(r"\bpush\b", cmd): return ["push"] return [] subs = [] i, n = 0, len(tokens) while i < n: base = tokens[i].rsplit("/", 1)[-1] # /usr/bin/git -> git if base == "git": j = i + 1 while j < n: tj = tokens[j] if tj.startswith("-"): if "=" in tj: # --opt=val (자기완결) j += 1 elif tj in GIT_FLAGS_TAKING_ARG: # 별도 인자 소비 j += 2 else: # 단독 플래그 j += 1 continue subs.append(tj) # 첫 non-flag 토큰 = subcommand break i = j + 1 else: i += 1 return subs def _bash_write_targets(cmd): """Bash 명령이 '쓰는' 파일 경로 후보를 뽑는다: `> f`, `>> f`, `tee [flags] f`, `dd of=f`. 보고서 불변성 우회(redirection으로 기존 .report.yaml overwrite) 탐지에 쓴다.""" targets = [] # redirection: > file, >> file, 1>/2>/&> file (입력 <, 2>&1 같은 fd 복제는 제외) for m in re.finditer(r"(?:\d*|&)>>?\s*([^\s;|&<>]+)", cmd): targets.append(m.group(1)) # tee [flags...] file... for m in re.finditer(r"\btee\b((?:\s+-\S+)*)((?:\s+[^\s;|&<>]+)+)", cmd): for tok in m.group(2).split(): targets.append(tok) # dd ... of=file for m in re.finditer(r"\bdd\b[^\n;|&]*?\bof=([^\s;|&<>]+)", cmd): targets.append(m.group(1)) return [t.strip("'\"") for t in targets if t] def _path_for(tool_name, tool_input): # NotebookEdit 는 notebook_path 를 쓴다(예전 코드가 file_path만 봐서 우회됐음). if tool_name == "NotebookEdit": return str(tool_input.get("notebook_path") or tool_input.get("file_path") or "") return str(tool_input.get("file_path", "")) # 언어레벨 write 우회(finding #11): Bash 안에서 python/node 등으로 기존 .report.yaml 을 쓰는 경우. # redirection/tee/dd(_bash_write_targets) 외에 open(...,'w'/'a')·write_text·writeFile·shutil.copy/move· # os.replace/rename 로 report 경로를 대상으로 하는 write 를 2차로 탐지한다(정규식이라 우회 가능 — 심층방어). _REPORT_TOKEN_RE = re.compile(r"['\"]?([^\s'\"()]*completion-records/[^\s'\"()]*\.report\.yaml)['\"]?") _WRITE_IDIOM_RE = re.compile( r"open\s*\([^)]*\.report\.yaml[^)]*,[^)]*['\"][wax+]|" # open('...report.yaml', 'w'/'a'/'x'/'+') r"\.write_text\s*\(|" # pathlib Path.write_text( r"writeFileSync?\s*\(|" # node fs.writeFile(Sync)( r"shutil\.(?:copy\w*|move)\s*\(|os\.(?:replace|rename)\s*\(", # shutil.copy/move, os.replace/rename re.IGNORECASE) def _lang_write_to_report(cmd): """Bash 명령이 언어레벨 write 로 기존 report 를 덮어쓰려 하면 그 경로를 반환(없으면 None).""" if not _WRITE_IDIOM_RE.search(cmd): return None for m in _REPORT_TOKEN_RE.finditer(cmd): tgt = m.group(1) if IMMUTABLE_RE.search(tgt.replace("\\", "/")) and _exists(tgt): return tgt return None # ---------------------------------------------------------------- ledger trust boundary # finding P0-4: 상태·수락·증거·레지스트리 원장은 신뢰 경계(trust boundary)다. 에이전트가 # 이 파일들을 직접 Write/Edit 하거나 Bash redirection/tee/dd/python-c 로 위조·덮어쓸 수 없다. # 정상 기록 경로는 둘뿐이다: (a) Claude Code 가 자동 호출하는 PostToolUse 훅(evidence_ledger — # 실제 실행 컨텍스트를 Claude Code 가 공급하므로 위조 불가), (b) 선행조건을 스스로 검증하는 # 전이/수락 CLI(state_engine transition·acceptance_log append; P0-4b/4c). 이 가드는 그 두 경로 # 밖의 모든 원장 쓰기를 막는다. (regex 2차 방어 — 원리상 우회 가능하나 바를 크게 올린다.) _LEDGER_BASENAMES = { "state-events.jsonl", "workflow-events.jsonl", "artifact-events.jsonl", "acceptance-events.jsonl", "subagent-registry.jsonl", "token-ledger.jsonl", "human-signoff.jsonl", # P0-4: 사람 승인 원장 — 에이전트가 쓰면 human-gate 위조 "spawn-bindings.jsonl", "usage-events.jsonl", } def _is_ledger_target(path): """path 가 보호 대상 원장 파일인가. workflow.yaml/ledger.jsonl 은 흔한 이름이라 각각 state/·evidence/ 세그먼트를 요구해 오탐을 줄인다.""" p = str(path).replace("\\", "/").strip().strip("'\"") base = p.rsplit("/", 1)[-1] if base in _LEDGER_BASENAMES: return True if base == "ledger.jsonl" and "evidence/" in p: return True if base == "workflow.yaml" and "state/" in p: return True return False def _is_protected_sot(path): """공식 company-context SoT — commit_company_context.py(candidate→원자 교체)로만 갱신(P1 §9.5). 경로 접미사로 판정(basename 매칭 아님 — 테스트/후보 임시파일 오탐 방지).""" p = str(path).replace("\\", "/").strip().strip("'\"") return p.endswith("org-os/01-company/company-context.yaml") def _is_activation_registry(path): """Contract v2 활성화 레지스트리(P3-B §14) — trusted CLI activate_method_contract.py 로만 write. 에이전트가 직접 쓰면 HUMAN 게이트(golden+signoff)를 우회해 계약을 self-activate 하게 되므로 차단. 경로 접미사로 판정(임시파일 .tmp 는 CLI 내부 os.replace 대상이라 미차단).""" p = str(path).replace("\\", "/").strip().strip("'\"") return p.endswith("org-os/00-role-registry/method-contract-activations.yaml") # 원장 위조용 언어레벨 write 관용구(report 전용 _WRITE_IDIOM_RE 와 달리 일반 open(...,'a') 포함). _LEDGER_WRITE_IDIOM_RE = re.compile( r"open\s*\([^)]*['\"][wax+]|" # open(..., 'w'/'a'/'x'/'+') r"\.write_text\s*\(|" # pathlib write_text r"(?:append|write)FileSync?\s*\(|" # node fs.appendFile/writeFile r"shutil\.(?:copy\w*|move)\s*\(|os\.(?:replace|rename)\s*\(", # (shell >> handled by _bash_write_targets) re.IGNORECASE) _LEDGER_PATH_TOKEN_RE = re.compile(r"['\"]?([^\s'\"()]+(?:\.jsonl|workflow\.yaml))['\"]?") # evidence_ledger.py 는 PostToolUse 훅 전용 — 에이전트가 **수동 실행**해 위조 receipt 를 밀어넣지 # 못하게 막는다. 단순 언급(py_compile/git add/cat/grep 의 인자)은 막지 않고, 실제 '실행'만 잡는다: # (1) python (path/)evidence_ledger.py (2) 명령 세그먼트 시작의 (path/)evidence_ledger.py 실행 _EVIDENCE_SCRIPT_RE = re.compile( r"python[0-9.]*\s+(?:[^\s'\"|&;]*/)?evidence_ledger\.py\b" r"|(?:^|[|&;]\s*)(?:[^\s'\"|&;]*/)?evidence_ledger\.py\b", re.IGNORECASE) # state_engine.py signoff 는 사람 승인(human-gate) 전용 — 에이전트가 호출해 human-gate 를 # 위조하지 못하게 막는다(P0-4 soft-boundary). 사람은 세션 밖 자기 셸에서 호출한다. _SIGNOFF_CLI_RE = re.compile( r"state_engine\.py\s+(?:signoff|record-human-signoff)\b", re.IGNORECASE) _HUMAN_REVIEW_CLI_RE = re.compile( r"state_engine\.py\s+review-artifact\b[^\n;&|]*--reviewer\s+HUMAN-[A-Za-z0-9_-]+" r"|acceptance_log\.py\s+append\b[^\n;&|]*--reviewer\s+HUMAN-[A-Za-z0-9_-]+" r"|state_engine\.py\s+record-release-decision\b[^\n;&|]*--actor\s+HUMAN-[A-Za-z0-9_-]+", re.IGNORECASE, ) _ACCEPTANCE_INTERNAL_RE = re.compile( r"\b(?:acceptance_log|AL)\.(?:append_event|build_event)\b", re.IGNORECASE) def _lang_write_to_ledger(cmd): """Bash 명령이 언어레벨 write 로 원장을 위조/덮어쓰려 하면 그 경로 반환(없으면 None).""" if not _LEDGER_WRITE_IDIOM_RE.search(cmd): return None for m in _LEDGER_PATH_TOKEN_RE.finditer(cmd): if _is_ledger_target(m.group(1)): return m.group(1) return None # activation 레지스트리를 python -c 등 언어레벨 write(open('w')·os.replace·write_text)로 직접 # 쓰려는 우회 탐지. 정상 CLI(python3 .../activate_method_contract.py ...)는 명령줄에 이 관용구· # 경로 리터럴이 없어(모듈 내부에 있음) 걸리지 않는다. _ACTIVATION_PATH_TOKEN_RE = re.compile( r"['\"]?([^\s'\"()]*method-contract-activations\.yaml)['\"]?") def _lang_write_to_activation(cmd): if not _LEDGER_WRITE_IDIOM_RE.search(cmd): return None m = _ACTIVATION_PATH_TOKEN_RE.search(cmd) return m.group(1) if m else None # secret/자격증명 경로: Read/Grep/Glob 이 명시적으로 이런 파일을 타깃하면 2차 차단 # (네이티브 permissions.deny 가 1차. Grep/Glob 은 네이티브 커버가 약해 여기서 보조로 막는다). _SECRET_PATH_RE = re.compile( r"\.env\b(?!\.(?:example|sample|template|dist)\b)|" r"(id_rsa|\.aws/credentials|\.ssh/|/etc/shadow|secrets?/)", re.IGNORECASE) def _read_like_targets(tool_name, tool_input): """Read/Grep/Glob 의 경로류 입력(file_path/path/glob/pattern)을 모은다.""" keys = ("file_path", "path", "glob", "pattern", "notebook_path") return [str(tool_input.get(k)) for k in keys if tool_input.get(k)] def check(tool_name, tool_input, hook_payload=None): policy = _task_policy_check(tool_name, tool_input, hook_payload) if policy[0]: return policy # spawn gate(finding P0-2): Org OS 워커는 유효한 context-package 없이 spawn 금지. if tool_name in ("Agent", "Task"): return _check_spawn(tool_input, hook_payload) if tool_name == "Bash": cmd = str(tool_input.get("command", "")) # 1) git push (플래그 변형 포함) — 토큰 파서로 탐지 if "push" in _git_subcommands(cmd): return "git-push", "원격 push는 기본 금지(git -C/기타 플래그 변형 포함). 승인 필요." # 2) regex denylist (gh-pr-create / deploy / secret / rm-rf / slack / db-write) # side-effect 카테고리는 tool-permission-matrix가 default-deny일 때만 차단(#11 deep). for pat, cat, reason in BASH_DENY: if re.search(pat, cmd, re.IGNORECASE) and _category_active(cat): return cat, reason # 3) 보고서 불변성 우회: redirection/tee/dd 로 기존 .report.yaml overwrite 차단 for tgt in _bash_write_targets(cmd): norm = tgt.replace("\\", "/") if IMMUTABLE_RE.search(norm) and _exists(tgt): return ("immutable-report", "보고서(.report.yaml)를 Bash redirection/tee/dd 로 덮어쓸 수 없다 — " "불변이다. new_report.py로 새 버전을 생성하라.") # 3b) 언어레벨(python/node/shutil) write 로 기존 report overwrite 차단(finding #11) if _lang_write_to_report(cmd): return ("immutable-report", "보고서(.report.yaml)를 python/node open('w')·write_text·writeFile·shutil.copy/move 로 " "덮어쓸 수 없다 — 불변이다. new_report.py로 새 버전을 생성하라.") # 4) 원장 신뢰 경계(finding P0-4): redirection/tee/dd 로 원장 파일 쓰기 차단 for tgt in _bash_write_targets(cmd): if _is_ledger_target(tgt): return ("ledger-trust-boundary", f"원장({tgt})은 신뢰 경계다 — Bash redirection/tee/dd 로 쓸 수 없다. " "상태/수락/토큰은 각 CLI, 증거는 PostToolUse 훅만 기록한다.") # 4a2) 공식 company-context.yaml SoT — Bash redirection/tee/dd 로 직접 쓰기 금지 for tgt in _bash_write_targets(cmd): if _is_protected_sot(tgt): return ("company-context-sot", f"공식 company-context.yaml({tgt})은 Bash redirection/tee/dd 로 쓸 수 없다 — " "commit_company_context.py(원자 교체)로만 갱신한다(P1 §9.5).") # 4a3) Contract v2 활성화 레지스트리 — Bash redirection/tee/dd 로 직접 쓰기 금지 for tgt in _bash_write_targets(cmd): if _is_activation_registry(tgt): return ("activation-registry-boundary", f"활성화 레지스트리({tgt})는 신뢰 경계다 — Bash redirection/tee/dd 로 쓸 수 없다. " "activate_method_contract.py(4단 게이트: hash·golden·HUMAN signoff)로만 활성화한다.") # 4b) 언어레벨 write 로 원장 위조/덮어쓰기 차단 led = _lang_write_to_ledger(cmd) if led: return ("ledger-trust-boundary", f"원장({led})을 python/node/redirection write 로 위조·덮어쓸 수 없다(신뢰 경계).") # 4b2) 활성화 레지스트리 언어레벨 write 차단(python -c open('w')/os.replace 등) act = _lang_write_to_activation(cmd) if act: return ("activation-registry-boundary", f"활성화 레지스트리({act})를 python/node write 로 직접 쓸 수 없다 — " "activate_method_contract.py(HUMAN signoff 게이트)로만 활성화한다.") # 4c) evidence_ledger.py 수동 호출 차단 — 정상 경로는 Claude Code 의 PostToolUse 훅 뿐. if _EVIDENCE_SCRIPT_RE.search(cmd): return ("ledger-trust-boundary", "evidence_ledger.py 는 PostToolUse 훅 전용이다 — 수동 호출로 receipt 를 위조할 수 없다.") # 4d) state_engine.py signoff 차단 — 사람 승인(human-gate)은 에이전트가 대신 낼 수 없다. if _SIGNOFF_CLI_RE.search(cmd): return ("human-gate-boundary", "state_engine.py signoff(사람 승인)는 에이전트가 호출할 수 없다 — human-gate 는 " "사람이 세션 밖에서 승인한다(P0-4 soft-boundary).") if _HUMAN_REVIEW_CLI_RE.search(cmd): return ("human-gate-boundary", "HUMAN-* reviewer/decider를 에이전트가 대리할 수 없다 — 사람은 세션 밖에서 " "review/signoff/release decision을 기록해야 한다.") if _ACCEPTANCE_INTERNAL_RE.search(cmd): return ("ledger-trust-boundary", "acceptance_log 저수준 append/build API 직접 호출은 금지된다 — " "state_engine.py review-artifact의 권한·id+sha 검증 경로를 사용하라.") return None, None # Read/Grep/Glob(finding #11): secret/자격증명 경로를 명시적으로 타깃하면 2차 차단. if tool_name in ("Read", "Grep", "Glob"): if _category_active("secret-read"): for tgt in _read_like_targets(tool_name, tool_input): if _SECRET_PATH_RE.search(tgt.replace("\\", "/")): return ("secret-read", f"secret/자격증명 경로({tool_name})는 기본 금지 — {tgt} (네이티브 deny 1차 + guard 2차).") return None, None if tool_name in ("Write", "Edit", "NotebookEdit"): path = _path_for(tool_name, tool_input) if IMMUTABLE_RE.search(path.replace("\\", "/")) and _exists(path): return ("immutable-report", "보고서(.report.yaml)는 불변이다 — 덮어쓰기/수정 금지. new_report.py로 새 버전을 생성하라.") # 원장 신뢰 경계(finding P0-4): 존재 여부와 무관하게 에이전트 직접 쓰기 금지 # (에이전트는 원장을 생성/추가하지 않는다 — CLI/훅만 한다). if _is_ledger_target(path): return ("ledger-trust-boundary", "원장 파일(state/evidence 원장)은 신뢰 경계다 — Write/Edit 로 직접 쓸 수 없다. " "상태=state_engine, 수락=acceptance_log, 토큰=token_ledger CLI, 증거=PostToolUse 훅만 기록한다.") if _is_protected_sot(path): return ("company-context-sot", "공식 company-context.yaml 은 신뢰 경계다 — Write/Edit 로 직접 쓸 수 없다. " "commit_company_context.py(candidate→원자 교체)로만 갱신한다(P1 §9.5).") if _is_activation_registry(path): return ("activation-registry-boundary", "활성화 레지스트리(method-contract-activations.yaml)는 신뢰 경계다 — Write/Edit 로 " "직접 쓸 수 없다. activate_method_contract.py(hash·golden·HUMAN signoff 4단 게이트)로만 활성화한다.") for pat, cat, reason in FILE_DENY: if re.search(pat, path, re.IGNORECASE) and _category_active(cat): return cat, reason return None, None def main(): data = sys.stdin.read().strip() # fail-closed: 파싱 불가/비객체 payload 는 차단한다(보안 가드는 fail-open 하지 않는다). # 정상 PreToolUse payload 는 항상 {tool_name, tool_input,...} JSON 객체이므로 유효 호출은 통과. try: payload = json.loads(data) except (json.JSONDecodeError, ValueError): sys.stderr.write("[guard_tools] BLOCK: 파싱 불가한 hook payload — fail-closed 차단.\n") sys.exit(2) if not isinstance(payload, dict): sys.stderr.write("[guard_tools] BLOCK: hook payload가 JSON 객체가 아니다 — fail-closed 차단.\n") sys.exit(2) tool_name = payload.get("tool_name", "") tool_input = payload.get("tool_input", {}) if not isinstance(tool_input, dict): tool_input = {} cat, reason = check(tool_name, tool_input, payload) if cat: sys.stderr.write(f"[guard_tools] BLOCK {tool_name} ({cat}): {reason}\n") sys.exit(2) sys.exit(0) if __name__ == "__main__": main()