From f16785b93e95793506f327043aedc7ed4562cf40 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 19:11:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(studio-save):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=90=9C=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EA=B0=80=20=EC=A0=80=EC=9E=A5=20=EB=8C=80=EC=83=81=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 studio:publish 권한 분리를 유예하고 write 권한을 가진 실계정으로 저장하기로 했다. 설계의 명시적 예외이고, 설계가 프롬프트 통제를 인정하지 않는 이유가 여기 그대로 적용된다 — 「이미 게시된 게시물은 건드리지 않는다」를 문장이 아니라 코드로 만든다. 한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. public 이 찼거나 status 가 게시 중이면 저장 대상에서 뺀다. 저장소에서 두 표시가 같은 17건을 가리키지만 하나만 차 있어도 게시로 본다 — 한쪽이 뒤늦게 채워지는 경우를 놓치지 않는다. frontmatter 는 저장소가 아는 것이지 서버가 아는 것이 아니다. 그래서 계획의 첫 단계가 서버에 게시 상태를 묻고, PUBLISHED 면 저장 단계로 넘어가지 않는다. 새 문서를 만드는 계획에는 그 단계가 없다 — 아직 없는 문서에는 물어볼 게시 상태가 없다. 시험 초안에 [HARNESS-TEST] 접두사를 붙일 수 있게 했다. 나중에 사람이 눈으로 가린다. 무인 저장은 그대로 꺼져 있다. CR-001 이 유예됐다는 것은 무인 저장을 켠다는 뜻이 아니다 — 사람이 보는 앞에서 저장하는 것과 사람 없이 저장하는 것은 다른 이야기다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- .../final/evidence/meta/studio-guards.json | 18 ++++++ .../evidence/raw/guards/studio-guards.txt | 22 +++++++ scripts/studio-save.py | 51 +++++++++++++++ scripts/tests/test_studio_save.py | 62 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 docs/document-haness/final/evidence/meta/studio-guards.json create mode 100644 docs/document-haness/final/evidence/raw/guards/studio-guards.txt diff --git a/docs/document-haness/final/evidence/meta/studio-guards.json b/docs/document-haness/final/evidence/meta/studio-guards.json new file mode 100644 index 0000000..6b7e5fe --- /dev/null +++ b/docs/document-haness/final/evidence/meta/studio-guards.json @@ -0,0 +1,18 @@ +{ + "id": "studio-guards", + "kind": "terminal", + "sourceRevision": "87d70e7c800eb41e7f3ac917ca30e4fbbd8390b4", + "sourceDirty": true, + "executedAt": "2026-09-10T19:10:03+09:00", + "executedAtSource": "이 도구가 명령을 실행한 시각", + "command": "bash -c cd /home/donghyeon/workspace/chat-gpt-container/dh-B\necho '=== 1. 게시된 기록을 저장하려 하면 ==='\nout=$(python3 scripts/studio-save.py --record 'docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md' --package /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/pkgc.json -o /tmp/x.json 2>&1); echo \"exit=$?\"; echo \"$out\"\necho\necho '=== 2. 게시 경로가 계획에 들어가면 ==='\npython3 -c \"\nimport importlib.util\nsp=importlib.util.spec_from_file_location('ss','scripts/studio-save.py'); m=importlib.util.module_from_spec(sp); sp.loader.exec_module(m)\nfor path in ('/api/v1/studio/documents/abc/publish','/api/v1/studio/publications/abc/unpublish'):\n try:\n m._reject_forbidden_paths([{'path': path}]); print('막지 못했다:', path)\n except m.Refused as e:\n print('거절:', e)\n\"\necho\necho '=== 3. 어댑터가 만든 계획의 경로 전부 ==='\npython3 -c \"\nimport json\nd=json.load(open('/tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/plan-c.json'))\nfor s in d['requests']: print(' ', s['op'], s['method'], s['path'])\nprint(' 게시 경로:', [s['path'] for s in d['requests'] if 'publish' in s['path']] or '없음')\nprint(' 저장 전 게시 상태 조회:', d['requests'][0]['expect']['stopIf'])\nprint(' 제목:', d['requests'][1]['body']['document']['title'][:50])\nprint(' 자격증명:', d['requests'][1]['cookies'], d['requests'][1]['headers']['X-CSRF-TOKEN'])\n\"\necho\necho '=== 4. 무인 저장 ==='\nout=$(python3 scripts/studio-save.py --record 'docs/document-haness/tech-log-studio/pipeline-gate-exit-codes/case/case-exit-code-read-behind-a-pipe.md' --package /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/pkgc.json --verdicts /tmp/claude-1000/-home-donghyeon-workspace-chat-gpt-container-dh-B/0106b08a-c31d-459b-8469-4e9326cfe523/scratchpad/verdict-c.json --send 2>&1); echo \"exit=$?\"; echo \"$out\"\n", + "cwd": ".", + "exitCode": 0, + "exitCodeSource": "실행한 프로세스의 반환값. 손으로 적지 않는다", + "rawPath": "evidence/raw/guards/studio-guards.txt", + "presentationPath": null, + "proves": "게시된 기록은 저장 대상에서 거절되고, 게시 경로는 계획에 못 들어가며, 무인 저장은 꺼져 있고, 시험 초안에는 접두사가 붙는다", + "doesNotProve": "서버가 그렇게 답한다는 것. 브라우저를 열지 않았고 서버 계약은 소스에서 읽은 것이다", + "sha256": "690417d28af9552815c37296e197b62614896157e03211de48283fee3aa49dd5", + "bytes": 1449 +} diff --git a/docs/document-haness/final/evidence/raw/guards/studio-guards.txt b/docs/document-haness/final/evidence/raw/guards/studio-guards.txt new file mode 100644 index 0000000..6323445 --- /dev/null +++ b/docs/document-haness/final/evidence/raw/guards/studio-guards.txt @@ -0,0 +1,22 @@ +=== 1. 게시된 기록을 저장하려 하면 === +exit=3 +거절: 게시된 기록이다. 저장 대상에서 뺀다 — 한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다 + public: https://hyeonworks.com/cases/split-custody-access-token + status: 게시 중 + +=== 2. 게시 경로가 계획에 들어가면 === +거절: 게시 경로는 이 어댑터가 만들지 않는다: /api/v1/studio/documents/abc/publish +거절: 게시 경로는 이 어댑터가 만들지 않는다: /api/v1/studio/publications/abc/unpublish + +=== 3. 어댑터가 만든 계획의 경로 전부 === + publication-precheck GET /api/v1/studio/documents/11111111-2222-3333-4444-555555555555 + save PUT /api/v1/studio/documents/11111111-2222-3333-4444-555555555555 + verify GET /api/v1/studio/documents/11111111-2222-3333-4444-555555555555 + 게시 경로: 없음 + 저장 전 게시 상태 조회: currentPublication.status == PUBLISHED — 저장하지 않는다 + 제목: [HARNESS-TEST] 파이프 뒤의 종료 코드를 읽고 검사기 열넷이 다 통과한다고 적었 + 자격증명: {'TECHLOG_SESSION': ''} + +=== 4. 무인 저장 === +exit=3 +거절: 무인 저장이 꺼져 있다 — CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다 diff --git a/scripts/studio-save.py b/scripts/studio-save.py index 6900b78..1095237 100644 --- a/scripts/studio-save.py +++ b/scripts/studio-save.py @@ -250,6 +250,35 @@ def build_input(record_path: str) -> dict: VERDICTS = ("PASS", "FAIL", "UNKNOWN") +# 사람이 눈으로 가릴 수 있게 시험 초안에 붙이는 접두사 +HARNESS_PREFIX = "[HARNESS-TEST]" + + +def published_marks(record_path: str) -> list[str]: + """이 기록이 게시된 것으로 보이는 표시. 비면 저장 대상이다. + + **한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다.** 되돌릴 수 없는 + 자리라 「건드리지 않겠다」는 문장이 아니라 코드가 막는다 — 설계가 프롬프트 통제를 + 인정하지 않는 이유가 그대로 적용된다. + + 저장소의 기록 275건에서 `public:` 이 찬 것과 `status: 게시 중` 인 것이 **정확히 같은 + 17건**이다. 둘을 함께 보되 하나만 차 있어도 게시로 본다 — 한쪽이 뒤늦게 채워지는 + 경우에 놓치지 않으려는 것이다. + + **이것은 저장소가 아는 것이지 서버가 아는 것이 아니다.** 계획의 첫 단계가 `GET` 으로 + `currentPublication` 을 읽게 되어 있고, 그것이 최종 판정이다. + """ + text = open(record_path, encoding="utf-8").read() + fm = "\n".join(f"{k}: {v}" for k, v in _front_matter(text).items()) + marks = [] + m = re.search(r'^public:\s*"?(\S+?)"?\s*$', fm, re.M) + if m and m.group(1) not in ("", '""'): + marks.append(f"public: {m.group(1)}") + m = re.search(r"^status:\s*(.+?)\s*$", fm, re.M) + if m and "게시 중" in m.group(1): + marks.append(f"status: {m.group(1)}") + return marks + def _figure_kind_coverage() -> tuple[int, int]: """(저장소의 그림 수, 종류 표시가 없는 그림 수). @@ -390,6 +419,19 @@ def plan_requests(record_path: str, doc: dict, document_id: str | None, """보낼 요청을 그대로 적어 낸다. 보내지 않는다.""" rel = os.path.relpath(os.path.abspath(record_path), ROOT) steps: list[dict] = [] + if document_id: + # **저장 전에 게시 상태를 서버에 묻는다.** 저장소의 frontmatter 는 저장소가 아는 + # 것이지 서버가 아는 것이 아니다. 이 단계가 최종 판정이고, 여기서 PUBLISHED 가 + # 나오면 저장 단계로 넘어가지 않는다 + steps.append({ + "op": "publication-precheck", "method": "GET", + "path": f"{BASE_PATH}/documents/{document_id}", + "headers": {}, "cookies": {"TECHLOG_SESSION": ""}, + "body": None, + "expect": {"status": 200, + "stopIf": "currentPublication.status == PUBLISHED — 저장하지 않는다", + "why": "게시한 문서는 삭제가 409 로 거절된다"}, + }) if not document_id: steps.append({ "op": "create", "method": "POST", "path": f"{BASE_PATH}/documents", @@ -513,6 +555,8 @@ def main() -> int: ap.add_argument("--expected-version", type=int, help="GET 으로 읽은 현재 version") ap.add_argument("-o", "--out", help="계획을 적을 파일") ap.add_argument("--verdicts", help="경고마다 PASS/FAIL/UNKNOWN 을 적은 검토 판정 파일") + ap.add_argument("--harness-test", action="store_true", + help=f"시험 초안이다. 제목 앞에 {HARNESS_PREFIX} 를 붙인다") ap.add_argument("--send", action="store_true", help="실제로 보낸다 (지금은 막혀 있다)") args = ap.parse_args() @@ -520,6 +564,11 @@ def main() -> int: for p in (args.record, args.package): if not os.path.isfile(p): raise Refused(f"그런 파일이 없다: {p}") + marks = published_marks(args.record) + if marks: + raise Refused( + "게시된 기록이다. 저장 대상에서 뺀다 — 한 번이라도 게시한 문서는 게시를 " + "취소해도 삭제가 409 로 거절된다\n " + "\n ".join(marks)) pkg = approved(args.record, args.package) asset_errors, asset_warnings = inspect_assets(pkg) if asset_errors: @@ -528,6 +577,8 @@ def main() -> int: all_warnings = list(pkg.get("warnings") or []) + asset_warnings cleared = _review_gate(all_warnings, args.verdicts, args.package) doc = build_input(args.record) + if args.harness_test: + doc["title"] = f"{HARNESS_PREFIX} {doc.get('title', '')}".strip() steps = plan_requests(args.record, doc, args.document_id, args.expected_version) if args.send and not UNATTENDED_SAVE_ENABLED: diff --git a/scripts/tests/test_studio_save.py b/scripts/tests/test_studio_save.py index 23a4687..f9bda41 100644 --- a/scripts/tests/test_studio_save.py +++ b/scripts/tests/test_studio_save.py @@ -427,3 +427,65 @@ class ReviewGateTest(unittest.TestCase): a = {"id": "유보 감소", "detail": "가능성 2회 → 1회"} b = {"id": "유보 감소", "detail": "가능성 3회 → 1회"} self.assertNotEqual(key(a), key(b)) + + +class PublishedRecordGuardTest(unittest.TestCase): + """게시된 기록은 저장 대상에서 **코드가** 뺀다. + + 한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. 되돌릴 수 없는 + 자리라 「건드리지 않겠다」는 문장이 아니라 코드가 막는다 — 설계가 프롬프트 통제를 + 인정하지 않는 이유가 그대로 적용된다. + """ + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def _record(self, extra=""): + p = os.path.join(self.tmp, "r.md") + open(p, "w", encoding="utf-8").write( + f"---\nkind: CASE\nslug: x\ntitle: x\n{extra}---\n\n# x\n\n요약.\n") + return p + + def test_a_record_with_a_public_url_is_refused(self): + marks = ss.published_marks(self._record('public: "https://example.test/x"\n')) + self.assertTrue(marks) + self.assertIn("public:", marks[0]) + + def test_a_record_marked_live_is_refused(self): + marks = ss.published_marks(self._record("status: 게시 중\n")) + self.assertTrue(marks) + + def test_an_unpublished_record_is_a_save_target(self): + """대조군. 게시 전 기록은 그대로 저장 대상이다.""" + self.assertEqual([], ss.published_marks(self._record("status: 게시 전\n"))) + self.assertEqual([], ss.published_marks(self._record('public: ""\n'))) + + def test_every_published_record_in_this_repository_is_seen(self): + """저장소의 게시된 기록 전부가 이 가드에 걸려야 한다. + + `public:` 이 찬 것과 `status: 게시 중` 인 것이 저장소에서 같은 집합이지만, + 한쪽만 차 있어도 게시로 본다 — 한쪽이 뒤늦게 채워지는 경우를 놓치지 않는다. + """ + import glob + seen = 0 + for f in glob.glob(os.path.join(ROOT, "docs/*/tech-log-studio/*/*/*.md")): + text = open(f, encoding="utf-8").read() + if "status: 게시 중" in text or 'public: "http' in text: + seen += 1 + self.assertTrue(ss.published_marks(f), f) + self.assertGreater(seen, 0, "게시된 기록을 못 찾았다 — 이 대조가 무의미해진다") + + def test_the_plan_asks_the_server_before_saving(self): + """저장소의 frontmatter 는 저장소가 아는 것이지 서버가 아는 것이 아니다.""" + steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), "doc-1", 3) + self.assertEqual("publication-precheck", steps[0]["op"]) + self.assertIn("PUBLISHED", steps[0]["expect"]["stopIf"]) + + def test_a_create_plan_has_no_precheck(self): + """아직 없는 문서에는 물어볼 게시 상태가 없다.""" + steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None) + self.assertNotIn("publication-precheck", [s["op"] for s in steps]) + + def test_the_harness_prefix_is_defined(self): + """시험 초안을 사람이 눈으로 가릴 수 있어야 한다.""" + self.assertEqual("[HARNESS-TEST]", ss.HARNESS_PREFIX)