#!/usr/bin/env python3 """PostToolUse hook — append a tool-execution *receipt* to the evidence ledger (C5). 이 훅은 Bash/Write/Edit 도구가 실제로 실행된 뒤(PostToolUse) 호출되어, 무엇이 실제로 돌았는지를 append-only 원장에 기록한다. validator(C6)는 이 원장을 읽어 에이전트가 report에서 주장한 E4/E5 등급(command+exit-code:0 / 파일 산출)이 실제 실행에 뒷받침되는지 대조한다 — 자기신고(self-report)를 receipt로 접지시킨다. 원장 위치: /ledger.jsonl (한 줄 = JSON receipt) receipt 필드(C5): {tool_use_id, tool_name, ts, cwd, command?, exit_code?, stdout_sha256?, artifact_path?, artifact_sha256?} - Bash: command / exit_code / stdout_sha256 - Write/Edit: artifact_path / artifact_sha256 (기록 시점=쓰기 직후, 디스크 실물 해시) 입력: Claude Code PostToolUse JSON on stdin {tool_name, tool_input, tool_use_id?, cwd?, tool_response?} 강건성 계약: - 절대 도구 파이프라인을 깨지 않는다. 어떤 예외에도 exit 0. - workspace 미설정/디렉터리 부재면 조용히 degrade(로그는 stderr, 그래도 exit 0). - ts는 실제 벽시계(UTC) — 이 훅은 일반 OS 프로세스라 현재시각을 쓸 수 있다. """ import hashlib import json import os import re import sys from datetime import datetime, timezone HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) ARTIFACT_TOOLS = ("Write", "Edit", "NotebookEdit", "MultiEdit") _SECRET_RE = re.compile( r"(?i)(--(?:password|token|secret|api-key)|authorization:|bearer)\s*(?:=|\s)\s*([^\s]+)" ) def _log(msg): try: sys.stderr.write(f"[evidence_ledger] {msg}\n") except Exception: pass def _sha256_text(s): try: return hashlib.sha256(str(s).encode("utf-8", "replace")).hexdigest() except Exception: return None def _sha256_file(path): try: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() except Exception: return None def _redact_command(command): value = str(command or "") value = _SECRET_RE.sub(lambda match: f"{match.group(1)}=[REDACTED]", value) value = re.sub(r"(?i)([?&](?:token|key|secret|password)=)[^&\s]+", r"\1[REDACTED]", value) return value def _ledger_path(): """/ledger.jsonl. workspace 미설정이면 None(조용히 degrade).""" try: import _workspace as W # noqa: E402 ed = W.evidence_dir() except Exception as e: # WorkspaceNotSetError 포함 _log(f"workspace 미해석 — receipt 스킵: {e}") return None try: os.makedirs(ed, exist_ok=True) except Exception as e: _log(f"evidence_dir 생성 실패 — receipt 스킵: {e}") return None return os.path.join(ed, "ledger.jsonl") def _abs(path, cwd): if not path: return None if os.path.isabs(path): return path for base in (cwd, os.environ.get("CLAUDE_PROJECT_DIR"), os.getcwd()): if base: cand = os.path.join(base, path) if os.path.exists(cand): return cand # 존재 안 해도 cwd 기준 절대경로는 돌려준다(해시는 실패→None) return os.path.join(cwd or os.getcwd(), path) def _extract_exit(tool_response): """PostToolUse tool_response에서 exit code를 추출. finding P0-6: 어떤 exit 신호도 해석하지 못하면 **성공(0)으로 위장 기록하지 않고 None(미상)** 을 반환한다 — 예전엔 signal-less 응답을 0으로 적어, exit-code를 못 읽는 환경에서 자기신고 E4/E5 command 주장이 '성공 receipt'로 접지되는 우회가 있었다. 명시적 성공 신호(is_error=False)만 0으로 인정한다. 우선순위: 명시 숫자 필드 > interrupted(130) > is_error True(1) > is_error False(0) > None.""" if isinstance(tool_response, dict): for k in ("exit_code", "exitCode", "returncode", "return_code", "code", "status"): v = tool_response.get(k) if isinstance(v, bool): continue if isinstance(v, int): return v if isinstance(v, str) and v.strip().lstrip("-").isdigit(): return int(v.strip()) if tool_response.get("interrupted") is True: return 130 ie = tool_response.get("is_error") if ie is None: ie = tool_response.get("isError") if ie is True: return 1 if ie is False: return 0 # 명시적 성공 신호만 0 return None # 미상 — 성공으로 위장하지 않는다 def _stdout_of(tool_response): if isinstance(tool_response, str): return tool_response if isinstance(tool_response, dict): for k in ("stdout", "output", "stdoutText", "result"): v = tool_response.get(k) if isinstance(v, str): return v content = tool_response.get("content") if isinstance(content, list): texts = [c.get("text", "") for c in content if isinstance(c, dict) and isinstance(c.get("text"), str)] if texts: return "\n".join(texts) return None def build_receipt(payload): tool = payload.get("tool_name") or payload.get("toolName") or "" ti = payload.get("tool_input") or payload.get("toolInput") or {} if not isinstance(ti, dict): ti = {} tr = payload.get("tool_response") if tr is None: tr = payload.get("toolResponse") cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() # finding P0-6: receipt 를 Claude Code 가 공급하는 실행 컨텍스트(session/agent/workflow/ # tool_use_id/cwd)에 결속한다 — 오래된 다른 작업·다른 세션의 receipt 재사용을 식별 가능하게. receipt = { "tool_use_id": (payload.get("tool_use_id") or payload.get("toolUseId") or payload.get("id")), "tool_name": tool, "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "cwd": cwd, } receipt["receipt_id"] = receipt.get("tool_use_id") _sid = (payload.get("session_id") or payload.get("sessionId") or os.environ.get("CLAUDE_SESSION_ID")) _aid = (payload.get("agent_id") or payload.get("agentId") or os.environ.get("CLAUDE_AGENT_ID")) _wid = (payload.get("workflow_id") or os.environ.get("ORGOS_WORKFLOW_ID") or os.environ.get("ORGOS_WORKFLOW")) for _k, _v in (("session_id", _sid), ("agent_id", _aid), ("workflow_id", _wid)): if _v: receipt[_k] = _v source_revision = payload.get("source_revision_sha256") or os.environ.get("ORGOS_SOURCE_REVISION_SHA256") if source_revision: receipt["source_revision_sha256"] = source_revision if tool == "Bash": command = str(ti.get("command", "")).strip() receipt["command"] = _redact_command(command) receipt["command_sha256"] = _sha256_text(command) receipt["exit_code"] = _extract_exit(tr) receipt["receipt_type"] = payload.get("receipt_type") or "command-run" if payload.get("assertion_status") in ("passed", "failed"): receipt["assertion_status"] = payload.get("assertion_status") so = _stdout_of(tr) if so is not None: receipt["stdout_sha256"] = _sha256_text(so) elif tool in ARTIFACT_TOOLS: receipt["receipt_type"] = "artifact-write" path = ti.get("file_path") or ti.get("notebook_path") or ti.get("path") or "" receipt["artifact_path"] = path h = _sha256_file(_abs(path, cwd)) if path else None if h is None: # 디스크 해시 불가 시 입력 콘텐츠로 폴백(Write=content, Edit=new_string 등) content = (ti.get("content") if ti.get("content") is not None else ti.get("new_string") if ti.get("new_string") is not None else ti.get("new_source") if ti.get("new_source") is not None else ti.get("new_str")) if content is not None: h = _sha256_text(content) receipt["artifact_sha256"] = h return receipt def main(): try: data = sys.stdin.read() except Exception: sys.exit(0) if not data or not data.strip(): sys.exit(0) try: payload = json.loads(data) except Exception as e: _log(f"malformed PostToolUse JSON — 스킵: {e}") sys.exit(0) if not isinstance(payload, dict): sys.exit(0) tool = payload.get("tool_name") or payload.get("toolName") or "" if tool not in ("Bash",) + ARTIFACT_TOOLS: sys.exit(0) # 원장 대상 아님(Read/Grep 등) — 조용히 통과 try: receipt = build_receipt(payload) except Exception as e: _log(f"receipt 생성 실패 — 스킵: {e}") sys.exit(0) lp = _ledger_path() if not lp: sys.exit(0) try: with open(lp, "a", encoding="utf-8") as f: try: import fcntl fcntl.flock(f.fileno(), fcntl.LOCK_EX) except Exception: pass f.write(json.dumps(receipt, ensure_ascii=False) + "\n") f.flush() os.fsync(f.fileno()) try: import fcntl fcntl.flock(f.fileno(), fcntl.LOCK_UN) except Exception: pass except Exception as e: _log(f"원장 append 실패 — 스킵: {e}") sys.exit(0) sys.exit(0) if __name__ == "__main__": main()