608 lines
33 KiB
Python
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()
|