feat(studio-save): 게시된 기록을 코드가 저장 대상에서 뺀다
사용자가 studio:publish 권한 분리를 유예하고 write 권한을 가진 실계정으로 저장하기로 했다. 설계의 명시적 예외이고, 설계가 프롬프트 통제를 인정하지 않는 이유가 여기 그대로 적용된다 — 「이미 게시된 게시물은 건드리지 않는다」를 문장이 아니라 코드로 만든다. 한 번이라도 게시한 문서는 게시를 취소해도 삭제가 409 로 거절된다. public 이 찼거나 status 가 게시 중이면 저장 대상에서 뺀다. 저장소에서 두 표시가 같은 17건을 가리키지만 하나만 차 있어도 게시로 본다 — 한쪽이 뒤늦게 채워지는 경우를 놓치지 않는다. frontmatter 는 저장소가 아는 것이지 서버가 아는 것이 아니다. 그래서 계획의 첫 단계가 서버에 게시 상태를 묻고, PUBLISHED 면 저장 단계로 넘어가지 않는다. 새 문서를 만드는 계획에는 그 단계가 없다 — 아직 없는 문서에는 물어볼 게시 상태가 없다. 시험 초안에 [HARNESS-TEST] 접두사를 붙일 수 있게 했다. 나중에 사람이 눈으로 가린다. 무인 저장은 그대로 꺼져 있다. CR-001 이 유예됐다는 것은 무인 저장을 켠다는 뜻이 아니다 — 사람이 보는 앞에서 저장하는 것과 사람 없이 저장하는 것은 다른 이야기다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
co-authored by
Claude Opus 5
parent
87d70e7c80
commit
f16785b93e
@@ -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
|
||||
}
|
||||
@@ -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': '<env STUDIO_SESSION_COOKIE>'} <env STUDIO_CSRF_TOKEN>
|
||||
|
||||
=== 4. 무인 저장 ===
|
||||
exit=3
|
||||
거절: 무인 저장이 꺼져 있다 — CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다
|
||||
@@ -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": "<env STUDIO_SESSION_COOKIE>"},
|
||||
"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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user