makdoong2-team 1.5.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 +3 -3
- package/agents/makdoong2-issue-reporter.md +24 -2
- package/agents/makdoong2-team-leader.md +2 -0
- package/dist/issue-reporter-guard.d.ts +55 -0
- package/dist/issue-reporter-guard.js +177 -0
- package/dist/opencode-plugin.js +134 -11
- package/dist/state-access-guard.d.ts +51 -0
- package/dist/state-access-guard.js +167 -0
- package/package.json +1 -1
- package/scripts/lib/confirm.sh +77 -0
- package/scripts/publish-if-changed.sh +32 -20
- package/scripts/release.sh +41 -26
- package/scripts/run-tests.mjs +2 -0
- package/scripts/state.sh +70 -4
- package/skills/makdoong2-issue-reporter/SKILL.md +75 -14
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 이므로 사내 정보는 마스킹 후 첨부되며, **전송 전
|
|
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:
|
|
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
|
---
|
|
@@ -24,7 +40,13 @@ permission:
|
|
|
24
40
|
## 하드룰
|
|
25
41
|
|
|
26
42
|
1. **첫 행동으로 `skill(name="makdoong2-issue-reporter")` 를 로드**하고, 스킬에 정의된 절차를 그대로 따른다. 실행 순서는 스킬이 고정한다: **수집 → 이상 지점 포착 → 마스킹 → 중복 확인 → 최소 질의 → 이슈 생성**.
|
|
27
|
-
2.
|
|
43
|
+
2. **GitHub 게시(이슈·코멘트·Gist·라벨)는 훅이 강제하는 사용자 승인 게이트를 통과해야만 가능하다.** 절차는 고정이다:
|
|
44
|
+
1. payload 를 **리터럴 절대 경로** JSON 파일로 작성한다 (예: `/tmp/makdoong2-issue/issue-payload.json`).
|
|
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
|
+
- 차단 메시지를 받으면 우회하지 말고 지시대로 재표시·재승인을 거친다. 승인을 스스로 만들어낼 수 있는 경로는 없다.
|
|
28
50
|
3. **토큰(PAT)은 어디에도 원문 노출 금지.** 커맨드 문자열에 직접 박지 않고 환경변수로 전달하며, 출력에는 마스킹(`ghp_****`)만 허용한다.
|
|
29
51
|
4. **워크플로우 상태를 변경하지 않는다.** state.json 은 증거 수집을 위한 읽기(`state.sh get`)만 허용. `state.sh set` / dispatch 계열 툴 호출 금지. 이 에이전트는 워크플로우 오케스트레이션과 완전히 분리된 조사·보고 전용이다.
|
|
30
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,62 @@
|
|
|
1
1
|
export declare const ISSUE_REPORTER_SKILL_NAME = "makdoong2-issue-reporter";
|
|
2
2
|
export declare const ISSUE_REPORTER_AGENT = "makdoong2-issue-reporter";
|
|
3
|
+
/** GitHub API 호출 분류 결과 */
|
|
4
|
+
export type GithubApiCall = {
|
|
5
|
+
kind: "none";
|
|
6
|
+
} | {
|
|
7
|
+
kind: "read";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "mutation";
|
|
10
|
+
payloadPaths: string[];
|
|
11
|
+
problems: string[];
|
|
12
|
+
} | {
|
|
13
|
+
kind: "forbidden-client";
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* issue-reporter 의 bash 명령을 GitHub API 관점에서 분류한다.
|
|
18
|
+
*
|
|
19
|
+
* - api.github.com 미참조 → none (이 게이트와 무관)
|
|
20
|
+
* - curl 이 아닌 클라이언트(node/python/wget/gh …)로 api.github.com 접근
|
|
21
|
+
* → forbidden-client. 훅이 payload 를 검사할 수 있는 형태가 curl -d @file
|
|
22
|
+
* 뿐이므로 다른 클라이언트는 쓰기·읽기 불문 전부 막는다 (읽기는 curl 로).
|
|
23
|
+
* - curl 이지만 mutation 징후(-X POST/PATCH/PUT/DELETE, 데이터 플래그) 없음 → read
|
|
24
|
+
* - mutation → payload 파일 경로 추출. 문제(인라인 데이터, 상대 경로, 변수 포함)는
|
|
25
|
+
* problems 로 수집한다 — 호출부는 problems 가 하나라도 있으면 차단한다.
|
|
26
|
+
*/
|
|
27
|
+
export declare function classifyGithubApiCall(cmd: string): GithubApiCall;
|
|
28
|
+
export declare function sha256Hex(content: string | Buffer): string;
|
|
29
|
+
/**
|
|
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
|
+
* 불일치 사유를 문자열로 반환하고, 유효하면 null.
|
|
42
|
+
*/
|
|
43
|
+
export declare function displayMismatch(payloadContent: Buffer, shownHash: string | undefined): string | null;
|
|
3
44
|
/** skill 툴 args 에서 스킬 이름을 추출한다. { name } 및 { arguments: { name } } 형태 수용. */
|
|
4
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;
|
|
5
60
|
/**
|
|
6
61
|
* skill 툴 호출이 issue-reporter 트리거 정책을 위반하면 사용자에게 보여줄
|
|
7
62
|
* 에러 메시지를 반환하고, 정상이면 null 을 반환한다.
|
|
@@ -15,8 +15,156 @@
|
|
|
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
|
+
// (가) 의사표시 — 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|$)/;
|
|
50
|
+
const MUTATION_METHOD_RE = /(?:-X|--request)[= ]*['"]?(POST|PATCH|PUT|DELETE)\b/i;
|
|
51
|
+
const DATA_FLAG_RE = /(^|[\s'"])(-d|--data|--data-binary|--data-raw|--data-urlencode|--json|-F|--form)([= ]|$)/;
|
|
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|$)/;
|
|
63
|
+
/**
|
|
64
|
+
* issue-reporter 의 bash 명령을 GitHub API 관점에서 분류한다.
|
|
65
|
+
*
|
|
66
|
+
* - api.github.com 미참조 → none (이 게이트와 무관)
|
|
67
|
+
* - curl 이 아닌 클라이언트(node/python/wget/gh …)로 api.github.com 접근
|
|
68
|
+
* → forbidden-client. 훅이 payload 를 검사할 수 있는 형태가 curl -d @file
|
|
69
|
+
* 뿐이므로 다른 클라이언트는 쓰기·읽기 불문 전부 막는다 (읽기는 curl 로).
|
|
70
|
+
* - curl 이지만 mutation 징후(-X POST/PATCH/PUT/DELETE, 데이터 플래그) 없음 → read
|
|
71
|
+
* - mutation → payload 파일 경로 추출. 문제(인라인 데이터, 상대 경로, 변수 포함)는
|
|
72
|
+
* problems 로 수집한다 — 호출부는 problems 가 하나라도 있으면 차단한다.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyGithubApiCall(cmd) {
|
|
75
|
+
// gh CLI 는 URL 문자열 없이도 이슈·코멘트·Gist 를 만들 수 있으므로
|
|
76
|
+
// (gh issue create / gh api ...) api.github.com 참조 여부와 무관하게 막는다.
|
|
77
|
+
if (/(^|[;&|(]\s*|\s)gh\s+(api|issue|pr|gist|label|repo|release)\b/.test(cmd)) {
|
|
78
|
+
return {
|
|
79
|
+
kind: "forbidden-client",
|
|
80
|
+
reason: "gh CLI 는 사용할 수 없다. GitHub 접근은 curl 만 허용된다 (쓰기는 curl -d @<절대경로> 형태만 승인 검증 가능).",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!/api\.github\.com/i.test(cmd))
|
|
84
|
+
return { kind: "none" };
|
|
85
|
+
const usesCurl = /(^|[;&|(]\s*|\s)curl(\s|$)/.test(cmd);
|
|
86
|
+
if (!usesCurl) {
|
|
87
|
+
return {
|
|
88
|
+
kind: "forbidden-client",
|
|
89
|
+
reason: "api.github.com 접근은 curl 만 허용된다 (쓰기는 curl -d @<절대경로> 형태만 검증 가능).",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const hasMutationMethod = MUTATION_METHOD_RE.test(cmd);
|
|
93
|
+
const hasDataFlag = DATA_FLAG_RE.test(cmd);
|
|
94
|
+
// curl -G / --get 은 데이터 플래그를 쿼리 스트링으로 변환하는 GET 이다
|
|
95
|
+
// (중복 검색이 --data-urlencode 와 함께 쓴다). mutation method 가 명시되지
|
|
96
|
+
// 않은 -G 호출은 읽기로 분류한다.
|
|
97
|
+
const isGetConverted = /(^|\s)(-G|--get)(\s|$)/.test(cmd) && !hasMutationMethod;
|
|
98
|
+
if (isGetConverted)
|
|
99
|
+
return { kind: "read" };
|
|
100
|
+
if (!hasMutationMethod && !hasDataFlag)
|
|
101
|
+
return { kind: "read" };
|
|
102
|
+
const payloadPaths = [];
|
|
103
|
+
const problems = [];
|
|
104
|
+
for (const m of cmd.matchAll(PAYLOAD_AT_RE)) {
|
|
105
|
+
payloadPaths.push(m[2]);
|
|
106
|
+
}
|
|
107
|
+
if (payloadPaths.length === 0) {
|
|
108
|
+
problems.push("payload 는 반드시 파일로 전달한다: -d @</absolute/path/payload.json>. " +
|
|
109
|
+
"인라인 JSON(-d '{...}')과 stdin(-d @-)은 승인 검증이 불가능해 금지된다.");
|
|
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
|
+
}
|
|
117
|
+
for (const p of payloadPaths) {
|
|
118
|
+
if (!p.startsWith("/")) {
|
|
119
|
+
problems.push(`payload 경로는 리터럴 절대 경로여야 한다: "${p}"`);
|
|
120
|
+
}
|
|
121
|
+
if (/[$\`]/.test(p)) {
|
|
122
|
+
problems.push(`payload 경로에 변수·명령 치환을 쓸 수 없다: "${p}"`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// TOCTOU 방어: 훅은 "명령 실행 전" 파일 해시를 검증하므로, 같은 명령 안에서
|
|
126
|
+
// payload 를 다시 쓰고(curl 앞에 echo > file 등) 전송하면 검증을 우회할 수 있다.
|
|
127
|
+
// 그래서 쓰기 호출은 셸 제어 연산자·치환이 없는 "단일 curl 호출"만 허용한다.
|
|
128
|
+
// (mutation POST URL 에는 &, ; 등이 필요한 경우가 없다 — 읽기 호출은 이 검사 밖.)
|
|
129
|
+
if (/[;|<>\n`]|\$\(|&&|\s&(\s|$)/.test(cmd)) {
|
|
130
|
+
problems.push("GitHub 쓰기 호출은 단일 curl 명령이어야 한다 — 체이닝(;, &&, |, &), " +
|
|
131
|
+
"리다이렉트(<, >), 명령 치환($(), `)을 함께 쓸 수 없다.");
|
|
132
|
+
}
|
|
133
|
+
return { kind: "mutation", payloadPaths, problems };
|
|
134
|
+
}
|
|
135
|
+
export function sha256Hex(content) {
|
|
136
|
+
return createHash("sha256").update(content).digest("hex");
|
|
137
|
+
}
|
|
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;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 표시 증명 검증: 사용자가 본 원문의 해시가 지금 전송하려는 payload 와 같은가.
|
|
157
|
+
* 불일치 사유를 문자열로 반환하고, 유효하면 null.
|
|
158
|
+
*/
|
|
159
|
+
export function displayMismatch(payloadContent, shownHash) {
|
|
160
|
+
if (!shownHash) {
|
|
161
|
+
return "이 payload 의 원문이 세션에 표시된 적이 없다 — 사용자는 무엇이 게시되는지 볼 수 없었다";
|
|
162
|
+
}
|
|
163
|
+
if (sha256Hex(payloadContent) !== shownHash) {
|
|
164
|
+
return "표시 이후 payload 내용이 변경됐다 — 승인은 사용자가 본 원문에 바인딩되며, 변경된 내용은 재표시·재승인이 필요하다";
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
20
168
|
/** skill 툴 args 에서 스킬 이름을 추출한다. { name } 및 { arguments: { name } } 형태 수용. */
|
|
21
169
|
export function extractSkillNameFromArgs(args) {
|
|
22
170
|
if (!args || typeof args !== "object")
|
|
@@ -32,6 +180,35 @@ export function extractSkillNameFromArgs(args) {
|
|
|
32
180
|
}
|
|
33
181
|
return undefined;
|
|
34
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
|
+
}
|
|
35
212
|
/**
|
|
36
213
|
* skill 툴 호출이 issue-reporter 트리거 정책을 위반하면 사용자에게 보여줄
|
|
37
214
|
* 에러 메시지를 반환하고, 정상이면 null 을 반환한다.
|
package/dist/opencode-plugin.js
CHANGED
|
@@ -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 } 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
|
-
|
|
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 (
|
|
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
|
|
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,6 +1047,25 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1020
1047
|
`first invoke skill(name="${skillName}") in the current session, then retry.`);
|
|
1021
1048
|
}
|
|
1022
1049
|
}
|
|
1050
|
+
// ── Issue-reporter: payload 를 고쳐 쓰면 표시 증명이 무효가 된다 ──
|
|
1051
|
+
// 마커 파일 방식과 달리 표시 증명은 프로세스 메모리에 있어 위조할 수 없다.
|
|
1052
|
+
// 다만 사용자에게 보여준 뒤 파일만 바꿔치기하는 경로가 남으므로, write 계열
|
|
1053
|
+
// 툴이 표시된 payload 를 건드리면 그 증명을 즉시 폐기한다 (재표시 필요).
|
|
1054
|
+
if (agent === ISSUE_REPORTER_AGENT && (LEADER_FORBIDDEN_TOOLS.has(toolLower) || WRITE_TOOLS.has(toolLower))) {
|
|
1055
|
+
// filePath 인자뿐 아니라 인자 전체를 검사한다 — apply_patch 는 파일 경로가
|
|
1056
|
+
// 패치 본문 안에 들어 있어 filePath 추출로는 대상 파일을 알 수 없다.
|
|
1057
|
+
let argsSerialized = "";
|
|
1058
|
+
try {
|
|
1059
|
+
argsSerialized = JSON.stringify(output.args ?? "");
|
|
1060
|
+
}
|
|
1061
|
+
catch { /* ignore */ }
|
|
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
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1023
1069
|
// ── Leader hardrule 1: 직접 파일 편집·생성 금지 (write/edit/patch/multiedit) ──
|
|
1024
1070
|
if (agent === "makdoong2-team-leader" && LEADER_FORBIDDEN_TOOLS.has(toolLower)) {
|
|
1025
1071
|
logger.error(`[makdoong2-team hook] BLOCKED: team-leader가 ${input.tool} 툴 호출을 시도했다.`);
|
|
@@ -1034,11 +1080,17 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1034
1080
|
return;
|
|
1035
1081
|
// ── Universal state.json hardrule: agent 식별 결과와 무관하게 차단 ──
|
|
1036
1082
|
// sessionAgent map race, primary/outer session, undefined agent 모두 포함.
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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(",")})`);
|
|
1042
1094
|
}
|
|
1043
1095
|
// ── Leader hardrule 2: Bash 우회 파일 쓰기 금지 (state.json 제외) ──
|
|
1044
1096
|
if (agent === "makdoong2-team-leader" && looksLikeFileWrite(cmd)) {
|
|
@@ -1059,6 +1111,41 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1059
1111
|
`• 초안 파일 필요 시: spec을 team-leader에게 반환 → dev 단계에서 engineer가 구현.\n\n` +
|
|
1060
1112
|
`**참조**: agents/${agent}.md "금지" 섹션, CLAUDE.md "워크플로우 상태 & 위임 규약"`);
|
|
1061
1113
|
}
|
|
1114
|
+
// ── Issue-reporter 게시 게이트: GitHub 쓰기는 사용자가 본 원문만 ──
|
|
1115
|
+
// 승인의 의사표시는 opencode permission 프롬프트(세션 내 yes/no)가 받는다.
|
|
1116
|
+
// 여기서 강제하는 것은 나머지 절반 — 형식(검증 가능한 형태)과 표시 증명
|
|
1117
|
+
// (사용자가 본 원문 == 전송되는 원문). 계약 상세: src/issue-reporter-guard.ts 상단.
|
|
1118
|
+
if (agent === ISSUE_REPORTER_AGENT) {
|
|
1119
|
+
const approveHint = `승인 절차: ① payload 를 리터럴 절대경로 JSON 파일로 쓴다 → ② 'cat <payload>' 로 원문 전체를\n` +
|
|
1120
|
+
`세션에 표시한다 (체이닝 없이 단독 실행 — 이 출력이 사용자가 보는 원문이고 훅이 해시를 기록한다) →\n` +
|
|
1121
|
+
`③ 단일 curl -d @<payload> 로 전송하면 opencode 가 사용자에게 게시 여부를 묻는다 (yes/no).\n` +
|
|
1122
|
+
`표시 이후 payload 를 고치면 증명이 무효가 되므로 ②부터 다시 한다.`;
|
|
1123
|
+
const call = classifyGithubApiCall(cmd);
|
|
1124
|
+
if (call.kind === "forbidden-client") {
|
|
1125
|
+
logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter가 비-curl 클라이언트로 GitHub API 접근. cmd="${redactAndTruncate(cmd, 200)}"`);
|
|
1126
|
+
throw new Error(`[makdoong2-team issue-reporter 게시 게이트] ${call.reason}\n${approveHint}`);
|
|
1127
|
+
}
|
|
1128
|
+
if (call.kind === "mutation") {
|
|
1129
|
+
if (call.problems.length > 0) {
|
|
1130
|
+
logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter GitHub 쓰기 형식 위반: ${call.problems.join(" / ")}`);
|
|
1131
|
+
throw new Error(`[makdoong2-team issue-reporter 게시 게이트] GitHub 쓰기 호출 형식 위반:\n` +
|
|
1132
|
+
call.problems.map((p) => ` - ${p}`).join("\n") + `\n${approveHint}`);
|
|
1133
|
+
}
|
|
1134
|
+
for (const payloadPath of call.payloadPaths) {
|
|
1135
|
+
if (!existsSync(payloadPath)) {
|
|
1136
|
+
throw new Error(`[makdoong2-team issue-reporter 게시 게이트] payload 파일이 없다: ${payloadPath}\n${approveHint}`);
|
|
1137
|
+
}
|
|
1138
|
+
const mismatch = displayMismatch(readFileSync(payloadPath), issueReporterShownPayloads.get(payloadPath));
|
|
1139
|
+
if (mismatch !== null) {
|
|
1140
|
+
logger.error(`[makdoong2-team hook] BLOCKED: issue-reporter GitHub 쓰기 — ${mismatch} (${payloadPath})`);
|
|
1141
|
+
throw new Error(`[makdoong2-team issue-reporter 게시 게이트] ${mismatch}.\n` +
|
|
1142
|
+
`게시될 원문을 세션에 그대로 표시하라 (단독 실행):\n` +
|
|
1143
|
+
` cat ${payloadPath}\n${approveHint}`);
|
|
1144
|
+
}
|
|
1145
|
+
logger.debug(`[makdoong2-team hook] issue-reporter 표시 증명 확인: ${payloadPath} (hash 일치)`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1062
1149
|
const hookIssue = sessionIssue.get(sessionID ?? "") ?? "";
|
|
1063
1150
|
const r = await runScript(HOOKS_DIR, "guard-bash.sh", cmd, hookIssue);
|
|
1064
1151
|
if (!r.ok) {
|
|
@@ -1081,6 +1168,39 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
1081
1168
|
else
|
|
1082
1169
|
sessionActiveToolCount.set(afterSessionID, cur - 1);
|
|
1083
1170
|
}
|
|
1171
|
+
// ── Issue-reporter 표시 증명 기록·소멸 ──
|
|
1172
|
+
// 기록: 단독 `cat <payload>` 가 실행되면 그 시점의 파일 해시를 남긴다. 이
|
|
1173
|
+
// 출력이 사용자가 세션에서 실제로 본 원문이고, 게시 게이트가 전송 직전에
|
|
1174
|
+
// 같은 해시인지 대조한다.
|
|
1175
|
+
// 소멸: GitHub 쓰기가 "실행"되고 나면 결과와 무관하게 증명을 폐기한다.
|
|
1176
|
+
// 성공이면 재게시에 새 승인이 필요하고, 실패(네트워크 등)여도 재시도 전에
|
|
1177
|
+
// 다시 보여주고 다시 묻는다 — 증명을 남겨두면 실패를 빌미로 승인 한 번에
|
|
1178
|
+
// 여러 번의 전송이 가능해지므로 엄격한 쪽을 택했다.
|
|
1179
|
+
if (toolLowerAfter === "bash") {
|
|
1180
|
+
const afterAgent = afterSessionID
|
|
1181
|
+
? (sessionAgent.get(afterSessionID) ?? pendingDispatch.get(afterSessionID)?.agent)
|
|
1182
|
+
: undefined;
|
|
1183
|
+
if (afterAgent === ISSUE_REPORTER_AGENT) {
|
|
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
|
+
}
|
|
1194
|
+
const afterCall = classifyGithubApiCall(afterCmd);
|
|
1195
|
+
if (afterCall.kind === "mutation") {
|
|
1196
|
+
for (const payloadPath of afterCall.payloadPaths) {
|
|
1197
|
+
if (issueReporterShownPayloads.delete(payloadPath)) {
|
|
1198
|
+
logger.debug(`[makdoong2-team hook] issue-reporter 표시 증명 소멸(1회용): ${payloadPath}`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1084
1204
|
if (afterSessionID && WRITE_TOOLS.has(toolLowerAfter)) {
|
|
1085
1205
|
const agentAfter = sessionAgent.get(afterSessionID)
|
|
1086
1206
|
?? pendingDispatch.get(afterSessionID)?.agent;
|
|
@@ -2493,8 +2613,11 @@ export const Makdoong2TeamPlugin = async ({ $, client, directory, worktree }) =>
|
|
|
2493
2613
|
effective_cwd: effectiveCwd,
|
|
2494
2614
|
reason: `state.json 을 판독할 수 없습니다. worktree cwd 기준 예상 경로: ${expectedPath}. ` +
|
|
2495
2615
|
`주요 원인: (1) wt-sync-ignored.sh 로 worktree 동기화 미수행, (2) 다른 cwd(main repo)에서 state.json 을 조작하여 worktree 사본과 불일치, (3) state.json 손상.`,
|
|
2496
|
-
next_action:
|
|
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}' 로 재동기화하거나, ` +
|
|
2497
2619
|
`'bash ${SCRIPTS_DIR}/state.sh init ${args.issue} ${effectiveCwd}' 로 초기화하세요. ` +
|
|
2620
|
+
`③ readable=false (JSON 손상) 면 사용자에게 에스컬레이션하세요. ` +
|
|
2498
2621
|
`근본 원인이 해소되기 전에는 dispatch_stage 를 호출하지 마세요.`,
|
|
2499
2622
|
});
|
|
2500
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;
|