makdoong2-team 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,7 +105,7 @@ makdoong2-team doctor # 설치 진단
105
105
 
106
106
  - 커맨드가 **전용 full-permission 에이전트**(`makdoong2-issue-reporter`)로 라우팅되어, 호출 시점 이전의 로그·프롬프트·세션 컨텍스트를 스스로 수집해 이상 지점을 포착하고 [y00njinuk/makdoong2-team issues](https://github.com/y00njinuk/makdoong2-team/issues) 에 등록한다.
107
107
  - **사용자 직접 호출이 유일한 트리거**다. 부장님·막둥이가 실패를 관측했다고 자율적으로 이슈를 만들지 않는다 (훅이 차단).
108
- - 저장소가 public 이므로 사내 정보는 마스킹 후 첨부되며, **전송 전 마스킹 요약에 대한 사용자 최종 승인**을 반드시 거친다.
108
+ - 저장소가 public 이므로 사내 정보는 마스킹 후 첨부되며, **전송 전 사용자 승인이 훅으로 강제된다**: 에이전트가 게시될 원문 전체를 채팅에 표시한 뒤, 사용자가 직접 `issue-reporter-approve.sh <payload>` 를 실행해 원문을 확인·승인해야만 GitHub 쓰기 호출이 통과된다 (승인은 내용 sha256 에 바인딩, 1회용).
109
109
  - PAT 는 `~/.config/opencode/.github` 파일에서 읽는다. 상세: ARCHITECTURE.md §4.6
110
110
 
111
111
  ---
@@ -24,7 +24,13 @@ permission:
24
24
  ## 하드룰
25
25
 
26
26
  1. **첫 행동으로 `skill(name="makdoong2-issue-reporter")` 를 로드**하고, 스킬에 정의된 절차를 그대로 따른다. 실행 순서는 스킬이 고정한다: **수집 → 이상 지점 포착 → 마스킹 → 중복 확인 → 최소 질의 → 이슈 생성**.
27
- 2. **마스킹 최종 확인 없이 전송 금지.** 이슈·코멘트·Gist 전송 마스킹 결과 요약을 사용자에게 제시하고 승인을 받는다. 사용자 승인 없이 GitHub 어떤 내용도 게시하지 않는다.
27
+ 2. **GitHub 게시(이슈·코멘트·Gist·라벨)는 훅이 강제하는 사용자 승인 게이트를 통과해야만 가능하다.** 절차는 고정이다:
28
+ 1. payload 를 **리터럴 절대 경로** JSON 파일로 작성한다 (예: `/tmp/makdoong2-issue/issue-payload.json`).
29
+ 2. **게시될 원문 전체(제목·라벨·본문 전문)를 채팅에 그대로 표시**하고 마스킹 내역 요약을 덧붙인다. 요약·발췌로 대체 금지 — 사용자는 전송될 내용 원문을 봐야 한다.
30
+ 3. 사용자에게 안내한다: `bash <SCRIPTS_DIR>/issue-reporter-approve.sh </absolute/path/payload.json>` 을 **사용자가 직접** 실행 (스크립트가 원문을 다시 보여주고 승인을 받아 `<payload>.approved` 마커를 기록한다). 실행을 안내한 뒤 **대기한다**.
31
+ 4. 승인 마커가 생긴 뒤에만 전송한다. 전송은 **단일 curl 명령 + `-d @<절대경로>`** 형태만 허용된다 (체이닝·리다이렉트·인라인 JSON 금지 — 훅이 차단).
32
+ - 승인은 **1회용**이며 payload 내용이 바뀌면 무효다 (해시 바인딩). 재작성했으면 2번부터 다시.
33
+ - **승인 스크립트를 직접 실행하거나 `.approved` 마커를 만들지·읽지·지우지 않는다.** 훅이 물리 차단하며, 차단 메시지를 보면 우회하지 말고 사용자 승인을 기다린다.
28
34
  3. **토큰(PAT)은 어디에도 원문 노출 금지.** 커맨드 문자열에 직접 박지 않고 환경변수로 전달하며, 출력에는 마스킹(`ghp_****`)만 허용한다.
29
35
  4. **워크플로우 상태를 변경하지 않는다.** state.json 은 증거 수집을 위한 읽기(`state.sh get`)만 허용. `state.sh set` / dispatch 계열 툴 호출 금지. 이 에이전트는 워크플로우 오케스트레이션과 완전히 분리된 조사·보고 전용이다.
30
36
  5. **다른 에이전트로 위임하지 않는다.** 수집·분석·마스킹·등록 전 과정을 이 세션에서 직접 수행한다.
@@ -1,5 +1,46 @@
1
1
  export declare const ISSUE_REPORTER_SKILL_NAME = "makdoong2-issue-reporter";
2
2
  export declare const ISSUE_REPORTER_AGENT = "makdoong2-issue-reporter";
3
+ export declare const APPROVAL_MARKER_SUFFIX = ".approved";
4
+ export declare const APPROVE_SCRIPT_BASENAME = "issue-reporter-approve.sh";
5
+ /** GitHub API 호출 분류 결과 */
6
+ export type GithubApiCall = {
7
+ kind: "none";
8
+ } | {
9
+ kind: "read";
10
+ } | {
11
+ kind: "mutation";
12
+ payloadPaths: string[];
13
+ problems: string[];
14
+ } | {
15
+ kind: "forbidden-client";
16
+ reason: string;
17
+ };
18
+ /**
19
+ * issue-reporter 의 bash 명령을 GitHub API 관점에서 분류한다.
20
+ *
21
+ * - api.github.com 미참조 → none (이 게이트와 무관)
22
+ * - curl 이 아닌 클라이언트(node/python/wget/gh …)로 api.github.com 접근
23
+ * → forbidden-client. 훅이 payload 를 검사할 수 있는 형태가 curl -d @file
24
+ * 뿐이므로 다른 클라이언트는 쓰기·읽기 불문 전부 막는다 (읽기는 curl 로).
25
+ * - curl 이지만 mutation 징후(-X POST/PATCH/PUT/DELETE, 데이터 플래그) 없음 → read
26
+ * - mutation → payload 파일 경로 추출. 문제(인라인 데이터, 상대 경로, 변수 포함)는
27
+ * problems 로 수집한다 — 호출부는 problems 가 하나라도 있으면 차단한다.
28
+ */
29
+ export declare function classifyGithubApiCall(cmd: string): GithubApiCall;
30
+ /** 승인 스크립트 호출 여부 — 에이전트에게는 실행이 금지된다 (사용자 전용). */
31
+ export declare function isApproveScriptInvocation(cmd: string): boolean;
32
+ /** 승인 마커 경로 참조 여부 — 에이전트의 bash/write 에서 일절 금지된다. */
33
+ export declare function referencesApprovalMarker(text: string): boolean;
34
+ /** payload 파일 경로 → 승인 마커 경로 */
35
+ export declare function approvalMarkerPath(payloadPath: string): string;
36
+ export declare function sha256Hex(content: string | Buffer): string;
37
+ /** 마커 파일 내용에서 해시를 파싱한다. 첫 줄이 64자리 hex 가 아니면 null. */
38
+ export declare function parseApprovalMarker(markerContent: string): string | null;
39
+ /**
40
+ * 승인 검증: 마커의 해시가 현재 payload 내용과 일치하는가.
41
+ * 불일치 사유를 문자열로 반환하고, 유효하면 null.
42
+ */
43
+ export declare function approvalMismatch(payloadContent: Buffer, markerContent: string): string | null;
3
44
  /** skill 툴 args 에서 스킬 이름을 추출한다. { name } 및 { arguments: { name } } 형태 수용. */
4
45
  export declare function extractSkillNameFromArgs(args: unknown): string | undefined;
5
46
  /**
@@ -15,8 +15,132 @@
15
15
  // agent 미상(undefined)은 차단하지 않는다 — outer/primary 세션 passthrough 로,
16
16
  // OUTER_WORLD_TOOLS 가드와 동일한 설계 철학이다. 우리가 식별한 에이전트에만
17
17
  // 정책을 적용하고, 식별 밖의 세션은 opencode 자체 permission 에 맡긴다.
18
+ import { createHash } from "node:crypto";
18
19
  export const ISSUE_REPORTER_SKILL_NAME = "makdoong2-issue-reporter";
19
20
  export const ISSUE_REPORTER_AGENT = "makdoong2-issue-reporter";
21
+ // ── GitHub 게시 승인 게이트 ──────────────────────────────────────────────
22
+ //
23
+ // 정책: issue-reporter 가 GitHub 에 무엇이든 게시(이슈·코멘트·Gist·라벨)하려면
24
+ // 사용자가 게시될 "원문 전체"를 보고 명시적으로 승인해야 한다. 채팅 승인은
25
+ // 프롬프트 수준 규약일 뿐이라 강제가 아니다 — 여기의 계약이 물리적 강제다:
26
+ //
27
+ // 1. 에이전트는 payload 를 "리터럴 절대 경로" 파일로 만들어 -d @<path> 로만
28
+ // 전달할 수 있다 (인라인 -d '{...}', 변수 경로, curl 외 HTTP 클라이언트 금지).
29
+ // 2. 사용자가 scripts/issue-reporter-approve.sh <payload> 를 "직접" 실행한다.
30
+ // 스크립트는 payload 원문 전체를 화면에 출력한 뒤 stdin 으로 승인을 받고,
31
+ // payload 의 sha256 을 <payload>.approved 마커에 기록한다.
32
+ // 3. tool.execute.before 훅이 GitHub 쓰기 호출 시 마커의 해시와 현재 payload
33
+ // 내용을 대조한다. 승인 후 1바이트라도 바뀌면 차단 — 승인은 특정 원문에
34
+ // 바인딩되고, 다른 내용으로 바꿔치기할 수 없다.
35
+ // 4. 전송이 실행되면 훅(after)이 마커를 삭제한다. 승인은 1회용이다.
36
+ // 5. 에이전트 자신의 승인 스크립트 실행·마커 생성/조작은 훅이 차단한다.
37
+ // 승인 스크립트는 stdin confirm 이라 에이전트 셸에서는 EOF 로도 못 넘어가지만,
38
+ // printf 'y' 파이프 우회가 가능하므로 실행 자체를 막는 것이 1차다.
39
+ export const APPROVAL_MARKER_SUFFIX = ".approved";
40
+ export const APPROVE_SCRIPT_BASENAME = "issue-reporter-approve.sh";
41
+ const MUTATION_METHOD_RE = /(?:-X|--request)[= ]*['"]?(POST|PATCH|PUT|DELETE)\b/i;
42
+ const DATA_FLAG_RE = /(^|[\s'"])(-d|--data|--data-binary|--data-raw|--data-urlencode|--json|-F|--form)([= ]|$)/;
43
+ const PAYLOAD_AT_RE = /(?:-d|--data|--data-binary|--data-raw|--json)[= ]+@(["']?)([^"'\s]+)\1/g;
44
+ /**
45
+ * issue-reporter 의 bash 명령을 GitHub API 관점에서 분류한다.
46
+ *
47
+ * - api.github.com 미참조 → none (이 게이트와 무관)
48
+ * - curl 이 아닌 클라이언트(node/python/wget/gh …)로 api.github.com 접근
49
+ * → forbidden-client. 훅이 payload 를 검사할 수 있는 형태가 curl -d @file
50
+ * 뿐이므로 다른 클라이언트는 쓰기·읽기 불문 전부 막는다 (읽기는 curl 로).
51
+ * - curl 이지만 mutation 징후(-X POST/PATCH/PUT/DELETE, 데이터 플래그) 없음 → read
52
+ * - mutation → payload 파일 경로 추출. 문제(인라인 데이터, 상대 경로, 변수 포함)는
53
+ * problems 로 수집한다 — 호출부는 problems 가 하나라도 있으면 차단한다.
54
+ */
55
+ export function classifyGithubApiCall(cmd) {
56
+ // gh CLI 는 URL 문자열 없이도 이슈·코멘트·Gist 를 만들 수 있으므로
57
+ // (gh issue create / gh api ...) api.github.com 참조 여부와 무관하게 막는다.
58
+ if (/(^|[;&|(]\s*|\s)gh\s+(api|issue|pr|gist|label|repo|release)\b/.test(cmd)) {
59
+ return {
60
+ kind: "forbidden-client",
61
+ reason: "gh CLI 는 사용할 수 없다. GitHub 접근은 curl 만 허용된다 (쓰기는 curl -d @<절대경로> 형태만 승인 검증 가능).",
62
+ };
63
+ }
64
+ if (!/api\.github\.com/i.test(cmd))
65
+ return { kind: "none" };
66
+ const usesCurl = /(^|[;&|(]\s*|\s)curl(\s|$)/.test(cmd);
67
+ if (!usesCurl) {
68
+ return {
69
+ kind: "forbidden-client",
70
+ reason: "api.github.com 접근은 curl 만 허용된다 (쓰기는 curl -d @<절대경로> 형태만 검증 가능).",
71
+ };
72
+ }
73
+ const hasMutationMethod = MUTATION_METHOD_RE.test(cmd);
74
+ const hasDataFlag = DATA_FLAG_RE.test(cmd);
75
+ // curl -G / --get 은 데이터 플래그를 쿼리 스트링으로 변환하는 GET 이다
76
+ // (중복 검색이 --data-urlencode 와 함께 쓴다). mutation method 가 명시되지
77
+ // 않은 -G 호출은 읽기로 분류한다.
78
+ const isGetConverted = /(^|\s)(-G|--get)(\s|$)/.test(cmd) && !hasMutationMethod;
79
+ if (isGetConverted)
80
+ return { kind: "read" };
81
+ if (!hasMutationMethod && !hasDataFlag)
82
+ return { kind: "read" };
83
+ const payloadPaths = [];
84
+ const problems = [];
85
+ for (const m of cmd.matchAll(PAYLOAD_AT_RE)) {
86
+ payloadPaths.push(m[2]);
87
+ }
88
+ if (payloadPaths.length === 0) {
89
+ problems.push("payload 는 반드시 파일로 전달한다: -d @</absolute/path/payload.json>. " +
90
+ "인라인 JSON(-d '{...}')과 stdin(-d @-)은 승인 검증이 불가능해 금지된다.");
91
+ }
92
+ for (const p of payloadPaths) {
93
+ if (!p.startsWith("/")) {
94
+ problems.push(`payload 경로는 리터럴 절대 경로여야 한다: "${p}"`);
95
+ }
96
+ if (/[$\`]/.test(p)) {
97
+ problems.push(`payload 경로에 변수·명령 치환을 쓸 수 없다: "${p}"`);
98
+ }
99
+ }
100
+ // TOCTOU 방어: 훅은 "명령 실행 전" 파일 해시를 검증하므로, 같은 명령 안에서
101
+ // payload 를 다시 쓰고(curl 앞에 echo > file 등) 전송하면 검증을 우회할 수 있다.
102
+ // 그래서 쓰기 호출은 셸 제어 연산자·치환이 없는 "단일 curl 호출"만 허용한다.
103
+ // (mutation POST URL 에는 &, ; 등이 필요한 경우가 없다 — 읽기 호출은 이 검사 밖.)
104
+ if (/[;|<>\n`]|\$\(|&&|\s&(\s|$)/.test(cmd)) {
105
+ problems.push("GitHub 쓰기 호출은 단일 curl 명령이어야 한다 — 체이닝(;, &&, |, &), " +
106
+ "리다이렉트(<, >), 명령 치환($(), `)을 함께 쓸 수 없다.");
107
+ }
108
+ return { kind: "mutation", payloadPaths, problems };
109
+ }
110
+ /** 승인 스크립트 호출 여부 — 에이전트에게는 실행이 금지된다 (사용자 전용). */
111
+ export function isApproveScriptInvocation(cmd) {
112
+ return cmd.includes("issue-reporter-approve");
113
+ }
114
+ /** 승인 마커 경로 참조 여부 — 에이전트의 bash/write 에서 일절 금지된다. */
115
+ export function referencesApprovalMarker(text) {
116
+ return text.includes(APPROVAL_MARKER_SUFFIX);
117
+ }
118
+ /** payload 파일 경로 → 승인 마커 경로 */
119
+ export function approvalMarkerPath(payloadPath) {
120
+ return `${payloadPath}${APPROVAL_MARKER_SUFFIX}`;
121
+ }
122
+ export function sha256Hex(content) {
123
+ return createHash("sha256").update(content).digest("hex");
124
+ }
125
+ /** 마커 파일 내용에서 해시를 파싱한다. 첫 줄이 64자리 hex 가 아니면 null. */
126
+ export function parseApprovalMarker(markerContent) {
127
+ const first = markerContent.split("\n")[0]?.trim().toLowerCase() ?? "";
128
+ return /^[0-9a-f]{64}$/.test(first) ? first : null;
129
+ }
130
+ /**
131
+ * 승인 검증: 마커의 해시가 현재 payload 내용과 일치하는가.
132
+ * 불일치 사유를 문자열로 반환하고, 유효하면 null.
133
+ */
134
+ export function approvalMismatch(payloadContent, markerContent) {
135
+ const recorded = parseApprovalMarker(markerContent);
136
+ if (recorded === null)
137
+ return "승인 마커 형식이 잘못됐다 (첫 줄이 sha256 hex 가 아님)";
138
+ const actual = sha256Hex(payloadContent);
139
+ if (actual !== recorded) {
140
+ return "payload 내용이 승인 이후 변경됐다 — 승인은 특정 원문에 바인딩되며, 변경된 내용은 재승인이 필요하다";
141
+ }
142
+ return null;
143
+ }
20
144
  /** skill 툴 args 에서 스킬 이름을 추출한다. { name } 및 { arguments: { name } } 형태 수용. */
21
145
  export function extractSkillNameFromArgs(args) {
22
146
  if (!args || typeof args !== "object")
@@ -16,7 +16,7 @@
16
16
  // Install path (default): ~/.config/opencode/plugins/makdoong2-team/
17
17
  // Place this file at ~/.config/opencode/plugins/makdoong2-team/src/opencode-plugin.ts
18
18
  // (or load the npm package via opencode.json "plugin": ["./plugins/makdoong2-team/src/opencode-plugin.ts"]).
19
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { join, resolve as pathResolve, sep as pathSep } from "node:path";
22
22
  import { tool } from "@opencode-ai/plugin";
@@ -33,7 +33,7 @@ import { injectAllSecrets } from "./mcp-secret-injector.js";
33
33
  import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
34
34
  import { logger } from "./logger.js";
35
35
  import { redactAndTruncate } from "./redact-secrets.js";
36
- import { issueReporterSkillLoadViolation, ISSUE_REPORTER_AGENT } from "./issue-reporter-guard.js";
36
+ import { issueReporterSkillLoadViolation, ISSUE_REPORTER_AGENT, APPROVAL_MARKER_SUFFIX, APPROVE_SCRIPT_BASENAME, classifyGithubApiCall, isApproveScriptInvocation, referencesApprovalMarker, approvalMarkerPath, approvalMismatch, } from "./issue-reporter-guard.js";
37
37
  // All runtime paths come from makdoong2-team.json (paths.* overrides) or are
38
38
  // derived from the opencode config dir. No MAKDOONG2 environment variables.
39
39
  const { hooks: HOOKS_DIR, gates: GATES_DIR, scripts: SCRIPTS_DIR, stages: STAGES_DIR, skills: SKILLS_DIR, } = resolvePaths();
@@ -1020,6 +1020,24 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1020
1020
  `first invoke skill(name="${skillName}") in the current session, then retry.`);
1021
1021
  }
1022
1022
  }
1023
+ // ── Issue-reporter: 승인 마커는 어떤 툴로도 만들 수 없다 ──
1024
+ // 마커(<payload>.approved)는 오직 사용자가 issue-reporter-approve.sh 를
1025
+ // 직접 실행해서만 생긴다. write/edit 툴로 마커를 위조하면 게시 게이트가
1026
+ // 무력화되므로 경로에 마커 접미사가 보이면 즉시 차단한다.
1027
+ if (agent === ISSUE_REPORTER_AGENT && (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower))) {
1028
+ // filePath 인자뿐 아니라 인자 전체를 검사한다 — apply_patch 는 파일 경로가
1029
+ // 패치 본문 안에 들어 있어 filePath 추출로는 마커 위조를 잡을 수 없다.
1030
+ let argsSerialized = "";
1031
+ try {
1032
+ argsSerialized = JSON.stringify(output.args ?? "");
1033
+ }
1034
+ catch { /* ignore */ }
1035
+ if (referencesApprovalMarker(argsSerialized)) {
1036
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter가 ${input.tool} 인자에서 승인 마커 경로 참조.`);
1037
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] 승인 마커(*${APPROVAL_MARKER_SUFFIX})는 에이전트가 생성·수정할 수 없다. ` +
1038
+ `사용자가 'bash ${SCRIPTS_DIR}/${APPROVE_SCRIPT_BASENAME} <payload>' 를 직접 실행해야만 승인이 성립한다.`);
1039
+ }
1040
+ }
1023
1041
  // ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
1024
1042
  if (agent === "makdoong2-team-leader" && LEADER_FORBIDDEN_TOOLS.has(toolLower)) {
1025
1043
  logger.error(`[makdoong2-team hook] BLOCKED: team-leader가 ${input.tool} 툴 호출을 시도했다.`);
@@ -1059,6 +1077,56 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1059
1077
  `• 초안 파일 필요 시: spec을 team-leader에게 반환 → dev 단계에서 engineer가 구현.\n\n` +
1060
1078
  `**참조**: agents/${agent}.md "금지" 섹션, CLAUDE.md "워크플로우 상태 & 위임 규약"`);
1061
1079
  }
1080
+ // ── Issue-reporter 게시 게이트: GitHub 쓰기는 사용자 승인 원문만 ──
1081
+ // 정책·마커 계약 상세: src/issue-reporter-guard.ts 상단 주석.
1082
+ if (agent === ISSUE_REPORTER_AGENT) {
1083
+ const approveHint = `승인 절차: 에이전트가 payload 원문 전체를 채팅에 표시한 뒤, 사용자가 직접\n` +
1084
+ ` bash ${SCRIPTS_DIR}/${APPROVE_SCRIPT_BASENAME} </absolute/path/payload.json>\n` +
1085
+ `를 실행한다 (스크립트가 원문을 다시 보여주고 stdin 승인 후 <payload>${APPROVAL_MARKER_SUFFIX} 마커 기록). ` +
1086
+ `이 승인은 1회용이고 payload 내용이 바뀌면 무효다.`;
1087
+ if (isApproveScriptInvocation(cmd)) {
1088
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter가 승인 스크립트 실행 시도. cmd="${redactAndTruncate(cmd, 200)}"`);
1089
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] ${APPROVE_SCRIPT_BASENAME} 는 사용자 전용이다. ` +
1090
+ `에이전트가 실행하면 승인의 의미가 사라진다. 사용자에게 실행을 안내하고 대기하라.\n${approveHint}`);
1091
+ }
1092
+ if (referencesApprovalMarker(cmd)) {
1093
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter bash가 승인 마커를 참조. cmd="${redactAndTruncate(cmd, 200)}"`);
1094
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] 승인 마커(*${APPROVAL_MARKER_SUFFIX})는 에이전트가 ` +
1095
+ `생성·수정·삭제·열람할 수 없다. 마커 검증과 소멸은 훅이 수행한다.\n${approveHint}`);
1096
+ }
1097
+ const call = classifyGithubApiCall(cmd);
1098
+ if (call.kind === "forbidden-client") {
1099
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter가 비-curl 클라이언트로 GitHub API 접근. cmd="${redactAndTruncate(cmd, 200)}"`);
1100
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] ${call.reason}\n${approveHint}`);
1101
+ }
1102
+ if (call.kind === "mutation") {
1103
+ if (call.problems.length > 0) {
1104
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter GitHub 쓰기 형식 위반: ${call.problems.join(" / ")}`);
1105
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] GitHub 쓰기 호출 형식 위반:\n` +
1106
+ call.problems.map((p) => ` - ${p}`).join("\n") + `\n${approveHint}`);
1107
+ }
1108
+ for (const payloadPath of call.payloadPaths) {
1109
+ if (!existsSync(payloadPath)) {
1110
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] payload 파일이 없다: ${payloadPath}\n${approveHint}`);
1111
+ }
1112
+ const markerPath = approvalMarkerPath(payloadPath);
1113
+ if (!existsSync(markerPath)) {
1114
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter GitHub 쓰기 — 승인 마커 없음: ${markerPath}`);
1115
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] 사용자 승인이 없다.\n` +
1116
+ `게시하려는 원문(${payloadPath})을 채팅에 전문 표시한 뒤, 사용자에게 아래 실행을 안내하고 대기하라:\n` +
1117
+ ` bash ${SCRIPTS_DIR}/${APPROVE_SCRIPT_BASENAME} ${payloadPath}`);
1118
+ }
1119
+ const mismatch = approvalMismatch(readFileSync(payloadPath), readFileSync(markerPath, "utf8"));
1120
+ if (mismatch !== null) {
1121
+ logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter GitHub 쓰기 — ${mismatch} (${payloadPath})`);
1122
+ throw new Error(`[makdoong2-team issue-reporter 게시 게이트] ${mismatch}.\n` +
1123
+ `현재 원문을 다시 채팅에 전문 표시하고 재승인을 받아라:\n` +
1124
+ ` bash ${SCRIPTS_DIR}/${APPROVE_SCRIPT_BASENAME} ${payloadPath}`);
1125
+ }
1126
+ logger.debug(`[makdoong2-team hook] issue-reporter 게시 승인 확인: ${payloadPath} (marker hash 일치)`);
1127
+ }
1128
+ }
1129
+ }
1062
1130
  const hookIssue = sessionIssue.get(sessionID ?? "") ?? "";
1063
1131
  const r = await runScript(HOOKS_DIR, "guard-bash.sh", cmd, hookIssue);
1064
1132
  if (!r.ok) {
@@ -1081,6 +1149,29 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1081
1149
  else
1082
1150
  sessionActiveToolCount.set(afterSessionID, cur - 1);
1083
1151
  }
1152
+ // ── Issue-reporter 승인 마커 1회용 소멸 ──
1153
+ // GitHub 쓰기 명령이 "실행"되고 나면 결과와 무관하게 마커를 삭제한다.
1154
+ // 성공: 재게시에는 새 승인이 필요하다. 실패(네트워크 등): 같은 원문이라도
1155
+ // 재시도 전에 재승인을 받는다 — 마커를 남겨두면 실패를 빌미로 승인 한 번에
1156
+ // 여러 번의 전송 시도가 가능해지므로 엄격한 쪽을 택했다.
1157
+ if (toolLowerAfter === "bash") {
1158
+ const afterAgent = afterSessionID
1159
+ ? (sessionAgent.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.agent)
1160
+ : undefined;
1161
+ if (afterAgent === ISSUE_REPORTER_AGENT) {
1162
+ const afterCmd = input.args?.command ?? "";
1163
+ const afterCall = classifyGithubApiCall(afterCmd);
1164
+ if (afterCall.kind === "mutation") {
1165
+ for (const payloadPath of afterCall.payloadPaths) {
1166
+ const markerPath = approvalMarkerPath(payloadPath);
1167
+ if (existsSync(markerPath)) {
1168
+ rmSync(markerPath, { force: true });
1169
+ logger.debug(`[makdoong2-team hook] issue-reporter 승인 마커 소멸(1회용): ${markerPath}`);
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1084
1175
  if (afterSessionID && WRITE_TOOLS.has(toolLowerAfter)) {
1085
1176
  const agentAfter = sessionAgent.get(afterSessionID)
1086
1177
  ?? pendingDispatch.get(afterSessionID)?.agent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # issue-reporter-approve.sh — GitHub 게시 승인 (사용자 전용)
3
+ #
4
+ # makdoong2-issue-reporter 가 만든 payload 파일을 GitHub 에 게시하기 전에,
5
+ # 사용자가 게시될 "원문 전체"를 직접 확인하고 승인하는 스크립트다.
6
+ #
7
+ # bash issue-reporter-approve.sh </absolute/path/issue-payload.json>
8
+ #
9
+ # 동작:
10
+ # 1. payload 원문 전체를 화면에 출력한다 (jq 가 있으면 title/labels/body 를
11
+ # 사람이 읽기 좋게 함께 렌더링하지만, 원문 전문 출력이 항상 우선한다).
12
+ # 2. stdin 으로 승인을 받는다 (scripts/lib/confirm.sh — /dev/tty 미사용).
13
+ # 3. 승인 시 payload 의 sha256 을 <payload>.approved 마커에 기록한다.
14
+ #
15
+ # 이 마커는 플러그인 tool.execute.before 훅의 게시 게이트가 검증한다:
16
+ # - 마커 없음 / 해시 불일치(승인 후 내용 변경) → curl POST 차단
17
+ # - 전송 실행 후 마커 자동 삭제 (승인은 1회용)
18
+ #
19
+ # 에이전트는 이 스크립트를 실행할 수 없다 (훅이 이름으로 차단). 반드시
20
+ # 사용자가 별도 셸 또는 세션의 shell 입력으로 직접 실행해야 한다.
21
+
22
+ set -euo pipefail
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25
+ . "${SCRIPT_DIR}/lib/confirm.sh"
26
+
27
+ err() { printf '[error] %s\n' "$*" >&2; }
28
+
29
+ PAYLOAD="${1:-}"
30
+ if [ -z "${PAYLOAD}" ]; then
31
+ err "사용법: bash ${BASH_SOURCE[0]} </absolute/path/payload.json>"
32
+ exit 1
33
+ fi
34
+ if [ ! -f "${PAYLOAD}" ]; then
35
+ err "payload 파일이 없다: ${PAYLOAD}"
36
+ exit 1
37
+ fi
38
+
39
+ MARKER="${PAYLOAD}.approved"
40
+
41
+ printf '\n'
42
+ printf '==================== 게시될 원문 (전문) ====================\n'
43
+ printf 'payload: %s\n' "${PAYLOAD}"
44
+ printf '%s\n' '------------------------------------------------------------'
45
+ cat -- "${PAYLOAD}"
46
+ printf '\n------------------------------------------------------------\n'
47
+
48
+ # jq 가 있고 JSON 이면 사람이 읽기 좋은 렌더링을 덧붙인다. 실패해도 무시 —
49
+ # 위의 원문 전문 출력이 승인 근거이고, 이 블록은 가독성 보조일 뿐이다.
50
+ if command -v jq >/dev/null 2>&1 && jq -e . "${PAYLOAD}" >/dev/null 2>&1; then
51
+ if jq -e 'has("title") or has("body")' "${PAYLOAD}" >/dev/null 2>&1; then
52
+ printf '읽기용 렌더링 (title / labels / body):\n'
53
+ jq -r '"제목: \(.title // "<없음>")\n라벨: \((.labels // []) | join(", "))\n본문:\n\(.body // "<없음>")"' "${PAYLOAD}" 2>/dev/null || true
54
+ printf '%s\n' '------------------------------------------------------------'
55
+ fi
56
+ fi
57
+ printf '위 원문이 public GitHub 저장소에 그대로 게시된다.\n'
58
+ printf '사내 정보·자격 증명이 남아 있지 않은지 마지막으로 확인하라.\n\n'
59
+
60
+ _rc=0
61
+ confirm "이 원문 그대로 GitHub 에 게시하는 것을 승인하시겠습니까?" || _rc=$?
62
+ case "${_rc}" in
63
+ 0) ;;
64
+ 2)
65
+ confirm_unavailable "bash ${BASH_SOURCE[0]} ${PAYLOAD}"
66
+ exit 1
67
+ ;;
68
+ *)
69
+ err "승인 거부됨. 마커를 만들지 않는다."
70
+ exit 1
71
+ ;;
72
+ esac
73
+
74
+ if command -v shasum >/dev/null 2>&1; then
75
+ HASH="$(shasum -a 256 -- "${PAYLOAD}" | awk '{print $1}')"
76
+ else
77
+ HASH="$(sha256sum -- "${PAYLOAD}" | awk '{print $1}')"
78
+ fi
79
+
80
+ {
81
+ printf '%s\n' "${HASH}"
82
+ printf '# approved-at: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
83
+ printf '# payload: %s\n' "${PAYLOAD}"
84
+ } > "${MARKER}"
85
+
86
+ printf '\n[ok] 승인 완료: %s\n' "${MARKER}"
87
+ printf ' 이 승인은 1회용이며, payload 내용이 변경되면 무효가 된다.\n'
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # lib/confirm.sh — 릴리스 승인 프롬프트 공용 구현 (release.sh / publish-if-changed.sh 공유).
3
+ #
4
+ # 왜 stdin 만 쓰는가:
5
+ # 이전 구현은 `read -r reply </dev/tty` 였다. git pre-push 훅은 stdin 으로 ref
6
+ # 정보를 받으므로 훅 안에서 프롬프트하려면 /dev/tty 가 필요했고, release.sh 가
7
+ # 그 패턴을 그대로 복사했다. 그런데 제어 터미널이 없는 환경(에이전트 셸,
8
+ # 컨테이너, CI)에서는 /dev/tty 열기 자체가 실패한다 — macOS 는 "Device not
9
+ # configured", Linux 는 "No such device or address". 그 결과 릴리스가 그 환경에서
10
+ # 아예 불가능했다.
11
+ #
12
+ # 더 나쁜 것은 실패가 조용했다는 점이다. read 가 실패해도 reply 는 빈 값이라
13
+ # case 가 `*` 로 떨어져 "거부" 로 처리됐고, 호출부는 "사용자가 거부함" 을 찍었다.
14
+ # 물어보지도 못한 것과 거부당한 것이 구별되지 않아 진짜 원인이 은폐됐다.
15
+ #
16
+ # `[ ! -e /dev/tty ]` 가드도 틀렸다. 존재와 열 수 있음은 다르다 — macOS 에서는
17
+ # /dev/tty 가 존재하지만 열리지 않으므로 가드를 통과한 뒤 똑같이 실패했다.
18
+ #
19
+ # 그래서 /dev/tty 를 버리고 stdin 으로 통일했다. 터미널에서는 stdin 이 곧 터미널이라
20
+ # 종전과 동일하게 동작하고, 터미널이 없으면 파이프로 승인을 전달할 수 있다:
21
+ # printf 'y\ny\n' | npm run release:minor
22
+ #
23
+ # 반환값 (2 를 1 과 반드시 구별해서 처리할 것):
24
+ # 0 승인 (y / yes)
25
+ # 1 거부 (그 외 응답)
26
+ # 2 물어볼 수 없음 — stdin 이 EOF 이거나 닫혀 있다
27
+ #
28
+ # CONFIRM_AUTO_YES=1 이면 프롬프트 없이 승인한다 (CI 전용).
29
+
30
+ # 색상은 호출부가 정의했으면 그대로 쓰고, 아니면 빈 문자열로 둔다.
31
+ _confirm_bold="${BOLD:-}"
32
+ _confirm_reset="${RESET:-}"
33
+
34
+ _confirm_err() { printf "%s\n" "$*" >&2; }
35
+
36
+ # confirm <메시지>
37
+ confirm() {
38
+ local message="$1"
39
+
40
+ if [ "${CONFIRM_AUTO_YES:-0}" = "1" ]; then
41
+ printf "%s[confirm]%s %s -> 자동 승인 (CONFIRM_AUTO_YES=1)\n" \
42
+ "${_confirm_bold}" "${_confirm_reset}" "${message}"
43
+ return 0
44
+ fi
45
+
46
+ local reply=""
47
+ printf "%s[confirm]%s %s [y/N]: " "${_confirm_bold}" "${_confirm_reset}" "${message}"
48
+
49
+ # read 실패 = EOF. 승인 거부(빈 응답)와 구별해야 하므로 종료 코드를 나눈다.
50
+ if ! IFS= read -r reply; then
51
+ printf "\n"
52
+ return 2
53
+ fi
54
+
55
+ case "${reply}" in
56
+ [yY]|[yY][eE][sS]) return 0 ;;
57
+ *) return 1 ;;
58
+ esac
59
+ }
60
+
61
+ # confirm 이 2 를 반환했을 때 호출한다. 무엇이 막혔고 어떻게 푸는지 안내한다.
62
+ # <컨텍스트> 는 재실행할 명령 (예: "npm run release:minor").
63
+ confirm_unavailable() {
64
+ local context="${1:-<명령>}"
65
+ _confirm_err ""
66
+ _confirm_err "[error] 승인 입력을 받을 수 없다 — stdin 이 EOF 이거나 닫혀 있다."
67
+ _confirm_err " (거부당한 것이 아니라 물어볼 수단이 없었다)"
68
+ _confirm_err ""
69
+ _confirm_err " 해결 방법:"
70
+ _confirm_err " 1) 대화형 터미널에서 실행한다"
71
+ _confirm_err " 2) 승인을 파이프로 전달한다:"
72
+ _confirm_err " printf 'y\\ny\\n' | ${context}"
73
+ _confirm_err " 3) CI 라면 자동 승인을 명시한다 (CI 전용):"
74
+ _confirm_err " release.sh -> --yes"
75
+ _confirm_err " pre-push 훅 -> AUTO_YES=1"
76
+ _confirm_err ""
77
+ }
@@ -36,24 +36,16 @@ AUTO_YES="${AUTO_YES:-0}"
36
36
  HOOK_STDIN="${HOOK_STDIN:-0}"
37
37
  SKIP_PUBLISH="${SKIP_PUBLISH:-0}"
38
38
 
39
- confirm() {
40
- local message="$1"
41
- if [ "$AUTO_YES" = "1" ]; then
42
- info "[AUTO_YES=1] 자동 승인: $message"
43
- return 0
44
- fi
45
- if [ ! -t 0 ] && [ ! -e /dev/tty ]; then
46
- err "TTY 없음 - 대화형 승인 불가. AUTO_YES=1 명시 필요."
47
- return 1
48
- fi
49
- local reply
50
- prompt "$message [y/N]: "
51
- read -r reply </dev/tty
52
- case "$reply" in
53
- [yY]|[yY][eE][sS]) return 0 ;;
54
- *) return 1 ;;
55
- esac
56
- }
39
+ # confirm() 은 lib/confirm.sh 공용 구현을 쓴다 (release.sh 와 동일).
40
+ #
41
+ # 주의: 스크립트는 pre-push 훅으로 실행되며 stdin 에 ref 정보가 들어온다.
42
+ # STEP 1 while 루프가 stdin 을 EOF 까지 읽으므로, 훅 경로에서 confirm 은
43
+ # 항상 2("물어볼 수 없음")를 반환한다. 이는 의도된 동작이다 — 훅이 사람을
44
+ # 붙잡고 묻는 대신 push 를 막고 안내한다. 배포는 release.sh 로 하거나
45
+ # CI 에서는 AUTO_YES=1 명시한다.
46
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/confirm.sh"
47
+
48
+ CONFIRM_AUTO_YES="${AUTO_YES}"
57
49
 
58
50
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
59
51
  PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -150,7 +142,20 @@ log "=========================================="
150
142
  info "이 push에는 다음 버전 변경이 포함되어 있습니다:"
151
143
  info " ${old_version:-<없음>} → ${BOLD}${new_version}${RESET}"
152
144
 
153
- if ! confirm "${BOLD}${YELLOW}이 버전을 공개 npm registry에 배포하시겠습니까?${RESET}"; then
145
+ _rc=0
146
+ confirm "${BOLD}${YELLOW}이 버전을 공개 npm registry에 배포하시겠습니까?${RESET}" || _rc=$?
147
+ if [ "${_rc}" -eq 2 ]; then
148
+ # 훅 경로에서는 stdin 이 ref 정보로 이미 소진되어 항상 여기로 온다.
149
+ # 사람을 붙잡고 묻는 대신 push 를 막고 정규 경로를 안내한다.
150
+ err "pre-push 훅에서는 배포 승인을 받을 수 없다 (stdin 이 ref 정보로 소진됨)."
151
+ err "선택지:"
152
+ err " 1) 정규 경로로 배포: npm run release:<patch|minor|major>"
153
+ err " 2) 이미 배포했다면 그대로 push (registry 확인 후 자동 건너뜀)"
154
+ err " 3) CI: AUTO_YES=1 git push"
155
+ err " 4) 배포 없이 push: git push --no-verify (권장하지 않음)"
156
+ exit 1
157
+ fi
158
+ if [ "${_rc}" -ne 0 ]; then
154
159
  err "사용자가 배포를 거부함 - push 차단"
155
160
  err "이 버전을 배포하지 않으려면:"
156
161
  err " 1) package.json 의 version을 이전 값으로 되돌린 커밋을 추가"
@@ -178,7 +183,14 @@ warn "이제 공개 npm registry (registry.npmjs.org) 에 $new_version 을(를)
178
183
  warn "배포 후에는 동일 버전 재-publish 가 registry에 의해 거부됩니다."
179
184
  warn "돌이킬 수 없는 작업입니다."
180
185
 
181
- if ! confirm "${BOLD}${RED}정말 공개 npm registry에 $new_version 을(를) 배포하시겠습니까?${RESET}"; then
186
+ _rc=0
187
+ confirm "${BOLD}${RED}정말 공개 npm registry에 ${new_version} 을(를) 배포하시겠습니까?${RESET}" || _rc=$?
188
+ if [ "${_rc}" -eq 2 ]; then
189
+ err "최종 배포 승인을 받을 수 없다 (stdin EOF). push 차단."
190
+ err "정규 경로로 배포하라: npm run release:<patch|minor|major>"
191
+ exit 1
192
+ fi
193
+ if [ "${_rc}" -ne 0 ]; then
182
194
  err "사용자가 최종 배포를 거부함 - push 차단"
183
195
  err "다시 시도하려면 git push 를 재실행하세요."
184
196
  exit 1
@@ -62,21 +62,15 @@ log "프로젝트 루트: $PROJECT_ROOT"
62
62
  log "Bump 유형: $BUMP"
63
63
 
64
64
  # ---------- 사용자 확인 헬퍼 ----------
65
- confirm() {
66
- local message="$1"
67
- if [ "$AUTO_YES" = true ]; then
68
- info "[--yes] 자동 승인: $message"
69
- return 0
70
- fi
71
-
72
- local reply
73
- prompt "$message [y/N]: "
74
- read -r reply </dev/tty
75
- case "$reply" in
76
- [yY]|[yY][eE][sS]) return 0 ;;
77
- *) return 1 ;;
78
- esac
79
- }
65
+ # confirm() 은 lib/confirm.sh 공용 구현을 쓴다. stdin 으로만 읽으므로 터미널이
66
+ # 없는 환경에서도 파이프로 승인을 전달할 수 있다:
67
+ # printf 'y\ny\n' | npm run release:minor
68
+ # 반환값 2 는 "물어볼 없음" 으로, 거부(1)와 반드시 구별해서 처리한다.
69
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/confirm.sh"
70
+
71
+ if [ "$AUTO_YES" = true ]; then
72
+ CONFIRM_AUTO_YES=1
73
+ fi
80
74
 
81
75
  # ---------- STEP 1: Pre-flight 체크 ----------
82
76
  log "=========================================="
@@ -96,10 +90,13 @@ ok "Working tree clean"
96
90
  CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
97
91
  if [ "$CURRENT_BRANCH" != "master" ] && [ "$CURRENT_BRANCH" != "main" ]; then
98
92
  warn "현재 브랜치가 master/main이 아님: $CURRENT_BRANCH"
99
- confirm "이 브랜치에서 릴리스를 진행하시겠습니까?" || {
100
- err "사용자가 취소함"
101
- exit 1
102
- }
93
+ _rc=0
94
+ confirm " 브랜치에서 릴리스를 진행하시겠습니까?" || _rc=$?
95
+ case "${_rc}" in
96
+ 0) ;;
97
+ 2) confirm_unavailable "npm run release:${BUMP}"; exit 1 ;;
98
+ *) err "사용자가 취소함"; exit 1 ;;
99
+ esac
103
100
  fi
104
101
  ok "브랜치: $CURRENT_BRANCH"
105
102
 
@@ -173,10 +170,13 @@ log "=========================================="
173
170
  log "STEP 4: 버전 bump 승인 (게이트 #1)"
174
171
  log "=========================================="
175
172
 
176
- confirm "${BOLD}${YELLOW}버전을 $CURRENT_VERSION → $NEXT_VERSION 로 올리시겠습니까?${RESET}" || {
177
- err "사용자가 버전 bump를 거부함. 릴리스 중단."
178
- exit 1
179
- }
173
+ _rc=0
174
+ confirm "${BOLD}${YELLOW}버전을 ${CURRENT_VERSION} ${NEXT_VERSION} 올리시겠습니까?${RESET}" || _rc=$?
175
+ case "${_rc}" in
176
+ 0) ;;
177
+ 2) confirm_unavailable "npm run release:${BUMP}"; exit 1 ;;
178
+ *) err "사용자가 버전 bump를 거부함. 릴리스 중단."; exit 1 ;;
179
+ esac
180
180
 
181
181
  # ---------- STEP 5: 버전 bump 실행 ----------
182
182
  log "=========================================="
@@ -228,12 +228,27 @@ warn "공개 배포이므로 누구나 설치할 수 있게 됩니다."
228
228
  warn "배포 후에는 동일 버전 재-publish 가 registry에 의해 거부됩니다."
229
229
  warn "돌이킬 수 없는 작업이므로 신중히 확인하세요."
230
230
 
231
- confirm "${BOLD}${RED}공개 npm registry에 $NEW_TAG 를 배포하시겠습니까?${RESET}" || {
231
+ _rc=0
232
+ confirm "${BOLD}${RED}공개 npm registry에 ${NEW_TAG} 를 배포하시겠습니까?${RESET}" || _rc=$?
233
+ if [ "${_rc}" -eq 2 ]; then
234
+ # 버전 bump 는 이미 커밋·태그로 남아 있다. 여기서는 confirm_unavailable 의 일반
235
+ # 안내(파이프로 승인 전달)를 쓰지 않는다 — 그 시점의 복구는 릴리스 재실행이 아니라
236
+ # 이미 bump 된 버전을 배포하거나 되돌리는 것이고, npm publish 는 승인을 stdin 으로
237
+ # 받지 않으므로 파이프 안내가 오히려 틀린 지시가 된다.
238
+ err "최종 배포 승인을 받을 수 없다 — stdin 이 EOF 이거나 닫혀 있다."
239
+ err "(거부당한 것이 아니라 물어볼 수단이 없었다)"
240
+ warn "버전 bump는 유지됩니다 (${NEW_TAG})."
241
+ info "배포하려면: npm publish && git push origin HEAD --follow-tags"
242
+ info "되돌리려면: git tag -d ${NEW_TAG} && git reset --hard HEAD~1"
243
+ info "처음부터 다시: 위에서 되돌린 뒤 printf 'y\\ny\\n' | npm run release:${BUMP}"
244
+ exit 1
245
+ fi
246
+ if [ "${_rc}" -ne 0 ]; then
232
247
  warn "사용자가 배포를 거부함. 버전 bump는 유지됩니다."
233
248
  info "나중에 배포하려면: npm publish && git push origin HEAD --follow-tags"
234
- info "취소하려면: git tag -d $NEW_TAG && git reset --hard HEAD~1"
249
+ info "취소하려면: git tag -d ${NEW_TAG} && git reset --hard HEAD~1"
235
250
  exit 0
236
- }
251
+ fi
237
252
 
238
253
  # ---------- STEP 8: npm publish ----------
239
254
  log "=========================================="
@@ -39,6 +39,7 @@ const STEPS = [
39
39
  "node test/skill-mcp-registry.test.mjs",
40
40
  "node --test test/state-write-guard.test.mjs",
41
41
  "node --test test/issue-reporter-guard.test.mjs",
42
+ "node --test test/release-confirm.test.mjs",
42
43
  "node --test test/state-sh-schema.test.mjs",
43
44
  "node --test test/state-sh-init-review-shape.test.mjs",
44
45
  "node --test test/doctor-phantom-scan.test.mjs",
@@ -100,7 +100,9 @@ GH_TOKEN="$(grep -oE '(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]+' "$PAT_FILE
100
100
  1. 첨부 후보 텍스트를 모은 뒤, **첨부 직전에** 마스킹 스캔을 1회 수행한다.
101
101
  2. 마스킹 대상 여부가 불확실한 라인은 첨부에서 **제외**한다(포함하고 판단을 미루지 않는다).
102
102
  3. 마스킹으로 인해 재현 정보가 소실되는 경우, 소실된 항목을 이슈 본문에 `<마스킹됨: 사유>`로 명시한다.
103
- 4. 이슈 전송 전, 마스킹 결과 요약(무엇을 몇 건 가렸는지)을 사용자에게 제시하고 **최종 확인을 받는다**. 사용자 승인 없이 전송하지 않는다.
103
+ 4. 이슈 전송 전, **게시될 원문 전체(제목·라벨·본문 전문)와 마스킹 결과 요약**(무엇을 몇 건 가렸는지)을 사용자에게 제시하고 **최종 확인을 받는다**. 사용자 승인 없이 전송하지 않는다.
104
+
105
+ > **4번은 프롬프트 규약이 아니라 플러그인 훅이 물리적으로 강제한다.** GitHub 쓰기 API 호출(이슈·코멘트·Gist·라벨)은 payload 파일에 대한 사용자 승인 마커(`<payload>.approved`)가 없거나, 승인 이후 내용이 변경되었으면 훅이 차단한다. 절차는 7장 참조.
104
106
 
105
107
  스캔 보조:
106
108
 
@@ -208,9 +210,11 @@ GitHub REST API의 issue 생성 엔드포인트는 **바이너리 파일 첨부
208
210
  curl -sS -X POST https://api.github.com/gists \
209
211
  -H "Authorization: Bearer $GH_TOKEN" \
210
212
  -H "Accept: application/vnd.github+json" \
211
- -d @gist-payload.json
213
+ -d @/tmp/makdoong2-issue/gist-payload.json
212
214
  ```
213
215
 
216
+ > Gist 생성도 GitHub 쓰기이므로 7-1 의 사용자 승인 게이트를 동일하게 거친다 (payload 절대 경로 + 원문 표시 + 승인 마커).
217
+
214
218
  ---
215
219
 
216
220
  ## 4. 항목 확정 (수집 우선 · 질의 최소화)
@@ -281,9 +285,11 @@ curl -sS -G https://api.github.com/search/issues \
281
285
 
282
286
  ```bash
283
287
  curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/issues/<number>/comments \
284
- -H "Authorization: Bearer $GH_TOKEN" -d @comment-payload.json
288
+ -H "Authorization: Bearer $GH_TOKEN" -d @/tmp/makdoong2-issue/comment-payload.json
285
289
  ```
286
290
 
291
+ > 코멘트 추가도 GitHub 쓰기이므로 7-1 의 사용자 승인 게이트를 동일하게 거친다.
292
+
287
293
  기존 열린 이슈(2026-08-26 기준):
288
294
 
289
295
  | # | 제목 요약 | 영역 | 링크 |
@@ -383,11 +389,28 @@ curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/issues/<n
383
389
 
384
390
  ## 7. 이슈 생성
385
391
 
392
+ ### 7-1. 사용자 승인 게이트 (훅 강제 — 생략 불가)
393
+
394
+ GitHub 쓰기 호출(이슈·코멘트·Gist·라벨 생성)은 다음 절차를 거쳐야만 훅을 통과한다:
395
+
396
+ 1. payload 를 **리터럴 절대 경로** 파일로 작성한다 (예: `/tmp/makdoong2-issue/issue-payload.json`). 상대 경로·변수 포함 경로는 훅이 거부한다.
397
+ 2. **게시될 원문 전체를 채팅에 그대로 표시한다** — 제목, 라벨, 본문 전문. 요약·발췌로 대체하지 않는다.
398
+ 3. 사용자에게 승인 스크립트 실행을 안내하고 **대기한다**:
399
+ ```
400
+ bash <SCRIPTS_DIR>/issue-reporter-approve.sh /tmp/makdoong2-issue/issue-payload.json
401
+ ```
402
+ `<SCRIPTS_DIR>` 는 npm 모듈의 scripts 디렉토리다 (전역 설치 기준 `$(npm root -g)/makdoong2-team/scripts`). 스크립트는 payload 원문을 다시 화면에 보여주고 stdin 으로 승인을 받은 뒤 `<payload>.approved` 마커(sha256)를 기록한다.
403
+ 4. 마커가 생긴 뒤 **단일 curl 명령**으로 전송한다. 체이닝(`;`, `&&`, `|`)·리다이렉트·인라인 JSON(`-d '{...}'`)은 훅이 거부한다.
404
+
405
+ 승인은 **1회용**이고 **내용에 바인딩**된다: 전송이 실행되면 마커는 훅이 자동 삭제하며, payload 를 승인 후 수정하면 해시 불일치로 차단된다 (수정했으면 2번부터 재승인). 에이전트가 승인 스크립트를 직접 실행하거나 `.approved` 마커를 조작하는 것은 훅이 차단한다.
406
+
407
+ ### 7-2. 전송
408
+
386
409
  ```bash
387
410
  curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/issues \
388
411
  -H "Authorization: Bearer $GH_TOKEN" \
389
412
  -H "Accept: application/vnd.github+json" \
390
- -d @issue-payload.json
413
+ -d @/tmp/makdoong2-issue/issue-payload.json
391
414
  ```
392
415
 
393
416
  `issue-payload.json`은 `title`, `body`, `labels`를 포함한다.
@@ -405,18 +428,21 @@ curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/issues \
405
428
  curl -sS https://api.github.com/repos/y00njinuk/makdoong2-team/labels \
406
429
  -H "Authorization: Bearer $GH_TOKEN"
407
430
 
408
- # 라벨 신규 생성 (사용자 승인 후에만)
431
+ # 라벨 신규 생성 다른 쓰기와 동일하게 7-1 승인 게이트를 거친다.
432
+ # 인라인 JSON 은 훅이 거부하므로 라벨도 payload 파일로 전달한다.
409
433
  curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/labels \
410
434
  -H "Authorization: Bearer $GH_TOKEN" \
411
- -d '{"name":"severity:blocker","color":"b60205"}'
435
+ -d @/tmp/makdoong2-issue/label-payload.json
412
436
  ```
413
437
 
414
438
  **본문은 반드시 파일(`-d @file`)로 전달한다.** 코드블록·백틱·개행이 포함되므로 셸 인라인 문자열로 전달하면 깨진다. 전송 후 페이로드 임시 파일은 삭제한다.
415
439
 
416
440
  ```bash
417
- shred -u issue-payload.json gist-payload.json 2>/dev/null || rm -f issue-payload.json gist-payload.json
441
+ shred -u /tmp/makdoong2-issue/*-payload.json 2>/dev/null || rm -f /tmp/makdoong2-issue/*-payload.json
418
442
  ```
419
443
 
444
+ 승인 마커(`*.approved`)는 전송 시 훅이 자동 삭제하므로 에이전트가 지우지 않는다 (마커 조작은 훅이 차단).
445
+
420
446
  ---
421
447
 
422
448
  ## 8. 완료 보고