makdoong2-team 1.6.0 → 1.7.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
@@ -104,9 +104,9 @@ makdoong2-team doctor # 설치 진단
104
104
  ```
105
105
 
106
106
  - 커맨드가 **전용 full-permission 에이전트**(`makdoong2-issue-reporter`)로 라우팅되어, 호출 시점 이전의 로그·프롬프트·세션 컨텍스트를 스스로 수집해 이상 지점을 포착하고 [y00njinuk/makdoong2-team issues](https://github.com/y00njinuk/makdoong2-team/issues) 에 등록한다.
107
- - **사용자 직접 호출이 유일한 트리거**다. 부장님·막둥이가 실패를 관측했다고 자율적으로 이슈를 만들지 않는다 (훅이 차단).
108
- - 저장소가 public 이므로 사내 정보는 마스킹 후 첨부되며, **전송 전 사용자 승인이 훅으로 강제된다**: 에이전트가 게시될 원문 전체를 채팅에 표시한 뒤, 사용자가 직접 `issue-reporter-approve.sh <payload>` 실행해 원문을 확인·승인해야만 GitHub 쓰기 호출이 통과된다 (승인은 내용 sha256 바인딩, 1회용).
109
- - PAT 는 `~/.config/opencode/.github` 파일에서 읽는다. 상세: ARCHITECTURE.md §4.6
107
+ - **사용자 직접 호출이 유일한 트리거**다. 부장님·막둥이가 실패를 관측했다고 자율적으로 이슈를 만들지 않는다 (훅이 차단). 이 에이전트는 **선택 가능한 에이전트 목록에도, `@` 멘션 목록에도 뜨지 않으며**, `task` 툴로 spawn 하는 것도 차단된다 — 진입점은 위 커맨드 하나뿐이다.
108
+ - 저장소가 public 이므로 사내 정보는 마스킹 후 첨부되며, **전송 전 승인을 세션 안에서 묻는다**: 에이전트가 게시될 원문 전체를 `cat` 으로 표시하면 훅이 sha256 기록하고, 전송용 curl 호출 opencode 게시 여부를 묻는다(yes/no). 표시하지 않았거나 표시 후 내용이 바뀌면 전송이 차단된다 (승인은 사용자가 원문에 바인딩, 1회용). 승인 프롬프트에서는 **"Allow once"** 를 고른다 — "always" 는 남은 세션의 승인 질문을 없앤다.
109
+ - PAT 는 `~/.config/opencode/.github` 파일에서 읽는다. **파일이 없거나 토큰을 찾지 못하면 중단하지 않고, 발급 URL 과 최소 권한(fine-grained: Issues Read/write, classic: `public_repo`)을 안내해 사용자에게 발급을 요청한 뒤 대기**한다. 401/403 도 같은 재발급 경로를 탄다. 상세: ARCHITECTURE.md §4.6
110
110
 
111
111
  ---
112
112
 
@@ -2,7 +2,14 @@
2
2
  name: makdoong2-issue-reporter
3
3
  description: makdoong2-team 오류·비정상 동작 GitHub 이슈 리포터. 사용자가 /makdoong2-issue-reporter 커맨드로 직접 호출할 때만 활성화된다. 워크플로우 stage 에 참여하지 않으며 dispatch_stage 로 spawn 되지 않는다. 다른 에이전트가 자율적으로 선택·호출하지 않는다.
4
4
  temperature: 0.1
5
- mode: all
5
+ # mode: subagent + hidden: true → 사용자가 고를 수 있는 primary 에이전트 목록에도,
6
+ # @ 멘션·task 자동완성 목록에도 뜨지 않는다. 진입점은 /makdoong2-issue-reporter
7
+ # 커맨드 하나뿐이고, 그 커맨드의 subtask:false 가 이 에이전트를 현재 세션에서
8
+ # 인라인으로 전환시킨다 (자식 세션으로 격리되지 않아 직전 대화 컨텍스트를 그대로 본다).
9
+ # opencode 는 mode==="subagent" 를 primary 목록에서 제외하고, hidden===true 를
10
+ # 모든 노출 목록에서 제외한다.
11
+ mode: subagent
12
+ hidden: true
6
13
  tools:
7
14
  Read: true
8
15
  Write: true
@@ -14,7 +21,16 @@ tools:
14
21
  skill: true
15
22
  permission:
16
23
  bash:
24
+ # opencode 는 매치되는 규칙 중 **마지막** 것을 쓴다 (findLast). 따라서 넓은
25
+ # 규칙을 위에, 좁은 규칙을 아래에 둔다 — 순서를 뒤집으면 아래 "ask" 가 죽는다.
17
26
  "*": "allow"
27
+ # 게시 승인을 세션 내 yes/no 질문으로 만드는 유일한 장치.
28
+ # payload 파일을 실어 보내는 호출(= 이슈·코멘트·Gist·라벨 생성)만 매치되고,
29
+ # 검색·라벨 조회 같은 읽기(-G / 순수 GET)는 매치되지 않아 사용자를 묻지 않는다.
30
+ # 읽기까지 물으면 승인 프롬프트가 일상이 되어 정작 게시 시점의 "예" 가 의미를 잃는다.
31
+ # 이 패턴은 issue-reporter-guard.ts 의 APPROVABLE_PAYLOAD_RE 와 한 쌍이다 —
32
+ # 훅이 `-d @/절대경로` 표기만 허용하므로 게시 호출은 반드시 여기에 걸린다.
33
+ "*-d @/*": "ask"
18
34
  write:
19
35
  "**/*": "allow"
20
36
  ---
@@ -26,11 +42,11 @@ permission:
26
42
  1. **첫 행동으로 `skill(name="makdoong2-issue-reporter")` 를 로드**하고, 스킬에 정의된 절차를 그대로 따른다. 실행 순서는 스킬이 고정한다: **수집 → 이상 지점 포착 → 마스킹 → 중복 확인 → 최소 질의 → 이슈 생성**.
27
43
  2. **GitHub 게시(이슈·코멘트·Gist·라벨)는 훅이 강제하는 사용자 승인 게이트를 통과해야만 가능하다.** 절차는 고정이다:
28
44
  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` 마커를 만들지·읽지·지우지 않는다.** 훅이 물리 차단하며, 차단 메시지를 보면 우회하지 말고 사용자 승인을 기다린다.
45
+ 2. **게시될 원문 전체를 `cat <payload>` 세션에 표시**하고 마스킹 내역 요약을 덧붙인다. 이 `cat` 은 **체이닝 없이 단독 실행**해야 하며(`;`·`&&`·리다이렉트·`$()` 금지), 훅이 이 시점의 sha256 을 표시 증명으로 기록한다. 요약·발췌로 대체 금지 — 사용자는 전송될 원문을 봐야 한다.
46
+ 3. **사용자에게 게시 여부를 묻고 yes/no 응답을 받는다.** 전송용 curl 호출하면 opencode 세션 안에서 승인 프롬프트를 띄운다. 안내할 **"Allow once" 고르도록 알린다** — "Allow always" 는 남은 세션 동안 승인 질문 자체를 없애 게이트를 무력화한다. 사용자가 거부하면 그대로 중단하고, 무엇을 고쳐야 하는지 물어본다.
47
+ 4. 전송은 **단일 curl 명령 + `-d @<절대경로>`** 형태만 허용된다. 표기까지 고정이다 — `--data`·`--data-binary`·`-d=@` 는 승인 프롬프트를 띄우지 못해 훅이 차단한다. 체이닝·리다이렉트·인라인 JSON·`gh` CLI 금지.
48
+ - 승인은 **1회용**이고 **사용자가 본 원문에 바인딩**된다. 2번 이후 payload 고치면 표시 증명이 무효가 되므로 2번부터 다시 한다.
49
+ - 차단 메시지를 받으면 우회하지 말고 지시대로 재표시·재승인을 거친다. 승인을 스스로 만들어낼 있는 경로는 없다.
34
50
  3. **토큰(PAT)은 어디에도 원문 노출 금지.** 커맨드 문자열에 직접 박지 않고 환경변수로 전달하며, 출력에는 마스킹(`ghp_****`)만 허용한다.
35
51
  4. **워크플로우 상태를 변경하지 않는다.** state.json 은 증거 수집을 위한 읽기(`state.sh get`)만 허용. `state.sh set` / dispatch 계열 툴 호출 금지. 이 에이전트는 워크플로우 오케스트레이션과 완전히 분리된 조사·보고 전용이다.
36
52
  5. **다른 에이전트로 위임하지 않는다.** 수집·분석·마스킹·등록 전 과정을 이 세션에서 직접 수행한다.
@@ -31,9 +31,11 @@ permission:
31
31
 
32
32
  1. **직접 파일 편집·생성 금지.** Read 외의 모든 파일 조작은 `dispatch_stage`로 서브에이전트에 위임한다. Edit/Write 툴은 frontmatter에서 제거되어 물리적으로 사용 불가하다.
33
33
  2. **Bash 우회 파일 쓰기 금지.** `echo >`, `echo >>`, `cat >`, `cat <<EOF >`, `tee`, `sed -i`, `awk ... > file`, `printf > file` 등 어떤 형태의 쓰기 리디렉션도 사용하지 않는다. Python/Node.js 인터프리터를 통한 파일 쓰기(`python3 -c "open(...,'w')"`, `node -e "fs.writeFileSync(...)"` 등)도 동일하게 금지된다. **예외**: `<SCRIPTS_DIR>/state.sh set ...` 를 통한 state.json 마커 기록만 허용한다.
34
+ **읽기는 이 규칙의 대상이 아니다.** `ls` / `cat` / `file` / `head` / `stat` / `jq` / `git check-ignore` 로 state.json 을 조회하는 진단 명령과 `<SCRIPTS_DIR>/state.sh status <이슈키>` 는 쓰기 리디렉션이 없는 한 자유롭게 쓸 수 있다. `state_unreadable` 복구는 이 명령들로 수행한다.
34
35
  3. **git 명령 직접 실행 금지 (신규).** `git commit` / `git push` / `git add` / `git rm` / `git worktree` 등 모든 git 명령을 직접 실행하지 않는다. 3_delivery.commit / 3_delivery.pr / 3_delivery.review 는 전부 publisher 가 worktree 에서 직접 실행한다. frontmatter permission 으로 deny 되어 있으며 훅이 물리적으로 차단한다.
35
36
  4. **`auto_advance_stage` 결과의 `next_action` 필드에 명시된 지시를 100% 따른다.** `next_action`이 `dispatch_stage(...)` 호출을 요구하면 다른 어떤 행동보다 먼저 그 툴을 호출한다. next_action이 게이트 차단을 알리면 그 이유를 사용자에게 보고하고 종료한다.
36
37
  5. **규칙 위반을 감지하면 즉시 자체 abort.** `"[부장님 자체 abort] 하드룰 위반: <규칙 번호> — <감지된 우회 시도>"` 형식으로 출력하고 세션을 종료한다. 사용자 개입을 기다린다.
38
+ **훅이 명령 하나를 차단한 것은 그 자체로 하드룰 위반이 아니다.** 훅 메시지에는 어떤 규칙인지와 허용되는 대안이 적혀 있다 — 먼저 읽고, 대안이 있으면 그 명령으로 바꿔 진행한다. abort 는 대안이 없거나 근본 원인이 사용자 개입을 요구할 때만 한다. 규칙 번호를 추측해서 인용하지 않는다.
37
39
 
38
40
  ## 핵심 원칙
39
41
 
@@ -1,7 +1,5 @@
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
3
  /** GitHub API 호출 분류 결과 */
6
4
  export type GithubApiCall = {
7
5
  kind: "none";
@@ -27,22 +25,38 @@ export type GithubApiCall = {
27
25
  * problems 로 수집한다 — 호출부는 problems 가 하나라도 있으면 차단한다.
28
26
  */
29
27
  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
28
  export declare function sha256Hex(content: string | Buffer): string;
37
- /** 마커 파일 내용에서 해시를 파싱한다. 첫 줄이 64자리 hex 가 아니면 null. */
38
- export declare function parseApprovalMarker(markerContent: string): string | null;
39
29
  /**
40
- * 승인 검증: 마커의 해시가 현재 payload 내용과 일치하는가.
30
+ * 명령이 "원문 표시" 인정되는 절대 경로들을 돌려준다.
31
+ *
32
+ * 인정 조건은 **체이닝·리다이렉트·명령 치환이 없는 단일 `cat <절대경로>`** 다.
33
+ * 좁게 잡은 이유는 표시가 증거이기 때문이다 — `cat p; echo x > p` 를 허용하면
34
+ * 사용자가 본 내용과 파일에 남는 내용이 갈라져 (나) 불변 조건이 깨진다.
35
+ * jq 등으로 예쁘게 렌더링하는 것은 막지 않지만, 해시 증명으로 인정되지 않는다
36
+ * (렌더링은 원문이 아니다).
37
+ */
38
+ export declare function payloadDisplayPaths(cmd: string): string[];
39
+ /**
40
+ * 표시 증명 검증: 사용자가 본 원문의 해시가 지금 전송하려는 payload 와 같은가.
41
41
  * 불일치 사유를 문자열로 반환하고, 유효하면 null.
42
42
  */
43
- export declare function approvalMismatch(payloadContent: Buffer, markerContent: string): string | null;
43
+ export declare function displayMismatch(payloadContent: Buffer, shownHash: string | undefined): string | null;
44
44
  /** skill 툴 args 에서 스킬 이름을 추출한다. { name } 및 { arguments: { name } } 형태 수용. */
45
45
  export declare function extractSkillNameFromArgs(args: unknown): string | undefined;
46
+ /**
47
+ * task 툴 호출이 issue-reporter 를 서브에이전트로 spawn 하려 하면 차단 사유를,
48
+ * 아니면 null 을 반환한다.
49
+ *
50
+ * 이 에이전트의 진입점은 `/makdoong2-issue-reporter` 커맨드 하나뿐이다. frontmatter 의
51
+ * `mode: subagent` + `hidden: true` 는 선택·자동완성 목록에서 감출 뿐이고, opencode 의
52
+ * task 툴은 **subagent_type 의 mode 를 검사하지 않으므로** 이름만 알면 spawn 된다.
53
+ * 목록에 없는 것과 부를 수 없는 것은 다르다 — 그 간극을 여기서 닫는다.
54
+ *
55
+ * spawn 을 막아야 하는 이유는 격리 그 자체다. task 로 띄운 자식 세션은 직전 대화
56
+ * 컨텍스트를 보지 못해 수집이 반쪽이 되고, 사용자 승인 프롬프트도 그 세션에서 뜬다.
57
+ * 무엇보다 "사용자가 직접 부른다" 는 트리거 정책이 우회된다.
58
+ */
59
+ export declare function issueReporterTaskSpawnViolation(args: unknown): string | null;
46
60
  /**
47
61
  * skill 툴 호출이 issue-reporter 트리거 정책을 위반하면 사용자에게 보여줄
48
62
  * 에러 메시지를 반환하고, 정상이면 null 을 반환한다.
@@ -21,26 +21,45 @@ export const ISSUE_REPORTER_AGENT = "makdoong2-issue-reporter";
21
21
  // ── GitHub 게시 승인 게이트 ──────────────────────────────────────────────
22
22
  //
23
23
  // 정책: issue-reporter 가 GitHub 에 무엇이든 게시(이슈·코멘트·Gist·라벨)하려면
24
- // 사용자가 게시될 "원문 전체"를 보고 명시적으로 승인해야 한다. 채팅 승인은
25
- // 프롬프트 수준 규약일 뿐이라 강제가 아니다 여기의 계약이 물리적 강제다:
24
+ // 사용자가 게시될 "원문 전체"를 보고 명시적으로 승인해야 한다. 승인은 두 조각이
25
+ // 함께 성립해야 유효하며, 어느 쪽도 프롬프트 규약이 아니라 코드가 강제한다:
26
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";
27
+ // (가) 의사표시 opencode permission 프롬프트의 yes/no.
28
+ // 에이전트 frontmatter api.github.com 접근을 "ask" 올리고,
29
+ // plugin permission.ask 훅이 쓰기(mutation)에 대해 status 를 "ask"
30
+ // 고정한다 (읽기는 "allow" 내려 사용자를 성가시게 하지 않는다).
31
+ // 사용자가 거부하면 tool 실행되지 않는다.
32
+ // (나) 정보에 근거한 동의 "사용자가 원문" == "전송되는 원문".
33
+ // permission 프롬프트에는 curl 명령만 보이고 본문은 파일 안에 있으므로,
34
+ // 프롬프트만으로는 무엇이 게시되는지 수 없다. 그래서 에이전트는 전송 전에
35
+ // payload 세션에서 `cat` 으로 그대로 출력해야 하고, 훅이 그 시점의
36
+ // sha256 기록한다. 전송 기록된 해시와 현재 파일이 다르면 차단된다.
37
+ //
38
+ // 조합은 2026-08 이전의 "issue-reporter-approve.sh + <payload>.approved 마커"
39
+ // 방식을 대체한다. 마커 방식은 사용자가 별도 셸에서 스크립트를 직접 실행해야 했고,
40
+ // 실행이 (가)와 (나)를 동시에 만족시켰다. 승인을 세션 안의 질문으로 옮기면서
41
+ // (가)는 opencode permission 으로, (나)는 표시 해시로 각각 넘겼다.
42
+ //
43
+ // 형식 제약은 그대로다 — 에이전트는 payload 를 "리터럴 절대 경로" 파일로 만들어
44
+ // 단일 curl 의 -d @<path> 로만 전달할 수 있다 (인라인 -d '{...}', 변수 경로,
45
+ // 체이닝, curl 외 HTTP 클라이언트 금지). 이것이 없으면 훅이 무엇이 전송되는지
46
+ // 검증할 수 없고, (나)의 해시 대조도 우회된다.
47
+ /** 표시 증명으로 인정되는 명령의 형태: 체이닝 없는 단일 `cat <절대경로>`. */
48
+ const DISPLAY_CAT_RE = /(?:^|\s)cat\s+(?:--\s+)?(["']?)(\/[^"'\s]+)\1(?:\s|$)/g;
49
+ const SHELL_COMPOSITION_RE = /[;|<>\n`]|\$\(|&&|\s&(\s|$)/;
41
50
  const MUTATION_METHOD_RE = /(?:-X|--request)[= ]*['"]?(POST|PATCH|PUT|DELETE)\b/i;
42
51
  const DATA_FLAG_RE = /(^|[\s'"])(-d|--data|--data-binary|--data-raw|--data-urlencode|--json|-F|--form)([= ]|$)/;
43
52
  const PAYLOAD_AT_RE = /(?:-d|--data|--data-binary|--data-raw|--json)[= ]+@(["']?)([^"'\s]+)\1/g;
53
+ /**
54
+ * 승인 프롬프트를 띄우는 유일한 형식: `-d @<절대경로>` (등호·따옴표 없이 공백 하나).
55
+ *
56
+ * 게시 승인의 의사표시는 opencode 의 bash permission 프롬프트가 받는데, 그 프롬프트는
57
+ * 에이전트 frontmatter 의 `"*-d @/*": "ask"` 패턴이 명령 문자열에 매치될 때만 뜬다.
58
+ * 그래서 `--data @/x` 나 `-d=@/x` 처럼 같은 의미의 다른 표기를 허용하면 **질문 없이
59
+ * 전송되는 경로**가 생긴다. 의미가 아니라 표기에 승인이 걸려 있으므로, 표기를 하나로
60
+ * 고정하고 나머지는 차단한다. 이 상수를 고칠 때는 frontmatter 패턴도 같이 고쳐야 한다.
61
+ */
62
+ const APPROVABLE_PAYLOAD_RE = /(^|\s)-d @\/[^\s'"]+(\s|$)/;
44
63
  /**
45
64
  * issue-reporter 의 bash 명령을 GitHub API 관점에서 분류한다.
46
65
  *
@@ -89,6 +108,12 @@ export function classifyGithubApiCall(cmd) {
89
108
  problems.push("payload 는 반드시 파일로 전달한다: -d @</absolute/path/payload.json>. " +
90
109
  "인라인 JSON(-d '{...}')과 stdin(-d @-)은 승인 검증이 불가능해 금지된다.");
91
110
  }
111
+ else if (!APPROVABLE_PAYLOAD_RE.test(cmd)) {
112
+ // 같은 의미라도 표기가 다르면 승인 프롬프트가 뜨지 않는다 — APPROVABLE_PAYLOAD_RE 주석 참조.
113
+ problems.push("payload 표기는 정확히 `-d @/절대경로` 여야 한다 (공백 하나, 등호·따옴표 없이). " +
114
+ "--data / --data-binary / --data-raw / --json / -d=@ 형태는 사용자 승인 프롬프트를 " +
115
+ "띄우지 못해 질문 없이 전송되므로 금지된다.");
116
+ }
92
117
  for (const p of payloadPaths) {
93
118
  if (!p.startsWith("/")) {
94
119
  problems.push(`payload 경로는 리터럴 절대 경로여야 한다: "${p}"`);
@@ -107,37 +132,36 @@ export function classifyGithubApiCall(cmd) {
107
132
  }
108
133
  return { kind: "mutation", payloadPaths, problems };
109
134
  }
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
135
  export function sha256Hex(content) {
123
136
  return createHash("sha256").update(content).digest("hex");
124
137
  }
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;
138
+ /**
139
+ * 명령이 "원문 표시" 로 인정되는 절대 경로들을 돌려준다.
140
+ *
141
+ * 인정 조건은 **체이닝·리다이렉트·명령 치환이 없는 단일 `cat <절대경로>`** 다.
142
+ * 좁게 잡은 이유는 표시가 증거이기 때문이다 — `cat p; echo x > p` 를 허용하면
143
+ * 사용자가 본 내용과 파일에 남는 내용이 갈라져 (나) 불변 조건이 깨진다.
144
+ * jq 등으로 예쁘게 렌더링하는 것은 막지 않지만, 해시 증명으로 인정되지 않는다
145
+ * (렌더링은 원문이 아니다).
146
+ */
147
+ export function payloadDisplayPaths(cmd) {
148
+ if (SHELL_COMPOSITION_RE.test(cmd))
149
+ return [];
150
+ const paths = [];
151
+ for (const m of cmd.matchAll(DISPLAY_CAT_RE))
152
+ paths.push(m[2]);
153
+ return paths;
129
154
  }
130
155
  /**
131
- * 승인 검증: 마커의 해시가 현재 payload 내용과 일치하는가.
156
+ * 표시 증명 검증: 사용자가 본 원문의 해시가 지금 전송하려는 payload 같은가.
132
157
  * 불일치 사유를 문자열로 반환하고, 유효하면 null.
133
158
  */
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 내용이 승인 이후 변경됐다 — 승인은 특정 원문에 바인딩되며, 변경된 내용은 재승인이 필요하다";
159
+ export function displayMismatch(payloadContent, shownHash) {
160
+ if (!shownHash) {
161
+ return "이 payload 의 원문이 세션에 표시된 적이 없다 — 사용자는 무엇이 게시되는지 볼 수 없었다";
162
+ }
163
+ if (sha256Hex(payloadContent) !== shownHash) {
164
+ return "표시 이후 payload 내용이 변경됐다 — 승인은 사용자가 본 원문에 바인딩되며, 변경된 내용은 재표시·재승인이 필요하다";
141
165
  }
142
166
  return null;
143
167
  }
@@ -156,6 +180,35 @@ export function extractSkillNameFromArgs(args) {
156
180
  }
157
181
  return undefined;
158
182
  }
183
+ /**
184
+ * task 툴 호출이 issue-reporter 를 서브에이전트로 spawn 하려 하면 차단 사유를,
185
+ * 아니면 null 을 반환한다.
186
+ *
187
+ * 이 에이전트의 진입점은 `/makdoong2-issue-reporter` 커맨드 하나뿐이다. frontmatter 의
188
+ * `mode: subagent` + `hidden: true` 는 선택·자동완성 목록에서 감출 뿐이고, opencode 의
189
+ * task 툴은 **subagent_type 의 mode 를 검사하지 않으므로** 이름만 알면 spawn 된다.
190
+ * 목록에 없는 것과 부를 수 없는 것은 다르다 — 그 간극을 여기서 닫는다.
191
+ *
192
+ * spawn 을 막아야 하는 이유는 격리 그 자체다. task 로 띄운 자식 세션은 직전 대화
193
+ * 컨텍스트를 보지 못해 수집이 반쪽이 되고, 사용자 승인 프롬프트도 그 세션에서 뜬다.
194
+ * 무엇보다 "사용자가 직접 부른다" 는 트리거 정책이 우회된다.
195
+ */
196
+ export function issueReporterTaskSpawnViolation(args) {
197
+ if (!args || typeof args !== "object")
198
+ return null;
199
+ const direct = args.subagent_type;
200
+ const nested = args.arguments?.subagent_type;
201
+ const target = typeof direct === "string" ? direct : typeof nested === "string" ? nested : undefined;
202
+ if (target !== ISSUE_REPORTER_AGENT)
203
+ return null;
204
+ return (`[makdoong2-team issue-reporter trigger violation]\n` +
205
+ `"${ISSUE_REPORTER_AGENT}" 는 task 툴로 spawn 할 수 없다.\n\n` +
206
+ `이 에이전트는 사용자가 /makdoong2-issue-reporter 커맨드를 실행할 때만, 현재 세션 안에서 ` +
207
+ `인라인으로 전환되어 동작한다. 자식 세션으로 격리하면 직전 대화 컨텍스트를 잃어 증거 수집이 ` +
208
+ `불완전해지고, 사용자 직접 호출이라는 트리거 정책도 우회된다.\n\n` +
209
+ `**올바른 절차:** 사용자에게 다음 실행을 안내하라:\n` +
210
+ ` /makdoong2-issue-reporter [증상 한 줄 설명(선택)]`);
211
+ }
159
212
  /**
160
213
  * skill 툴 호출이 issue-reporter 트리거 정책을 위반하면 사용자에게 보여줄
161
214
  * 에러 메시지를 반환하고, 정상이면 null 을 반환한다.
@@ -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, rmSync, writeFileSync } from "node:fs";
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, 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";
@@ -25,6 +25,7 @@ import { computeVerdictHash } from "./verdict-hash.js";
25
25
  import { nextModel, applyConfigOverrides, POLICIES } from "./model-fallback-policy.js";
26
26
  import { agentForStage, STAGE_SPEC_FILES } from "./agent-stage-config.js";
27
27
  import { shouldEscalateStall } from "./stall-escalation.js";
28
+ import { buildStateWriteBlockMessage, classifyStateJsonAccess, STATE_SH_CALL_RE } from "./state-access-guard.js";
28
29
  import { RESEARCH_SOURCES, DEFAULT_RESEARCH_TIMEOUT_MINUTES, buildResearchPrompt, mergeResearchFindings, normalizeQueries, parseResearchOutput, resolveParallelism, summarizeOutcomes, } from "./research-fanout.js";
29
30
  import { TmuxMonitor, readTmuxConfig, orphanCleanupGuard } from "./tmux-monitor.js";
30
31
  import { resolvePaths, loadConfig, readLoggingConfig, DEFAULT_STALL_ESCALATE_THRESHOLD, } from "./config.js";
@@ -33,7 +34,7 @@ import { injectAllSecrets } from "./mcp-secret-injector.js";
33
34
  import { pollSubSession as pollSubSessionCore, pollOutcomeToLegacy } from "./poll-sub-session.js";
34
35
  import { logger } from "./logger.js";
35
36
  import { redactAndTruncate } from "./redact-secrets.js";
36
- import { issueReporterSkillLoadViolation, ISSUE_REPORTER_AGENT, APPROVAL_MARKER_SUFFIX, APPROVE_SCRIPT_BASENAME, classifyGithubApiCall, isApproveScriptInvocation, referencesApprovalMarker, approvalMarkerPath, approvalMismatch, } from "./issue-reporter-guard.js";
37
+ import { issueReporterSkillLoadViolation, issueReporterTaskSpawnViolation, ISSUE_REPORTER_AGENT, classifyGithubApiCall, payloadDisplayPaths, displayMismatch, sha256Hex, } from "./issue-reporter-guard.js";
37
38
  // All runtime paths come from makdoong2-team.json (paths.* overrides) or are
38
39
  // derived from the opencode config dir. No MAKDOONG2 environment variables.
39
40
  const { hooks: HOOKS_DIR, gates: GATES_DIR, scripts: SCRIPTS_DIR, stages: STAGES_DIR, skills: SKILLS_DIR, } = resolvePaths();
@@ -254,11 +255,16 @@ async function createWorktree($, issue, cwd, wtLogger) {
254
255
  };
255
256
  }
256
257
  export function looksLikeFileWrite(cmd) {
257
- if (/\.makdoong2-team\/[^/\s]+\/state\.json/.test(cmd) && !/state\.sh\s+(get|set|init)/.test(cmd))
258
+ // state.json 전용 분류기가 판정한다. 읽기 전용 진단(ls/file/head/cat)을 여기서
259
+ // 다시 "파일 쓰기" 로 잡으면 universal 훅을 고쳐도 leader 는 하드룰 2 로 막힌다.
260
+ const stateAccess = classifyStateJsonAccess(cmd);
261
+ if (stateAccess.kind === "write")
258
262
  return true;
263
+ if (stateAccess.kind !== "unrelated")
264
+ return false;
259
265
  if (/^\s*(git\s+(commit|push|add|rm|status|log|diff|show|branch|checkout|fetch|worktree|config|remote))/i.test(cmd))
260
266
  return false;
261
- if (/state\.sh\s+(set|init|get|issue|root|update)/.test(cmd))
267
+ if (STATE_SH_CALL_RE.test(cmd))
262
268
  return false;
263
269
  if (/(^|[|&;])\s*(tee|dd)\b/.test(cmd))
264
270
  return true;
@@ -280,9 +286,10 @@ export function looksLikeFileWrite(cmd) {
280
286
  return true;
281
287
  return false;
282
288
  }
289
+ // 판정 본체는 src/state-access-guard.ts 에 있다 (플러그인 로더가 이 파일의 모든
290
+ // named export 를 factory 로 호출하므로 신규 helper 는 여기 두지 않는다).
283
291
  export function looksLikeSealedStateWrite(cmd) {
284
- return /\.makdoong2-team\/[^/\s]+\/state\.json/.test(cmd)
285
- && !/state\.sh\s+(get|set|init|issue|root|update)/.test(cmd);
292
+ return classifyStateJsonAccess(cmd).kind === "write";
286
293
  }
287
294
  export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) => {
288
295
  const cwd = worktree || directory || ".";
@@ -701,6 +708,16 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
701
708
  // hook에서 조회한다. 이 매핑을 통해 hook input에 없는 agent 식별을 우회한다.
702
709
  // Primary agent(team-leader)만 직접 파일 쓰기를 차단하기 위한 최소 필요 조건.
703
710
  const sessionAgent = new Map();
711
+ // ── issue-reporter 원문 표시 증명 (payload 절대경로 → 표시 시점 sha256) ──────
712
+ // GitHub 게시 승인의 "정보에 근거한 동의" 쪽 절반이다. permission 프롬프트에는
713
+ // curl 명령만 보이고 본문은 파일 안에 있으므로, 사용자가 실제로 무엇을 보았는지는
714
+ // 세션에 출력된 원문으로만 확인된다. 에이전트가 단일 `cat <payload>` 를 실행하면
715
+ // tool.execute.after 가 그 시점의 해시를 여기 기록하고, 전송 시 현재 파일과
716
+ // 대조한다. 표시 이후 내용이 바뀌면 차단된다.
717
+ //
718
+ // 프로세스 메모리에만 둔다 — 승인은 이 세션의 이 대화에 묶여야 하고, 디스크에
719
+ // 남기면 예전 마커 방식과 같은 "파일로 존재하는 승인" 이 되어 위조 표면이 생긴다.
720
+ const issueReporterShownPayloads = new Map();
704
721
  // ── sessionID → Jira Issue Key 매핑 ─────────────────────────────────────────
705
722
  // 모든 워크플로우는 Jira Issue Key를 중심으로 설계된다. dispatch_stage /
706
723
  // dispatch_verifier / auto_advance_stage 는 항상 args.issue 를 명시적으로
@@ -1004,6 +1021,16 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1004
1021
  throw new Error(violation);
1005
1022
  }
1006
1023
  }
1024
+ // ── issue-reporter 는 task 로 spawn 할 수 없다 ──
1025
+ // frontmatter 의 mode/hidden 은 "목록에서 감추기" 일 뿐이고, opencode 의 task 툴은
1026
+ // subagent_type 의 mode 를 검사하지 않는다. 이름만 알면 부를 수 있으므로 여기서 막는다.
1027
+ if (toolLower === "task") {
1028
+ const taskViolation = issueReporterTaskSpawnViolation(output.args);
1029
+ if (taskViolation) {
1030
+ logger.error(`[makdoong2-team hook] BLOCKED: agent "${agent ?? "unknown"}" attempted task spawn of ${ISSUE_REPORTER_AGENT} (sessionID="${sessionID}")`);
1031
+ throw new Error(taskViolation);
1032
+ }
1033
+ }
1007
1034
  // ── skill_mcp lazy-load 사전 힌트 ──
1008
1035
  // opencode 는 skill 이 로드되기 전에 skill_mcp 를 호출하면
1009
1036
  // "MCP server not found" 로 튕기지만 정작 어떤 skill 을 로드해야 하는지는
@@ -1020,22 +1047,23 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1020
1047
  `first invoke skill(name="${skillName}") in the current session, then retry.`);
1021
1048
  }
1022
1049
  }
1023
- // ── Issue-reporter: 승인 마커는 어떤 툴로도 만들 없다 ──
1024
- // 마커(<payload>.approved)는 오직 사용자가 issue-reporter-approve.sh
1025
- // 직접 실행해서만 생긴다. write/edit 툴로 마커를 위조하면 게시 게이트가
1026
- // 무력화되므로 경로에 마커 접미사가 보이면 즉시 차단한다.
1050
+ // ── Issue-reporter: payload 고쳐 쓰면 표시 증명이 무효가 된다 ──
1051
+ // 마커 파일 방식과 달리 표시 증명은 프로세스 메모리에 있어 위조할 수 없다.
1052
+ // 다만 사용자에게 보여준 파일만 바꿔치기하는 경로가 남으므로, write 계열
1053
+ // 툴이 표시된 payload 건드리면 그 증명을 즉시 폐기한다 (재표시 필요).
1027
1054
  if (agent === ISSUE_REPORTER_AGENT && (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower))) {
1028
1055
  // filePath 인자뿐 아니라 인자 전체를 검사한다 — apply_patch 는 파일 경로가
1029
- // 패치 본문 안에 들어 있어 filePath 추출로는 마커 위조를 잡을 수 없다.
1056
+ // 패치 본문 안에 들어 있어 filePath 추출로는 대상 파일을 수 없다.
1030
1057
  let argsSerialized = "";
1031
1058
  try {
1032
1059
  argsSerialized = JSON.stringify(output.args ?? "");
1033
1060
  }
1034
1061
  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>'직접 실행해야만 승인이 성립한다.`);
1062
+ for (const shownPath of [...issueReporterShownPayloads.keys()]) {
1063
+ if (argsSerialized.includes(shownPath)) {
1064
+ issueReporterShownPayloads.delete(shownPath);
1065
+ logger.debug(`[makdoong2-team hook] issue-reporter 표시 증명 폐기(${input.tool} payload 를 수정): ${shownPath}`);
1066
+ }
1039
1067
  }
1040
1068
  }
1041
1069
  // ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
@@ -1052,11 +1080,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1052
1080
  return;
1053
1081
  // ── Universal state.json hardrule: agent 식별 결과와 무관하게 차단 ──
1054
1082
  // sessionAgent map race, primary/outer session, undefined agent 모두 포함.
1055
- if (looksLikeSealedStateWrite(cmd)) {
1056
- logger.error(`[makdoong2-team hook] BLOCKED: state.json 우회 조작 시도 (agent="${agent ?? "unknown"}"). cmd="${redactAndTruncate(cmd, 200)}"`);
1057
- throw new Error(`[makdoong2-team state hardrule] state.json 은 오직 state.sh (get/set/init/append/update/migrate) 로만 조작할 수 있다.\n` +
1058
- `직접 편집 (python -c open, node -e writeFileSync, sed -i, jq > , tee, cat > , echo > 등) workflow 정합성을 훼손하므로 금지된다.\n` +
1059
- `caller agent="${agent ?? "unknown"}"`);
1083
+ // 차단 대상은 *쓰기* 뿐이다 — 읽기 전용 진단까지 막으면 state_unreadable
1084
+ // 복구 절차(next_action)를 수행할 수단이 사라진다 (issue #5).
1085
+ const stateAccess = classifyStateJsonAccess(cmd);
1086
+ if (stateAccess.kind === "write") {
1087
+ logger.error(`[makdoong2-team hook] BLOCKED: state.json 쓰기 우회 시도 (agent="${agent ?? "unknown"}", ` +
1088
+ `reason="${stateAccess.reason}"). cmd="${redactAndTruncate(cmd, 200)}"`);
1089
+ throw new Error(buildStateWriteBlockMessage(stateAccess.reason, agent));
1090
+ }
1091
+ if (stateAccess.kind === "read-only") {
1092
+ logger.debug(`[makdoong2-team hook] state.json 읽기 전용 진단 허용 (agent="${agent ?? "unknown"}", ` +
1093
+ `readers=${stateAccess.readers.join(",")})`);
1060
1094
  }
1061
1095
  // ── Leader hardrule 2: Bash 우회 파일 쓰기 금지 (state.json 제외) ──
1062
1096
  if (agent === "makdoong2-team-leader" && looksLikeFileWrite(cmd)) {
@@ -1077,23 +1111,15 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1077
1111
  `• 초안 파일 필요 시: spec을 team-leader에게 반환 → dev 단계에서 engineer가 구현.\n\n` +
1078
1112
  `**참조**: agents/${agent}.md "금지" 섹션, CLAUDE.md "워크플로우 상태 & 위임 규약"`);
1079
1113
  }
1080
- // ── Issue-reporter 게시 게이트: GitHub 쓰기는 사용자 승인 원문만 ──
1081
- // 정책·마커 계약 상세: src/issue-reporter-guard.ts 상단 주석.
1114
+ // ── Issue-reporter 게시 게이트: GitHub 쓰기는 사용자가 원문만 ──
1115
+ // 승인의 의사표시는 opencode permission 프롬프트(세션 내 yes/no)가 받는다.
1116
+ // 여기서 강제하는 것은 나머지 절반 — 형식(검증 가능한 형태)과 표시 증명
1117
+ // (사용자가 본 원문 == 전송되는 원문). 계약 상세: src/issue-reporter-guard.ts 상단.
1082
1118
  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
- }
1119
+ const approveHint = `승인 절차: payload 리터럴 절대경로 JSON 파일로 쓴다 → ② 'cat <payload>' 로 원문 전체를\n` +
1120
+ `세션에 표시한다 (체이닝 없이 단독 실행 — 이 출력이 사용자가 보는 원문이고 훅이 해시를 기록한다) →\n` +
1121
+ `③ 단일 curl -d @<payload> 전송하면 opencode 사용자에게 게시 여부를 묻는다 (yes/no).\n` +
1122
+ `표시 이후 payload 고치면 증명이 무효가 되므로 ②부터 다시 한다.`;
1097
1123
  const call = classifyGithubApiCall(cmd);
1098
1124
  if (call.kind === "forbidden-client") {
1099
1125
  logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter가 비-curl 클라이언트로 GitHub API 접근. cmd="${redactAndTruncate(cmd, 200)}"`);
@@ -1109,21 +1135,14 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1109
1135
  if (!existsSync(payloadPath)) {
1110
1136
  throw new Error(`[makdoong2-team issue-reporter 게시 게이트] payload 파일이 없다: ${payloadPath}\n${approveHint}`);
1111
1137
  }
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"));
1138
+ const mismatch = displayMismatch(readFileSync(payloadPath), issueReporterShownPayloads.get(payloadPath));
1120
1139
  if (mismatch !== null) {
1121
1140
  logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter GitHub 쓰기 — ${mismatch} (${payloadPath})`);
1122
1141
  throw new Error(`[makdoong2-team issue-reporter 게시 게이트] ${mismatch}.\n` +
1123
- `현재 원문을 다시 채팅에 전문 표시하고 재승인을 받아라:\n` +
1124
- ` bash ${SCRIPTS_DIR}/${APPROVE_SCRIPT_BASENAME} ${payloadPath}`);
1142
+ `게시될 원문을 세션에 그대로 표시하라 (단독 실행):\n` +
1143
+ ` cat ${payloadPath}\n${approveHint}`);
1125
1144
  }
1126
- logger.debug(`[makdoong2-team hook] issue-reporter 게시 승인 확인: ${payloadPath} (marker hash 일치)`);
1145
+ logger.debug(`[makdoong2-team hook] issue-reporter 표시 증명 확인: ${payloadPath} (hash 일치)`);
1127
1146
  }
1128
1147
  }
1129
1148
  }
@@ -1149,24 +1168,34 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
1149
1168
  else
1150
1169
  sessionActiveToolCount.set(afterSessionID, cur - 1);
1151
1170
  }
1152
- // ── Issue-reporter 승인 마커 1회용 소멸 ──
1153
- // GitHub 쓰기 명령이 "실행"되고 나면 결과와 무관하게 마커를 삭제한다.
1154
- // 성공: 재게시에는 승인이 필요하다. 실패(네트워크 등): 같은 원문이라도
1155
- // 재시도 전에 재승인을 받는다 — 마커를 남겨두면 실패를 빌미로 승인 한 번에
1156
- // 여러 번의 전송 시도가 가능해지므로 엄격한 쪽을 택했다.
1171
+ // ── Issue-reporter 표시 증명 기록·소멸 ──
1172
+ // 기록: 단독 `cat <payload>` 실행되면 시점의 파일 해시를 남긴다. 이
1173
+ // 출력이 사용자가 세션에서 실제로 원문이고, 게시 게이트가 전송 직전에
1174
+ // 같은 해시인지 대조한다.
1175
+ // 소멸: GitHub 쓰기가 "실행"되고 나면 결과와 무관하게 증명을 폐기한다.
1176
+ // 성공이면 재게시에 새 승인이 필요하고, 실패(네트워크 등)여도 재시도 전에
1177
+ // 다시 보여주고 다시 묻는다 — 증명을 남겨두면 실패를 빌미로 승인 한 번에
1178
+ // 여러 번의 전송이 가능해지므로 엄격한 쪽을 택했다.
1157
1179
  if (toolLowerAfter === "bash") {
1158
1180
  const afterAgent = afterSessionID
1159
1181
  ? (sessionAgent.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.agent)
1160
1182
  : undefined;
1161
1183
  if (afterAgent === ISSUE_REPORTER_AGENT) {
1162
1184
  const afterCmd = input.args?.command ?? "";
1185
+ for (const shownPath of payloadDisplayPaths(afterCmd)) {
1186
+ try {
1187
+ issueReporterShownPayloads.set(shownPath, sha256Hex(readFileSync(shownPath)));
1188
+ logger.debug(`[makdoong2-team hook] issue-reporter 표시 증명 기록: ${shownPath}`);
1189
+ }
1190
+ catch {
1191
+ // 읽을 수 없으면 표시가 성립하지 않은 것이다 — 기록하지 않는다.
1192
+ }
1193
+ }
1163
1194
  const afterCall = classifyGithubApiCall(afterCmd);
1164
1195
  if (afterCall.kind === "mutation") {
1165
1196
  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}`);
1197
+ if (issueReporterShownPayloads.delete(payloadPath)) {
1198
+ logger.debug(`[makdoong2-team hook] issue-reporter 표시 증명 소멸(1회용): ${payloadPath}`);
1170
1199
  }
1171
1200
  }
1172
1201
  }
@@ -2584,8 +2613,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
2584
2613
  effective_cwd: effectiveCwd,
2585
2614
  reason: `state.json 을 판독할 수 없습니다. worktree cwd 기준 예상 경로: ${expectedPath}. ` +
2586
2615
  `주요 원인: (1) wt-sync-ignored.sh 로 worktree 동기화 미수행, (2) 다른 cwd(main repo)에서 state.json 을 조작하여 worktree 사본과 불일치, (3) state.json 손상.`,
2587
- next_action: `state.json 존재/유효성을 먼저 확인하세요. 필요 시 'bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${effectiveCwd} ${args.issue}' 로 재동기화하거나, ` +
2616
+ next_action: `① 먼저 'bash ${SCRIPTS_DIR}/state.sh status ${args.issue}' 로 존재/유효성을 확인하세요 ` +
2617
+ `(승인된 읽기 명령입니다 — 훅이 차단하지 않습니다). ` +
2618
+ `② exists=false 면 'bash ${SCRIPTS_DIR}/wt-sync-ignored.sh ${effectiveCwd} ${args.issue}' 로 재동기화하거나, ` +
2588
2619
  `'bash ${SCRIPTS_DIR}/state.sh init ${args.issue} ${effectiveCwd}' 로 초기화하세요. ` +
2620
+ `③ readable=false (JSON 손상) 면 사용자에게 에스컬레이션하세요. ` +
2589
2621
  `근본 원인이 해소되기 전에는 dispatch_stage 를 호출하지 마세요.`,
2590
2622
  });
2591
2623
  }
@@ -0,0 +1,51 @@
1
+ /** `.makdoong2-team/<issue>/state.json` 리터럴 경로. */
2
+ export declare const STATE_JSON_PATH_RE: RegExp;
3
+ /**
4
+ * `scripts/state.sh` 가 실제로 구현하는 서브커맨드.
5
+ *
6
+ * 이 배열이 유일한 출처다 — 훅 에러 메시지와 allowlist 정규식이 모두 여기서
7
+ * 파생된다. 종전에는 세 곳(regex 2개 + 메시지)이 각자 다른 목록을 갖고 있어서
8
+ * 존재하지 않는 `update` 는 허용되고 실재하는 `append`/`migrate` 는 누락돼 있었다.
9
+ */
10
+ export declare const STATE_SH_SUBCOMMANDS: readonly ["root", "issue", "init", "status", "get", "set", "append", "migrate"];
11
+ /** `state.sh <승인 서브커맨드>` 호출 탐지. 목록은 STATE_SH_SUBCOMMANDS 하나에서만 온다. */
12
+ export declare const STATE_SH_CALL_RE: RegExp;
13
+ export type StateAccessVerdict =
14
+ /** 명령에 state.json 경로가 없다 — 이 가드의 관심 밖. */
15
+ {
16
+ kind: "unrelated";
17
+ }
18
+ /** 승인된 `state.sh <서브커맨드>` 호출. */
19
+ | {
20
+ kind: "approved-helper";
21
+ }
22
+ /** 읽기 전용임이 확인됨 (진단·복구 절차). */
23
+ | {
24
+ kind: "read-only";
25
+ readers: string[];
26
+ }
27
+ /** 쓰기 의도가 있거나 읽기 전용임을 증명할 수 없음 → 차단. */
28
+ | {
29
+ kind: "write";
30
+ reason: string;
31
+ };
32
+ /**
33
+ * state.json 을 참조하는 명령을 분류한다.
34
+ *
35
+ * 순서가 계약이다:
36
+ * 1. 경로 없음 → unrelated
37
+ * 2. 쓰기 지표 → write (state.sh 호출이 같이 있어도 차단)
38
+ * 3. state.sh 승인 호출 → approved-helper
39
+ * 4. state.json 을 언급하는 모든 세그먼트가 읽기 전용 allowlist → read-only
40
+ * 5. 그 외 → write (모르는 명령은 차단)
41
+ */
42
+ export declare function classifyStateJsonAccess(cmd: string): StateAccessVerdict;
43
+ /**
44
+ * 훅이 에이전트에게 throw 할 차단 메시지.
45
+ *
46
+ * 종전 메시지는 "무엇이 금지인가" 만 말하고 "그럼 무엇으로 확인하나" 를 말하지
47
+ * 않았다. 그 결과 leader 는 차단을 leader 하드룰 2(bash 파일 쓰기) 위반으로
48
+ * 오인해 자체 abort 했다 (issue #5). 그래서 ① 어떤 규칙인지, ② 무엇이 걸렸는지,
49
+ * ③ 읽기는 무엇으로 하는지, ④ abort 사유가 아님을 모두 적는다.
50
+ */
51
+ export declare function buildStateWriteBlockMessage(reason: string, agent: string | undefined): string;
@@ -0,0 +1,167 @@
1
+ // state-access-guard.ts — state.json 을 건드리는 bash 명령의 "읽기 / 쓰기" 판정.
2
+ //
3
+ // 왜 별도 모듈인가: opencode 플러그인 로더는 진입 파일(opencode-plugin.ts)의 모든
4
+ // named export 를 plugin factory 로 호출한다 (ARCHITECTURE.md §2). 신규 helper 는
5
+ // 이 파일에 두고 import 한다. `test/plugin-exports-shape.test.mjs` 가 진입 파일의
6
+ // export 집합을 고정한다.
7
+ //
8
+ // ── 배경 (issue #5) ──
9
+ // 종전 구현은 "명령 문자열에 state.json 경로가 있는가" 만 보고 차단했다. 그래서
10
+ // `auto_advance_stage` 의 state_unreadable next_action 이 지시하는 존재·유효성 확인
11
+ // (`ls` / `file` / `head`) 까지 "우회 조작 시도" 로 막혔고, leader 는 안내받은 복구
12
+ // 명령을 한 줄도 실행하지 못한 채 자체 abort 했다. 하드룰이 지키려는 것은 *쓰기*
13
+ // 경로다 — 훅의 에러 메시지도 쓰기 행위만 열거한다. 읽기는 스키마 정합성에 영향을
14
+ // 주지 않으므로 막을 이유가 없다.
15
+ //
16
+ // ── 판정 원칙: 애매하면 차단한다 ──
17
+ // 오탐(읽기를 막음)에는 `state.sh status` 라는 승인된 우회로가 있지만, 미탐(쓰기를
18
+ // 허용)은 state.json 정합성을 깨고 되돌릴 수단이 없다. 그래서 읽기 허용은
19
+ // allowlist 로만 한다 — 읽기 전용임이 증명된 명령만 통과하고, 모르는 명령은 차단.
20
+ /** `.makdoong2-team/<issue>/state.json` 리터럴 경로. */
21
+ export const STATE_JSON_PATH_RE = /\.makdoong2-team\/[^/\s]+\/state\.json/;
22
+ /**
23
+ * `scripts/state.sh` 가 실제로 구현하는 서브커맨드.
24
+ *
25
+ * 이 배열이 유일한 출처다 — 훅 에러 메시지와 allowlist 정규식이 모두 여기서
26
+ * 파생된다. 종전에는 세 곳(regex 2개 + 메시지)이 각자 다른 목록을 갖고 있어서
27
+ * 존재하지 않는 `update` 는 허용되고 실재하는 `append`/`migrate` 는 누락돼 있었다.
28
+ */
29
+ export const STATE_SH_SUBCOMMANDS = [
30
+ "root",
31
+ "issue",
32
+ "init",
33
+ "status",
34
+ "get",
35
+ "set",
36
+ "append",
37
+ "migrate",
38
+ ];
39
+ /** `state.sh <승인 서브커맨드>` 호출 탐지. 목록은 STATE_SH_SUBCOMMANDS 하나에서만 온다. */
40
+ export const STATE_SH_CALL_RE = new RegExp(String.raw `state\.sh\s+(?:${STATE_SH_SUBCOMMANDS.join("|")})(?![\w-])`);
41
+ /**
42
+ * 쓰기 의도 지표. 하나라도 매칭되면 명령 전체를 차단한다.
43
+ *
44
+ * state.sh 호출 여부보다 먼저 검사한다 — `state.sh get … ; rm …/state.json` 처럼
45
+ * 승인된 호출에 쓰기를 끼워 넣는 밀수 경로를 막기 위해서다.
46
+ */
47
+ const WRITE_INDICATORS = [
48
+ // `>` / `>>` 리디렉션. `2>/dev/null`, `1>&2`, `>/dev/null` 은 제외한다.
49
+ [/(?:^|[|&;\s(`{])>>?\s*(?!&|\/dev\/(?:null|stderr|stdout|tty)(?![\w/]))\S/, "출력 리디렉션 (> / >>)"],
50
+ [/(?:^|[|&;(`{])\s*(?:tee|dd|sponge)\b/, "tee / dd / sponge"],
51
+ [/\bsed\b[^|;&]*\s-i(?:\b|['"])/, "sed -i (in-place 편집)"],
52
+ [/\b(?:perl|ruby)\b[^|;&]*\s-\w*i\b/, "perl / ruby -i (in-place 편집)"],
53
+ // 인터프리터 인라인 스크립트는 읽기/쓰기를 정적으로 구분할 수 없다 → 전부 차단.
54
+ // 읽기가 필요하면 cat / jq / head 를 쓴다.
55
+ [/\b(?:python3?|node|bun|deno|ruby|perl|php)\b\s+-\w*[ce]\b/, "인터프리터 인라인 스크립트 (-c / -e)"],
56
+ [/(?:^|[|&;\s(`{])(?:cp|mv|rm|ln|install|touch|truncate|shred|chmod|chown|unlink|rsync|mkfifo)\s/, "파일 조작 명령"],
57
+ [/\bgit\b(?:\s+-\S+(?:\s+\S+)?)*\s+(?:add|rm|mv|checkout|restore|stash|apply|clean|update-index|reset|commit)\b/, "git 쓰기 서브커맨드"],
58
+ [/(?:^|[|&;\s(`{])(?:vi|vim|nano|emacs|ed|ex|patch)\s/, "편집기 / patch"],
59
+ ];
60
+ /** 세그먼트의 선두에 올 수 있는 읽기 전용 명령. 여기 없는 명령은 차단된다. */
61
+ const READ_ONLY_COMMANDS = new Set([
62
+ "ls", "cat", "bat", "head", "tail", "file", "stat", "wc", "du",
63
+ "jq", "yq", "grep", "egrep", "fgrep", "rg", "awk", "sed", "tr", "cut",
64
+ "sort", "uniq", "nl", "fold", "column", "strings", "xxd", "od", "hexdump",
65
+ "diff", "cmp", "cksum", "md5", "md5sum", "shasum", "sha1sum", "sha256sum",
66
+ "find", "realpath", "readlink", "dirname", "basename", "test", "[",
67
+ "echo", "printf", "pwd", "true", "false",
68
+ ]);
69
+ /** 읽기 전용으로 확인된 git 서브커맨드. */
70
+ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
71
+ "check-ignore", "check-attr", "status", "ls-files", "diff", "show", "log",
72
+ "cat-file", "rev-parse", "blame", "grep", "describe",
73
+ ]);
74
+ /** 값을 뒤 토큰으로 받는 git 전역 옵션 — 서브커맨드 추출 시 건너뛴다. */
75
+ const GIT_OPTIONS_WITH_VALUE = new Set([
76
+ "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path",
77
+ ]);
78
+ const SEGMENT_SPLIT_RE = /\|\||&&|[;|&\n]/;
79
+ /** 세그먼트 선두의 괄호·역따옴표·환경변수 대입을 벗겨 실제 명령 토큰을 얻는다. */
80
+ function segmentHead(segment) {
81
+ let s = segment;
82
+ for (let i = 0; i < 4; i++) {
83
+ const before = s;
84
+ s = s
85
+ .replace(/^[\s(){}`]+/, "")
86
+ .replace(/^\$\(/, "")
87
+ .replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "");
88
+ if (s === before)
89
+ break;
90
+ }
91
+ const m = /^(\S+)\s*([\s\S]*)$/.exec(s);
92
+ if (!m)
93
+ return { head: "", rest: "" };
94
+ return { head: m[1].replace(/^.*\//, ""), rest: m[2] };
95
+ }
96
+ function gitSubcommand(rest) {
97
+ const tokens = rest.split(/\s+/).filter(Boolean);
98
+ for (let i = 0; i < tokens.length; i++) {
99
+ const t = tokens[i];
100
+ if (GIT_OPTIONS_WITH_VALUE.has(t)) {
101
+ i++;
102
+ continue;
103
+ }
104
+ if (t.startsWith("-"))
105
+ continue;
106
+ return t;
107
+ }
108
+ return "";
109
+ }
110
+ /**
111
+ * state.json 을 참조하는 명령을 분류한다.
112
+ *
113
+ * 순서가 계약이다:
114
+ * 1. 경로 없음 → unrelated
115
+ * 2. 쓰기 지표 → write (state.sh 호출이 같이 있어도 차단)
116
+ * 3. state.sh 승인 호출 → approved-helper
117
+ * 4. state.json 을 언급하는 모든 세그먼트가 읽기 전용 allowlist → read-only
118
+ * 5. 그 외 → write (모르는 명령은 차단)
119
+ */
120
+ export function classifyStateJsonAccess(cmd) {
121
+ if (!STATE_JSON_PATH_RE.test(cmd))
122
+ return { kind: "unrelated" };
123
+ for (const [re, reason] of WRITE_INDICATORS) {
124
+ if (re.test(cmd))
125
+ return { kind: "write", reason };
126
+ }
127
+ if (STATE_SH_CALL_RE.test(cmd))
128
+ return { kind: "approved-helper" };
129
+ const readers = [];
130
+ for (const segment of cmd.split(SEGMENT_SPLIT_RE)) {
131
+ if (!STATE_JSON_PATH_RE.test(segment))
132
+ continue;
133
+ const { head, rest } = segmentHead(segment);
134
+ if (head === "git") {
135
+ const sub = gitSubcommand(rest);
136
+ if (!READ_ONLY_GIT_SUBCOMMANDS.has(sub)) {
137
+ return { kind: "write", reason: `읽기 전용으로 확인되지 않은 git 서브커맨드: git ${sub || "?"}` };
138
+ }
139
+ readers.push(`git ${sub}`);
140
+ continue;
141
+ }
142
+ if (!READ_ONLY_COMMANDS.has(head)) {
143
+ return { kind: "write", reason: `읽기 전용으로 확인되지 않은 명령: ${head || "(빈 세그먼트)"}` };
144
+ }
145
+ readers.push(head);
146
+ }
147
+ return { kind: "read-only", readers };
148
+ }
149
+ /**
150
+ * 훅이 에이전트에게 throw 할 차단 메시지.
151
+ *
152
+ * 종전 메시지는 "무엇이 금지인가" 만 말하고 "그럼 무엇으로 확인하나" 를 말하지
153
+ * 않았다. 그 결과 leader 는 차단을 leader 하드룰 2(bash 파일 쓰기) 위반으로
154
+ * 오인해 자체 abort 했다 (issue #5). 그래서 ① 어떤 규칙인지, ② 무엇이 걸렸는지,
155
+ * ③ 읽기는 무엇으로 하는지, ④ abort 사유가 아님을 모두 적는다.
156
+ */
157
+ export function buildStateWriteBlockMessage(reason, agent) {
158
+ return (`[makdoong2-team state hardrule] state.json 쓰기는 오직 state.sh ` +
159
+ `(${STATE_SH_SUBCOMMANDS.join("/")}) 로만 할 수 있다.\n` +
160
+ `차단 사유: ${reason}\n` +
161
+ `금지: 직접 편집 (python -c open, node -e writeFileSync, sed -i, jq > , tee, cat > , echo > , cp/mv/rm, git add 등)\n` +
162
+ `허용: 읽기 전용 진단은 그대로 쓸 수 있다 — 'bash <SCRIPTS_DIR>/state.sh status <이슈키>' (존재·유효성·phantom 키 보고), ` +
163
+ `또는 ls / file / head / cat / jq / git check-ignore 로 state.json 을 조회.\n` +
164
+ `이 차단은 state.json 전용 하드룰이며 leader 하드룰 2(bash 파일 쓰기) 위반이 아니다. ` +
165
+ `자체 abort 사유가 아니므로 읽기 전용 명령으로 바꿔 진단을 계속하라.\n` +
166
+ `caller agent="${agent ?? "unknown"}"`);
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makdoong2-team",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,6 +38,7 @@ const STEPS = [
38
38
  "node test/install-lib.test.mjs",
39
39
  "node test/skill-mcp-registry.test.mjs",
40
40
  "node --test test/state-write-guard.test.mjs",
41
+ "node --test test/state-access-guard.test.mjs",
41
42
  "node --test test/issue-reporter-guard.test.mjs",
42
43
  "node --test test/release-confirm.test.mjs",
43
44
  "node --test test/state-sh-schema.test.mjs",
package/scripts/state.sh CHANGED
@@ -7,8 +7,10 @@
7
7
  # state.sh root # 현재 컨텍스트의 git toplevel 출력 (worktree-local)
8
8
  # state.sh issue # 현재 브랜치에서 이슈키 추출 (없으면 빈 값)
9
9
  # state.sh init <issue> [worktree] # state.json 최초 생성 (이미 있으면 auto-migrate)
10
+ # state.sh status <issue> # 존재/유효성/스키마 진단 (읽기 전용, exit 0=정상)
10
11
  # state.sh get <issue> <jq-path> # 값 조회. 예: '.stages."3_delivery".substages."commit".done'
11
12
  # state.sh set <issue> <jq-path> <json-value># 값 설정. 문자열은 '"pass"' 형태로 전달
13
+ # state.sh append <issue> <jq-path> <json-value># 배열 끝에 항목 추가 (없으면 [] 로 시작)
12
14
  # state.sh migrate <issue> # legacy stage key → 계층형 substages 이관 (idempotent)
13
15
  #
14
16
  # ── 스키마 규약 (hardrule) ──
@@ -45,6 +47,17 @@ issue() {
45
47
  }
46
48
  sp() { echo "$(root)/.makdoong2-team/$1/state.json"; }
47
49
 
50
+ # usage_die <시그니처> [부연 설명...]
51
+ # `${2:?}` 가 뱉는 raw bash 에러(`line 127: 2: parameter null or not set`)는 복구
52
+ # 작업 중인 에이전트에게 아무것도 알려주지 못한다. 대신 무엇을 어떻게 부를지 적는다.
53
+ usage_die() {
54
+ echo "usage: state.sh $1" >&2
55
+ shift
56
+ local line
57
+ for line in "$@"; do echo " ${line}" >&2; done
58
+ exit 64
59
+ }
60
+
48
61
  # ── phantom-key guard ──
49
62
  # jq path 안에 `.stages."<PHASE>.<SUBSTAGE>"` 형태 (dot 포함 stage-id 를 단일 키로
50
63
  # 지정) 가 나타나면 flat 표기로 판정한다. hierarchical 표기는 dot 이 phase 밖으로
@@ -123,8 +136,46 @@ JSON
123
136
  "$0" migrate "$ISSUE" >/dev/null 2>&1 || true
124
137
  fi
125
138
  echo "$P" ;;
139
+ status)
140
+ # 승인된 읽기 전용 진단. state_unreadable 복구 절차의 1단계다 (ARCHITECTURE.md §5.5).
141
+ # stdout 은 key=value 한 줄씩 — 에이전트가 파싱하기 쉽고 사람이 읽을 수도 있다.
142
+ # exit 0 = 존재 && 판독 가능, exit 1 = 그 외 (어느 쪽인지는 exists/readable 이 말한다).
143
+ ISSUE="${1:-}"
144
+ [ -n "${ISSUE}" ] || usage_die "status <issue>" \
145
+ "예: state.sh status PROJ-12345" \
146
+ "출력: path / exists / readable / issue / worktree / phantom_keys / next"
147
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
148
+ P="$(sp "${ISSUE}")"
149
+ echo "path=${P}"
150
+ if [ ! -f "${P}" ]; then
151
+ echo "exists=false"
152
+ echo "readable=false"
153
+ echo "next=bash ${SELF_DIR}/wt-sync-ignored.sh $(root) ${ISSUE} # worktree 동기화 누락이면"
154
+ echo "next=bash ${SELF_DIR}/state.sh init ${ISSUE} $(root) # 신규 생성이면"
155
+ exit 1
156
+ fi
157
+ echo "exists=true"
158
+ if ! jq empty "${P}" 2>/dev/null; then
159
+ echo "readable=false"
160
+ echo "next=JSON 이 손상되었습니다. 자동 복구하지 말고 사용자에게 에스컬레이션하세요."
161
+ exit 1
162
+ fi
163
+ echo "readable=true"
164
+ echo "issue=$(jq -r '.issue // "null"' "${P}")"
165
+ echo "worktree=$(jq -r '.worktree // "null"' "${P}")"
166
+ echo "stages=$(jq -r '(.stages // {}) | keys | join(",") // "none"' "${P}")"
167
+ PHANTOM="$(jq -r '[(.stages // {}) | keys[] | select(test("\\."))] | join(",")' "${P}")"
168
+ echo "phantom_keys=${PHANTOM:-none}"
169
+ if [ -n "${PHANTOM}" ]; then
170
+ echo "next=bash ${SELF_DIR}/state.sh migrate ${ISSUE} # flat/phantom 키 정규화"
171
+ fi
172
+ ;;
126
173
  get)
127
- ISSUE="${1:?}"; Q="${2:?}"; P="$(sp "$ISSUE")"
174
+ ISSUE="${1:-}"; Q="${2:-}"
175
+ [ -n "${ISSUE}" ] && [ -n "${Q}" ] || usage_die "get <issue> <jq-path>" \
176
+ "예: state.sh get PROJ-1 '.stages.\"1_planning\".substages.\"jira\".done'" \
177
+ "파일 존재·유효성 확인은 state.sh status <issue> 를 쓴다."
178
+ P="$(sp "${ISSUE}")"
128
179
  check_flat_stage_notation "$Q"
129
180
  # Semantics contract (rely on this in all gate scripts):
130
181
  # * always print exactly one line = the value (jq -r style: null/false/0/{}/"str")
@@ -142,16 +193,31 @@ JSON
142
193
  if OUT="$(jq -r "$Q" "$P" 2>/dev/null)"; then
143
194
  printf '%s\n' "$OUT"
144
195
  else
196
+ # stdout 계약(정확히 한 줄 "null" + exit 1)은 그대로 두고, 원인만 stderr 로
197
+ # 알린다. 종전에는 "파일 부재" 와 "jq 경로 오류" 가 똑같이 null 로만 보여서
198
+ # 복구 작업 중인 에이전트가 어느 쪽인지 알 수 없었다.
199
+ if [ ! -f "${P}" ]; then
200
+ echo "[state.sh] state.json 없음: ${P} — 'state.sh status ${ISSUE}' 로 확인하세요." >&2
201
+ else
202
+ echo "[state.sh] jq 평가 실패 (경로 오류 또는 JSON 손상): ${Q}" >&2
203
+ fi
145
204
  echo "null"; exit 1
146
205
  fi
147
206
  ;;
148
207
  set)
149
- ISSUE="${1:?}"; Q="${2:?}"; V="${3:?}"; P="$(sp "$ISSUE")"
208
+ ISSUE="${1:-}"; Q="${2:-}"; V="${3:-}"
209
+ [ -n "${ISSUE}" ] && [ -n "${Q}" ] && [ -n "${V}" ] || usage_die "set <issue> <jq-path> <json-value>" \
210
+ "예: state.sh set PROJ-1 '.stages.\"1_planning\".substages.\"jira\".done' true" \
211
+ "문자열 값은 '\"pass\"' 처럼 JSON 리터럴로 전달한다."
212
+ P="$(sp "${ISSUE}")"
150
213
  check_flat_stage_notation "$Q"
151
214
  tmp="$(mktemp)"; jq "$Q = $V" "$P" > "$tmp" && mv "$tmp" "$P"
152
215
  echo "state[$ISSUE] $Q = $V" ;;
153
216
  append)
154
- ISSUE="${1:?}"; Q="${2:?}"; V="${3:?}"; P="$(sp "$ISSUE")"
217
+ ISSUE="${1:-}"; Q="${2:-}"; V="${3:-}"
218
+ [ -n "${ISSUE}" ] && [ -n "${Q}" ] && [ -n "${V}" ] || usage_die "append <issue> <jq-path> <json-value>" \
219
+ "예: state.sh append PROJ-1 '.stages.\"2_implementation\".substages.\"dev\".hang_history' '{\"at\":\"…\"}'"
220
+ P="$(sp "${ISSUE}")"
155
221
  check_flat_stage_notation "$Q"
156
222
  tmp="$(mktemp)"
157
223
  jq --argjson entry "$V" "$Q = ((($Q) // []) + [\$entry])" "$P" > "$tmp" && mv "$tmp" "$P"
@@ -188,5 +254,5 @@ JSON
188
254
  | move("3_delivery.review"; "3_delivery"; "review")
189
255
  ' "$P" > "$tmp" && mv "$tmp" "$P"
190
256
  echo "migrated[$ISSUE] $P" ;;
191
- *) echo "usage: state.sh {root|issue|init|get|set|append|migrate} ..." >&2; exit 64 ;;
257
+ *) echo "usage: state.sh {root|issue|init|status|get|set|append|migrate} ..." >&2; exit 64 ;;
192
258
  esac
@@ -10,7 +10,8 @@ makdoong2-team 플러그인의 결함을 재현 가능한 형태로 GitHub 이
10
10
  ## 트리거 & 실행 컨텍스트 (hardrule)
11
11
 
12
12
  - **유일한 트리거는 사용자의 직접 호출** — `/makdoong2-issue-reporter [보충 설명]` 커맨드. 커맨드 frontmatter 의 `agent` 필드가 전용 에이전트 `makdoong2-issue-reporter` 로 라우팅한다.
13
- - **전용 에이전트는 전권(full-permission) 으로 실행된다** — bash 전체 allow, 파일 쓰기 allow. team-leader 의 파일 쓰기·git 제한이 적용되지 않으므로 아래 절차의 임시 파일 생성(`issue-payload.json` 등)과 curl 호출을 그대로 수행할 수 있다.
13
+ - **전용 에이전트는 전권(full-permission) 으로 실행된다** — bash 전체 allow, 파일 쓰기 allow. team-leader 의 파일 쓰기·git 제한이 적용되지 않으므로 아래 절차의 임시 파일 생성(`issue-payload.json` 등)과 curl 호출을 그대로 수행할 수 있다. 예외는 GitHub 게시 하나뿐이고, 그건 7-1 의 사용자 승인을 거친다.
14
+ - **에이전트는 목록에 노출되지 않는다** — `mode: subagent` + `hidden: true` 라 사용자가 고르는 primary 목록에도, `@` 멘션·task 자동완성에도 뜨지 않는다. `task` 툴로 spawn 하는 것도 훅이 차단한다. 위 커맨드가 유일한 진입점이다.
14
15
  - **다른 에이전트의 자율 로드 금지** — team-leader·sealed 서브에이전트가 `skill(name="makdoong2-issue-reporter")` 를 호출하면 플러그인 `tool.execute.before` 훅이 차단한다. 실패를 관측한 에이전트는 스킬을 로드하는 대신 사용자에게 `/makdoong2-issue-reporter` 실행을 안내한다.
15
16
  - 인라인 실행이므로 현재 세션의 직전 대화 턴을 그대로 볼 수 있다. 4장(항목 확정)과 2.3(마스킹 최종 확인)의 사용자 문답도 같은 세션에서 이어서 진행한다.
16
17
 
@@ -47,10 +48,14 @@ PAT는 opencode config 디렉토리의 `.github` 파일에 기록되어 있다.
47
48
 
48
49
  ```bash
49
50
  PAT_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/.github"
50
- test -r "$PAT_FILE" || { echo "PAT 파일 없음 또는 읽기 권한 없음: $PAT_FILE"; exit 1; }
51
+ GH_TOKEN=""
52
+ if [ -r "$PAT_FILE" ]; then
53
+ GH_TOKEN="$(grep -oE '(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]+' "$PAT_FILE" | head -n1)"
54
+ fi
55
+ test -n "$GH_TOKEN" || echo "NO_TOKEN"
51
56
  ```
52
57
 
53
- 파일 포맷은 다음 중 하나일 수 있으므로 순서대로 판별한다.
58
+ 파일 포맷은 다음 중 하나일 수 있으므로 grep 이 모두 수용한다.
54
59
 
55
60
  | 포맷 | 예시 | 추출 방법 |
56
61
  |---|---|---|
@@ -58,15 +63,45 @@ test -r "$PAT_FILE" || { echo "PAT 파일 없음 또는 읽기 권한 없음: $P
58
63
  | `KEY=VALUE` | `GITHUB_TOKEN=ghp_xxx` | `=` 우측 값 |
59
64
  | INI/JSON | `{"token": "ghp_xxx"}` | 해당 키 값 |
60
65
 
61
- ```bash
62
- GH_TOKEN="$(grep -oE '(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]+' "$PAT_FILE" | head -n1)"
63
- ```
66
+ ### 1.1 토큰이 없을 때 — 사용자에게 발급을 요청한다 (중단하지 않는다)
67
+
68
+ **파일이 없거나, 있어도 토큰을 추출하지 못했으면 조용히 종료하지 않는다.** 수집·분석은 그대로
69
+ 끝내고(3~6장), 등록 직전에 사용자에게 **토큰 발급을 요청한 뒤 대기**한다. 아래 안내를 그대로 제시한다.
70
+
71
+ > `$PAT_FILE` 에서 GitHub 토큰을 찾지 못했습니다. 이슈를 등록하려면 PAT 가 필요합니다.
72
+ > 아래 중 하나로 발급한 뒤 파일에 저장해 주세요.
73
+ >
74
+ > **A. Fine-grained token** (권장) — https://github.com/settings/personal-access-tokens/new
75
+ > - Repository access: `y00njinuk/makdoong2-team` 만 선택
76
+ > - Repository permissions → **Issues: Read and write**
77
+ > - (증거를 Gist 로 올릴 경우에만) Account permissions → **Gists: Read and write**
78
+ >
79
+ > **B. Classic token** — https://github.com/settings/tokens/new
80
+ > - scope: **`public_repo`** (Gist 사용 시 `gist` 추가)
81
+ >
82
+ > 저장:
83
+ > ```bash
84
+ > mkdir -p "$(dirname "$PAT_FILE")"
85
+ > printf '%s\n' '<발급받은-토큰>' > "$PAT_FILE"
86
+ > chmod 600 "$PAT_FILE"
87
+ > ```
88
+ > 저장하셨으면 알려주세요. 다시 읽어서 등록을 진행합니다.
89
+
90
+ 규칙:
91
+
92
+ - **토큰 발급·저장은 사용자가 한다.** 에이전트가 토큰을 만들거나 대신 파일에 쓰지 않으며,
93
+ 사용자가 붙여넣은 토큰 값을 채팅에 다시 출력하지 않는다.
94
+ - 사용자가 저장을 알리면 파일을 **다시 읽어** 토큰을 얻고 등록을 이어간다. 여전히 없으면 한 번 더
95
+ 경로·형식을 확인해 알려주고, 그래도 실패하면 아래 수동 등록 대체 경로로 넘어간다.
96
+ - 사용자가 발급을 거부하거나 지금은 못 하겠다고 하면, 이슈 본문 전체를 마크다운으로 출력해
97
+ https://github.com/y00njinuk/makdoong2-team/issues/new 에서 수동 등록할 수 있게 한다.
98
+ - `401`/`403` 응답도 같은 절차를 적용한다 — 토큰이 만료·폐기되었거나 권한(scope)이 부족한
99
+ 경우이므로, 위 안내에 **어떤 상태 코드와 `message` 를 받았는지** 덧붙여 재발급을 요청한다.
64
100
 
65
101
  **제약 사항**
66
102
  - 토큰 값은 대화 출력, 이슈 본문, 로그 어디에도 그대로 노출하지 않는다. 마스킹(`ghp_****`)만 허용.
67
103
  - `curl` 사용 시 `-H "Authorization: Bearer $GH_TOKEN"` 형태로 환경변수를 통해 전달하고, 커맨드 문자열에 토큰을 직접 문자열로 박지 않는다.
68
104
  - 토큰 파일 내용을 그대로 출력하거나 다른 경로로 복사하지 않는다.
69
- - 토큰이 없거나 401/403이 반환되면 이슈 생성을 중단하고, 본문 전체를 마크다운으로 출력해 사용자가 수동 등록할 수 있게 한다.
70
105
 
71
106
  ---
72
107
 
@@ -102,7 +137,7 @@ GH_TOKEN="$(grep -oE '(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]+' "$PAT_FILE
102
137
  3. 마스킹으로 인해 재현 정보가 소실되는 경우, 소실된 항목을 이슈 본문에 `<마스킹됨: 사유>`로 명시한다.
103
138
  4. 이슈 전송 전, **게시될 원문 전체(제목·라벨·본문 전문)와 마스킹 결과 요약**(무엇을 몇 건 가렸는지)을 사용자에게 제시하고 **최종 확인을 받는다**. 사용자 승인 없이 전송하지 않는다.
104
139
 
105
- > **4번은 프롬프트 규약이 아니라 플러그인 훅이 물리적으로 강제한다.** GitHub 쓰기 API 호출(이슈·코멘트·Gist·라벨)은 payload 파일에 대한 사용자 승인 마커(`<payload>.approved`)가 없거나, 승인 이후 내용이 변경되었으면 훅이 차단한다. 절차는 7장 참조.
140
+ > **4번은 프롬프트 규약이 아니라 플러그인 훅이 물리적으로 강제한다.** GitHub 쓰기 API 호출(이슈·코멘트·Gist·라벨)은 사용자가 세션 승인 프롬프트에서 yes 로 답하고 ② payload 원문이 세션에 표시된 그대로일 때만 통과한다. 표시 이후 내용이 바뀌었으면 훅이 차단한다. 절차는 7장 참조.
106
141
 
107
142
  스캔 보조:
108
143
 
@@ -213,7 +248,7 @@ curl -sS -X POST https://api.github.com/gists \
213
248
  -d @/tmp/makdoong2-issue/gist-payload.json
214
249
  ```
215
250
 
216
- > Gist 생성도 GitHub 쓰기이므로 7-1 의 사용자 승인 게이트를 동일하게 거친다 (payload 절대 경로 + 원문 표시 + 승인 마커).
251
+ > Gist 생성도 GitHub 쓰기이므로 7-1 의 사용자 승인 게이트를 동일하게 거친다 (payload 절대 경로 + `cat` 원문 표시 + 세션 내 yes/no).
217
252
 
218
253
  ---
219
254
 
@@ -394,15 +429,15 @@ curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/issues/<n
394
429
  GitHub 쓰기 호출(이슈·코멘트·Gist·라벨 생성)은 다음 절차를 거쳐야만 훅을 통과한다:
395
430
 
396
431
  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
432
+ 2. **게시될 원문 전체를 세션에 그대로 표시한다** — 제목, 라벨, 본문 전문. 요약·발췌로 대체하지 않는다. 표시는 **체이닝 없는 단독 `cat`** 이어야 한다:
433
+ ```bash
434
+ cat /tmp/makdoong2-issue/issue-payload.json
401
435
  ```
402
- `<SCRIPTS_DIR>` npm 모듈의 scripts 디렉토리다 (전역 설치 기준 `$(npm root -g)/makdoong2-team/scripts`). 스크립트는 payload 원문을 다시 화면에 보여주고 stdin 으로 승인을 받은 `<payload>.approved` 마커(sha256)를 기록한다.
403
- 4. 마커가 생긴 **단일 curl 명령**으로 전송한다. 체이닝(`;`, `&&`, `|`)·리다이렉트·인라인 JSON(`-d '{...}'`) 훅이 거부한다.
436
+ 훅이 시점의 sha256 **표시 증명**으로 기록한다. `;`·`&&`·`|`·리다이렉트·`$()` 섞이면 증명으로 인정되지 않는다 사용자가 내용과 파일에 남는 내용이 갈라질 있기 때문이다. 가독성을 위해 `jq` 렌더링을 덧붙이는 것은 자유지만, 증명은 `cat` 원문에서만 나온다.
437
+ 3. **사용자에게 게시 여부를 묻는다 (yes/no).** 4번의 curl 호출하면 opencode 세션 안에서 승인 프롬프트를 띄우므로, 별도 스크립트를 실행시키지 않는다. 질문은 한 문장으로 명확히 한다 — 예: "위 원문 그대로 public 저장소에 이슈로 등록할까요? (yes/no)". 프롬프트에서 **"Allow once" 를 고르도록 안내한다** — "Allow always" 는 남은 세션 동안 승인 질문 자체를 없앤다. 사용자가 거부하면 전송하지 않고 무엇을 고칠지 확인한다.
438
+ 4. **단일 curl 명령**으로 전송하며, payload 표기는 정확히 **`-d @/절대경로`** (공백 하나, 등호·따옴표 없이) 여야 한다. 이 표기가 곧 3번의 승인 프롬프트를 띄우는 조건이므로, 같은 의미의 `--data @file`·`--data-binary @file`·`-d=@file` 은 **질문 없이 전송되어** 훅이 거부한다. 체이닝(`;`, `&&`, `|`)·리다이렉트·인라인 JSON(`-d '{...}'`)·`gh` CLI 도 거부된다.
404
439
 
405
- 승인은 **1회용**이고 **내용에 바인딩**된다: 전송이 실행되면 마커는 훅이 자동 삭제하며, payload 를 승인 후 수정하면 해시 불일치로 차단된다 (수정했으면 2번부터 재승인). 에이전트가 승인 스크립트를 직접 실행하거나 `.approved` 마커를 조작하는 것은 훅이 차단한다.
440
+ 승인은 **1회용**이고 **사용자가 본 원문에 바인딩**된다: 전송이 실행되면 표시 증명은 훅이 자동 폐기하고(성공·실패 무관), 표시 이후 payload 를 수정하면 해시 불일치로 차단된다 (수정했으면 2번부터 다시). 승인 자체는 opencode permission 프롬프트에서만 나오므로 에이전트가 스스로 만들어낼 수 있는 경로가 없다.
406
441
 
407
442
  ### 7-2. 전송
408
443
 
@@ -441,7 +476,7 @@ curl -sS -X POST https://api.github.com/repos/y00njinuk/makdoong2-team/labels \
441
476
  shred -u /tmp/makdoong2-issue/*-payload.json 2>/dev/null || rm -f /tmp/makdoong2-issue/*-payload.json
442
477
  ```
443
478
 
444
- 승인 마커(`*.approved`)는 전송 시 훅이 자동 삭제하므로 에이전트가 지우지 않는다 (마커 조작은 훅이 차단).
479
+ 표시 증명은 전송 시 훅이 자동 폐기한다. 재전송(같은 원문이라도)에는 2번의 재표시와 3번의 재승인이 다시 필요하다.
445
480
 
446
481
  ---
447
482
 
@@ -1,87 +0,0 @@
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'