init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
#!/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로 접지시킨다.
|
||||
|
||||
원장 위치: <evidence_dir>/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():
|
||||
"""<evidence_dir>/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()
|
||||
Reference in New Issue
Block a user