feat(studio-save): 그림 쪽에도 다리를 놓는다

문장 쪽에는 놨는데 그림 쪽에는 안 놨다. 화살표를 뒤집어도 관문이 전부 0 이고 경고도 비어
자동 통과했다.

설계가 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고 적었는데 그
정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만 스크립트가 들어 있는지는
문자열로 판정된다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이라 error 로 막는다.
script·foreignObject·이벤트 처리기·바깥 href·바깥 리소스·@import 여섯이다.
#fragment 와 data: 는 바깥으로 안 나가므로 막지 않는다.

그림이 검토 뒤에 바뀌면 경고로 올린다. 무엇이 바뀌었는지는 기계가 못 말하지만 바뀌었으니
보라는 말할 수 있다. 이것이 들어가면서 읽기 계약이 그림에도 걸린다.

실제 흐름과 개념 설명을 data-figure-kind 로 가른다. 표시가 없으면 error 가 아니라
warning 이다 — 지금 저장소의 그림 272장에 이 표시가 하나도 없고, 없다고 전부 막으면
정상을 막는 쪽으로 넘어간다. 설명용이라 밝힌 그림도 막지 않는다.

검사기를 조일 때마다 정상이 통과하는 대조를 같이 넣는다. 이번 대조군은 평범한 그림,
설명용이라 밝힌 그림, 내부·data 참조, 그리고 이 저장소의 그림 272장 전부다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
DongHyeonka
2026-09-10 11:32:51 +09:00
co-authored by Claude Opus 5
parent f1637ee532
commit a5215eda31
2 changed files with 199 additions and 1 deletions
+87 -1
View File
@@ -68,10 +68,84 @@ FIELD_MAP = {
BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->" BODY_START, BODY_END = "<!-- body:start -->", "<!-- body:end -->"
# SVG 에서 **문자열로 판정되는 것**. 사람 판단이 필요 없으므로 error 로 막는다.
# `plan/01` §8 이 「SVG 의 스크립트·외부 리소스는 저장 단계의 허용 정책으로 제한한다」고
# 적었는데 그 정책이 코드로 어디에도 없었다. 화살표 방향은 사람이 봐야 하지만
# 스크립트가 들어 있는지는 안 그렇다 — 그림 쪽에서 기계가 할 수 있는 유일한 일이다
SVG_FORBIDDEN = (
(re.compile(r"<\s*script\b", re.I), "스크립트가 들어 있다"),
(re.compile(r"<\s*foreignObject\b", re.I), "foreignObject 는 임의의 HTML 을 싣는다"),
(re.compile(r"\son[a-z]+\s*=", re.I), "이벤트 처리기 속성이 있다"),
# 따옴표는 \x22 \x27 로 적는다. 정규식 안에 두 종류를 함께 쓰면 파이썬 문자열이 먼저 닫힌다
(re.compile(r"(?:xlink:)?href\s*=\s*[\x22\x27](?:https?:)?//", re.I),
"바깥 주소를 가리킨다"),
(re.compile(r"<\s*(?:image|use)\b[^>]*(?:xlink:)?href\s*=\s*[\x22\x27](?!#|data:)", re.I),
"바깥 리소스를 불러온다"),
(re.compile(r"@import\b", re.I), "바깥 스타일을 불러온다"),
)
# 그림이 **실제 흐름**을 그린 것인지 **개념 설명**인지. `plan/01` §8 이 표시하라고 적었다.
# 없으면 error 가 아니라 warning 이다 — 지금 저장소의 그림 272장에 이 표시가 없고,
# 없다고 전부 막으면 정상을 막는 쪽으로 넘어간다
FIGURE_KIND = re.compile(
r"data-figure-kind\s*=\s*[\x22\x27](evidence|illustrative)[\x22\x27]", re.I)
class Refused(Exception): class Refused(Exception):
"""어댑터가 스스로 거절한 것. 서버 오류가 아니다.""" """어댑터가 스스로 거절한 것. 서버 오류가 아니다."""
def inspect_svg(path: str) -> tuple[list[str], str | None]:
"""(막아야 할 것, 그림 종류). 종류를 못 읽으면 None."""
try:
text = open(path, encoding="utf-8", errors="replace").read()
except OSError as e:
return [f"열지 못했다: {e}"], None
hits = [why for pat, why in SVG_FORBIDDEN if pat.search(text)]
m = FIGURE_KIND.search(text)
return hits, (m.group(1).lower() if m else None)
def inspect_assets(package: dict) -> tuple[list[dict], list[dict]]:
"""그림을 본다. 막는 것과 읽어야 할 것을 나눠 낸다.
**바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다.**
화살표를 뒤집어도 `gates` 는 전부 0 이고 `warnings` 도 비어 자동 통과했다. 문장 쪽에는
다리를 놨는데 그림 쪽에는 안 놨던 자리다.
"""
errors, warnings = [], []
for asset in package.get("assets") or []:
rel = asset.get("path", "")
full = os.path.join(ROOT, rel)
if not asset.get("exists") or not rel.endswith(".svg"):
continue
hits, kind = inspect_svg(full)
for why in hits:
errors.append({"id": "그림 허용 정책", "asset": rel, "detail": why})
now = _sha256_file(full)
if asset.get("sha256") and now != asset["sha256"]:
warnings.append({
"id": "그림이 검토 뒤에 바뀌었다", "asset": rel,
"detail": f"검토 시점 {asset['sha256'][:12]} → 지금 {now[:12]}",
"note": "무엇이 바뀌었는지는 이 어댑터가 말하지 못한다. "
"화살표 방향·주체는 사람이 그림을 열어 봐야 안다",
})
if kind is None:
warnings.append({
"id": "그림 종류가 표시돼 있지 않다", "asset": rel,
"detail": 'data-figure-kind="evidence" 또는 "illustrative" 가 없다',
"note": "실제 흐름을 그린 것인지 개념 설명인지 못 가린다. "
"설명용을 증거로 읽는 것을 막을 수 없다",
})
elif kind == "illustrative":
warnings.append({
"id": "설명용 그림", "asset": rel,
"detail": "data-figure-kind=\"illustrative\" — 실제 흐름이 아니라 개념 설명이다",
"note": "막지 않는다. 밝힌 대로 읽히는지만 검토가 본다",
})
return errors, warnings
def _sha256_file(path: str) -> str: def _sha256_file(path: str) -> str:
with open(path, "rb") as fh: with open(path, "rb") as fh:
return hashlib.sha256(fh.read()).hexdigest() return hashlib.sha256(fh.read()).hexdigest()
@@ -334,6 +408,10 @@ def main() -> int:
if not os.path.isfile(p): if not os.path.isfile(p):
raise Refused(f"그런 파일이 없다: {p}") raise Refused(f"그런 파일이 없다: {p}")
pkg = approved(args.record, args.package) pkg = approved(args.record, args.package)
asset_errors, asset_warnings = inspect_assets(pkg)
if asset_errors:
raise Refused("그림 허용 정책에 걸린다 — 저장 단계에서 막는다\n " +
"\n ".join(f"{e['asset']}: {e['detail']}" for e in asset_errors))
doc = build_input(args.record) doc = build_input(args.record)
steps = plan_requests(args.record, doc, args.document_id, args.expected_version) steps = plan_requests(args.record, doc, args.document_id, args.expected_version)
@@ -354,10 +432,14 @@ def main() -> int:
"approvedAgainst": pkg["target"]["sha256"], "approvedAgainst": pkg["target"]["sha256"],
"checkerVersions": pkg.get("checkerVersions"), "checkerVersions": pkg.get("checkerVersions"),
"requests": steps, "requests": steps,
"warnings": asset_warnings,
"notes": [ "notes": [
"이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.", "이 파일의 요청을 아직 보내지 않았다. mode 가 plan 이다.",
"게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.", "게시·게시취소 경로는 이 어댑터가 만들지 않는다. 계획에 들어 있으면 거절한다.",
"자격증명은 환경 변수 자리표시자로만 적혀 있다. 값은 이 저장소에 없다.", "자격증명은 환경 변수 자리표시자로만 적혀 있다. 값은 이 저장소에 없다.",
"**warnings 가 비어 있어야 자동 통과다.** 관문이 전부 통과해도 그것만으로는 "
"아니다. 그림이 검토 뒤에 바뀌었다는 경고는 무엇이 바뀌었는지 말하지 못한다 — "
"화살표 방향과 주체는 사람이 그림을 열어 봐야 안다.",
], ],
} }
text = json.dumps(plan, ensure_ascii=False, indent=2) + "\n" text = json.dumps(plan, ensure_ascii=False, indent=2) + "\n"
@@ -365,7 +447,11 @@ def main() -> int:
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True) os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
open(args.out, "w", encoding="utf-8").write(text) open(args.out, "w", encoding="utf-8").write(text)
ops = " · ".join(f"{s['op']} {s['method']}" for s in steps) ops = " · ".join(f"{s['op']} {s['method']}" for s in steps)
print(f"{args.out}{ops} · 무인 저장 {'' if UNATTENDED_SAVE_ENABLED else ''}") print(f"{args.out}{ops} · 그림 {len(pkg.get('assets') or [])}"
f"· 경고 {len(asset_warnings)}"
f"· 무인 저장 {'' if UNATTENDED_SAVE_ENABLED else ''}")
if asset_warnings:
print("경고는 통과가 아니다. 근거를 읽는 검토가 항목마다 판정한다", file=sys.stderr)
else: else:
print(text, end="") print(text, end="")
return 0 return 0
+112
View File
@@ -74,6 +74,118 @@ class ComparatorControlTest(unittest.TestCase):
self.assertIn("publishedAt", r["unexpected"]) self.assertIn("publishedAt", r["unexpected"])
SVG_HEAD = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"'
def _svg(tmp, body):
f = os.path.join(tmp, "f.svg")
open(f, "w", encoding="utf-8").write(body)
return f
class SvgPolicyTest(unittest.TestCase):
"""`plan/01` §8 의 「저장 단계의 허용 정책」.
화살표 방향은 사람이 봐야 하지만 스크립트가 들어 있는지는 문자열로 판정된다.
**그림 쪽에서 기계가 할 수 있는 유일한 일이라 error 로 막는다.**
"""
def setUp(self):
self.tmp = tempfile.mkdtemp()
def test_an_ordinary_figure_passes(self):
"""대조군. 이것이 막히면 아래의 모든 「막았다」가 정상을 막는 것이다."""
hits, kind = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + '><rect/><text>Guest</text></svg>'))
self.assertEqual([], hits)
self.assertIsNone(kind)
def test_a_figure_declared_illustrative_passes(self):
"""설명용이라 밝힌 그림을 막지 않는다.
막으면 V-004 에서 채택된 편집 9건을 막은 것과 같은 실패다. 밝힌 대로 읽히는지는
검토가 본다.
"""
hits, kind = ss.inspect_svg(
_svg(self.tmp, SVG_HEAD + ' data-figure-kind="illustrative"><rect/></svg>'))
self.assertEqual([], hits)
self.assertEqual("illustrative", kind)
def test_a_figure_declared_evidence_is_read_as_evidence(self):
_, kind = ss.inspect_svg(
_svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence"><rect/></svg>'))
self.assertEqual("evidence", kind)
def test_dangerous_shapes_are_blocked(self):
cases = {
"script": "><script>alert(1)</script></svg>",
"foreignObject": "><foreignObject><b>x</b></foreignObject></svg>",
"event handler": ' onload="x()"><rect/></svg>',
"external href": '><a href="https://evil.test/x">t</a></svg>',
"protocol relative": '><a xlink:href="//evil.test/x">t</a></svg>',
"external image": '><image href="https://evil.test/a.png"/></svg>',
"css import": "><style>@import url(x.css);</style></svg>",
}
for name, tail in cases.items():
with self.subTest(name=name):
hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail))
self.assertTrue(hits, f"{name} 이 통과했다")
def test_internal_and_data_references_are_not_blocked(self):
"""`#fragment` 와 `data:` 는 바깥으로 안 나간다. 막으면 정상을 막는 것이다."""
for tail in ('><use href="#box"/></svg>',
'><image href="data:image/png;base64,AA"/></svg>'):
with self.subTest(tail=tail):
hits, _ = ss.inspect_svg(_svg(self.tmp, SVG_HEAD + tail))
self.assertEqual([], hits)
def test_every_figure_in_this_repository_passes(self):
"""지금 저장소의 그림 전부가 통과해야 한다. 하나라도 막히면 정책이 과하다."""
import glob
files = glob.glob(os.path.join(ROOT, "docs/*/final/assets/**/*.svg"), recursive=True)
self.assertGreater(len(files), 100, "그림을 못 찾았다 — 이 대조가 무의미해진다")
blocked = [f for f in files if ss.inspect_svg(f)[0]]
self.assertEqual([], blocked)
class AssetWarningTest(unittest.TestCase):
"""바뀐 것이 무엇인지는 기계가 못 말한다. 바뀌었으니 보라는 말할 수 있다."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.svg = _svg(self.tmp, SVG_HEAD + ' data-figure-kind="evidence"><rect/></svg>')
self.rel = os.path.relpath(self.svg, ROOT)
def _pkg(self, sha):
return {"assets": [{"path": self.rel, "exists": True, "sha256": sha}]}
def test_an_unchanged_figure_raises_nothing(self):
"""대조군."""
e, w = ss.inspect_assets(self._pkg(ss._sha256_file(self.svg)))
self.assertEqual([], e)
self.assertEqual([], w)
def test_a_figure_changed_after_review_is_a_warning_not_an_error(self):
"""화살표를 뒤집어도 관문은 전부 0 이다. 그것이 보이지 않던 자리다."""
e, w = ss.inspect_assets(self._pkg("0" * 64))
self.assertEqual([], e)
self.assertEqual(["그림이 검토 뒤에 바뀌었다"], [x["id"] for x in w])
def test_a_figure_without_a_kind_marker_is_a_warning(self):
plain = _svg(self.tmp, SVG_HEAD + "><rect/></svg>")
rel = os.path.relpath(plain, ROOT)
e, w = ss.inspect_assets(
{"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(plain)}]})
self.assertEqual([], e)
self.assertIn("그림 종류가 표시돼 있지 않다", [x["id"] for x in w])
def test_a_dangerous_figure_is_an_error(self):
bad = _svg(self.tmp, SVG_HEAD + "><script>x</script></svg>")
rel = os.path.relpath(bad, ROOT)
e, _ = ss.inspect_assets(
{"assets": [{"path": rel, "exists": True, "sha256": ss._sha256_file(bad)}]})
self.assertEqual(["그림 허용 정책"], [x["id"] for x in e])
class IdempotencyKeyTest(unittest.TestCase): class IdempotencyKeyTest(unittest.TestCase):
def test_create_key_is_stable_across_retries(self): def test_create_key_is_stable_across_retries(self):
"""재시도가 문서를 둘 만들지 않게 하는 것이 이 키의 목적이다.""" """재시도가 문서를 둘 만들지 않게 하는 것이 이 키의 목적이다."""