Files
company-haness/.claude/hooks/guard_tools.py
T

608 lines
33 KiB
Python

#!/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/<type>.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 <pkg>` 검증 후, "
"출력된 `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()