From fe224f5b2c07c749f287944970022e5228b2a6a1 Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 27 Jul 2026 15:57:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(gh-egress):=20empty-body=20guard=20?= =?UTF-8?q?=E2=80=94=20refuse=20the=20data-loss=20dispatch=20(exit=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing net inspects what the body CONTAINS; none checked whether it EXISTS. An empty/whitespace-only body sailed through all nets and, on edit (overwrite semantics), wiped the target issue body — live incident 2026-07-22 (bestOCR#1: upstream python composition failed silently, '$(cat empty)' went out at scrub-attested warn, 40s restore only because a local copy existed). New guard after arg parse: body channel PROVIDED + stripped-empty -> refuse for all three verbs; edit additionally floors at 10 stripped chars ('done' is a legit short comment, a 3-char body OVERWRITE almost never is; byte-length on multibyte only relaxes the floor — safe direction). Wrapper-origin code 15 (band >=10 per #227 — the issue's suggested exit 5 would break the rc<10-is-gh invariant). Explicit-intent escape --allow-empty-body (consumed, never forwarded; same pending-value malformed-shape guard as the attestation flags). Label-only edits with no body channel are untouched. Suite +8 fixtures (RED 6 -> GREEN 75/0). Refs #275 --- plugins/issue-driven-dev/scripts/gh-egress.sh | 43 +++++++++++++++++++ .../scripts/tests/gh-egress/test.sh | 32 ++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/plugins/issue-driven-dev/scripts/gh-egress.sh b/plugins/issue-driven-dev/scripts/gh-egress.sh index 7f88f41..36fe955 100755 --- a/plugins/issue-driven-dev/scripts/gh-egress.sh +++ b/plugins/issue-driven-dev/scripts/gh-egress.sh @@ -73,6 +73,10 @@ # 12 unscannable --body-file (not a readable regular file, #203 item 3) # 13 attestation missing/invalid (--scrub-attested absent or bad level) # 14 usage error (bad/missing verb, malformed/split-token args, flag missing its value) +# 15 empty/near-empty body on the body channel (#275 — the signature of upstream +# composition failing silently; edit additionally floors at 10 stripped chars +# because its overwrite semantics turn an empty dispatch into data loss. +# Explicit intent escape: --allow-empty-body, consumed by the wrapper) # # TEST OVERRIDES (test-only; never set in production) # IDD_GH_BIN gh binary to exec (default: gh) @@ -106,6 +110,7 @@ require_scannable_bodyfile() { } ATTESTED="" +ALLOW_EMPTY_BODY="" # #275 escape hatch: explicit intent to dispatch an empty/short body MENTION_ATTESTED="" # comma-separated logins vetted via rules/tagging-collaborators.md 5-step (#117) GH_ARGS=() # forwarded to gh, byte-identical minus the attestation flag SCAN_PARTS=() # all drafted prose (--body / --title / --body-file) — privacy nets @@ -144,6 +149,14 @@ while [ $# -gt 0 ]; do *) ATTESTED="${arg#--scrub-attested=}"; shift; continue ;; esac ;; + --allow-empty-body) + # #275: same pending-value guard as the attestation flags — this flag + # appearing where a body/title value is expected means split tokens. + if [ -n "$next_is" ]; then + echo "✗ gh-egress: malformed args — '--allow-empty-body' found where a value for --body/--title/--body-file was expected." >&2 + exit 14 + fi + ALLOW_EMPTY_BODY=1; shift; continue ;; -b|--body|-t|--title) GH_ARGS+=("$arg") case "$arg" in -t|--title) next_is="title" ;; *) next_is="body" ;; esac @@ -193,6 +206,36 @@ esac # Joined once; the net only ever inspects the drafted prose, never --repo / # --label / --milestone etc. (so metadata-only edits are never false-flagged). SCAN="" +# ── #275 empty-body guard — presence check, not content check ──────────────── +# Every other net inspects what the body CONTAINS; this one inspects whether it +# EXISTS. An empty/whitespace-only body on the body channel is the signature of +# upstream shell composition failing silently (`"$(cat file)"` on a file that +# was never written) — on `edit` (overwrite semantics) dispatching it WIPES the +# target body irreversibly (live incident 2026-07-22). Refuse in the wrapper +# band (15) unless the caller states intent with --allow-empty-body. +if [ "${#BODY_PARTS[@]}" -gt 0 ] && [ "$ALLOW_EMPTY_BODY" != "1" ]; then + _BODY_JOINED="" + for p in "${BODY_PARTS[@]}"; do _BODY_JOINED+="$p"; done + _BODY_STRIPPED="$(printf '%s' "$_BODY_JOINED" | tr -d '[:space:]')" + if [ -z "$_BODY_STRIPPED" ]; then + echo "✗ gh-egress: REFUSED — the body channel is empty (whitespace-only)." >&2 + echo " This is the signature of upstream composition failing silently (e.g. \"\$(cat file)\" on an empty file)." >&2 + [ "$VERB" = "edit" ] && echo " edit has OVERWRITE semantics — dispatching this would wipe the target body." >&2 + echo " If an empty body is genuinely intended, re-dispatch with --allow-empty-body." >&2 + exit 15 + fi + # edit-only near-empty floor: overwrite semantics justify a stricter bar than + # comment/create ("done" is a legitimate short comment; a 3-char issue body + # overwrite almost never is). Byte-length check — multibyte text inflates the + # count, which only ever RELAXES the floor (safe direction). + if [ "$VERB" = "edit" ] && [ "${#_BODY_STRIPPED}" -lt 10 ]; then + echo "✗ gh-egress: REFUSED — edit body is near-empty (<10 chars after stripping whitespace)." >&2 + echo " edit OVERWRITES the target; a near-empty body is far more often a composition bug than intent." >&2 + echo " If this tiny body is genuinely intended, re-dispatch with --allow-empty-body." >&2 + exit 15 + fi +fi + for p in "${SCAN_PARTS[@]:-}"; do SCAN+="$p"$'\n'; done net_refuse() { echo "✗ gh-egress: REFUSED — mechanical net caught $1." >&2 diff --git a/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh b/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh index b9722ae..0ac0623 100644 --- a/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh +++ b/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh @@ -381,4 +381,36 @@ assert_output_grep "binding: SKILL template enumerates user-pasted" "points-fro assert_output_grep "binding: wrapper greps the same reply token" "grep -Fq -- 'type=reply'" "$SCRIPT" assert_output_grep "binding: wrapper greps the same user-pasted token" "grep -Fq -- 'points-from=user-pasted'" "$SCRIPT" +# ── §2.6 empty-body guard (#275) — presence check, not content check ───────── +# Upstream `"$(cat file)"` + silent composition failure dispatches an empty +# body; on `edit` (overwrite semantics) that WIPES the issue body. Wrapper- +# origin refusal code 15 (band >=10 per #227 — the issue's suggested exit 5 +# would break the rc<10-is-gh invariant). + +bash "$SCRIPT" edit 7 --repo o/r --body "" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit with empty body → refuse (exit 15)" 15 $? + +bash "$SCRIPT" edit 7 --repo o/r --body "hi" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit with <10-char body → refuse (overwrite floor, exit 15)" 15 $? + +OUT="$(bash "$SCRIPT" edit 7 --repo o/r --body "" --allow-empty-body "${ATT[@]}" 2>/dev/null)" +assert_exit "edit empty + --allow-empty-body → dispatch (exit 0)" 0 $? +ARGV="$(tr '\0' '\n' < "$FAKE_GH_ARGV")" +refute_grep "wrapper strips --allow-empty-body from gh argv" "--allow-empty-body" "$ARGV" + +bash "$SCRIPT" comment 7 --repo o/r --body " " "${ATT[@]}" >/dev/null 2>&1 +assert_exit "comment with whitespace-only body → refuse (exit 15)" 15 $? + +bash "$SCRIPT" comment 7 --repo o/r --body "done" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "comment with short-but-real body → dispatch (no floor on comment)" 0 $? + +printf '' > "$WORK/empty-body.md" +bash "$SCRIPT" create --repo o/r --title T --body-file "$WORK/empty-body.md" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "create with empty body-file → refuse (exit 15)" 15 $? + +bash "$SCRIPT" edit 7 --repo o/r --add-label bug "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit with NO body channel (label-only) → guard silent, dispatch" 0 $? + +assert_output_grep "header documents refusal code 15" "15 empty/near-empty body" "$SCRIPT" + print_summary "gh-egress" From 3982e5b0f20117db3242f26797e6f7ec062ae45c Mon Sep 17 00:00:00 2001 From: che cheng Date: Mon, 27 Jul 2026 16:05:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(gh-egress,idd-edit):=20edit-comment=20?= =?UTF-8?q?verb=20=E2=80=94=20comment=20surgery=20enters=20the=20nets,=20b?= =?UTF-8?q?atch=20consumes=20the=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause ran deeper than the filed symptom: idd-edit's Step-6 PATCH went through raw 'gh api', a channel the #226 rollout explicitly whitelisted as 'tracked separately' — so it bypassed EVERY net (privacy / mention / attestation / tier-floor / #275 empty-body), and classifying wrapper exit codes in the batch loop without wiring the channel first would have been a placebo (diagnosis correction on the issue). Two-part fix: (1) wrapper: new edit-comment verb — 'edit-comment --repo o/r --body-file ' dispatches the comment PATCH ('gh api .../comments/:id -X PATCH -F body=@file'); the body-file content rides the existing scan pipeline so all nets apply automatically, and the #275 near-empty floor covers it (overwrite semantics). --body-file is mandatory (14 otherwise). (2) idd-edit batch: Step 6 dispatches via the verb; rc>=10 lands in the new EGRESS_REFUSED bucket (warn-continue, #158 semantics), 0 [gh issue args...] \ +# gh-egress.sh edit-comment --repo owner/repo --body-file \ # #273: comment-PATCH surgery, all nets apply # --scrub-attested # # On success the wrapper `exec`s the real gh so stdout / stderr / exit code are @@ -85,15 +86,15 @@ set -u usage() { - echo "✗ gh-egress: usage: gh-egress.sh [gh args...] --scrub-attested " >&2 + echo "✗ gh-egress: usage: gh-egress.sh [gh args...] --scrub-attested " >&2 } # --- verb (first positional) ------------------------------------------------- VERB="${1:-}" case "$VERB" in - create|comment|edit) shift ;; + create|comment|edit|edit-comment) shift ;; "") echo "✗ gh-egress: missing egress verb." >&2; usage; exit 14 ;; - *) echo "✗ gh-egress: unknown egress verb '$VERB' (only create|comment|edit route through this gate)." >&2; usage; exit 14 ;; + *) echo "✗ gh-egress: unknown egress verb '$VERB' (only create|comment|edit|edit-comment route through this gate)." >&2; usage; exit 14 ;; esac # --- parse: pull out --scrub-attested, forward everything else verbatim ------- @@ -111,6 +112,9 @@ require_scannable_bodyfile() { ATTESTED="" ALLOW_EMPTY_BODY="" # #275 escape hatch: explicit intent to dispatch an empty/short body +EC_ID="" # edit-comment: target comment id (first bare numeric positional, #273) +EC_REPO="" # edit-comment: owner/repo (from --repo) +BODYFILE_PATH="" # edit-comment: dispatch needs the PATH, not just the content MENTION_ATTESTED="" # comma-separated logins vetted via rules/tagging-collaborators.md 5-step (#117) GH_ARGS=() # forwarded to gh, byte-identical minus the attestation flag SCAN_PARTS=() # all drafted prose (--body / --title / --body-file) — privacy nets @@ -157,6 +161,10 @@ while [ $# -gt 0 ]; do exit 14 fi ALLOW_EMPTY_BODY=1; shift; continue ;; + --repo) + GH_ARGS+=("$arg"); next_is="repo"; shift; continue ;; + --repo=*) + GH_ARGS+=("$arg"); EC_REPO="${arg#--repo=}"; shift; continue ;; -b|--body|-t|--title) GH_ARGS+=("$arg") case "$arg" in -t|--title) next_is="title" ;; *) next_is="body" ;; esac @@ -172,17 +180,23 @@ while [ $# -gt 0 ]; do -F?*) GH_ARGS+=("$arg"); f="${arg#-F}" require_scannable_bodyfile "$f" + BODYFILE_PATH="$f" FCONTENT="$(cat "$f")"; SCAN_PARTS+=("$FCONTENT"); BODY_PARTS+=("$FCONTENT"); next_is=""; shift; continue ;; --body-file=*) GH_ARGS+=("$arg"); next_is=""; f="${arg#--body-file=}" require_scannable_bodyfile "$f" + BODYFILE_PATH="$f" FCONTENT="$(cat "$f")"; SCAN_PARTS+=("$FCONTENT"); BODY_PARTS+=("$FCONTENT"); shift; continue ;; *) GH_ARGS+=("$arg") case "$next_is" in body) SCAN_PARTS+=("$arg"); BODY_PARTS+=("$arg") ;; title) SCAN_PARTS+=("$arg") ;; - bodyfile) require_scannable_bodyfile "$arg"; FCONTENT="$(cat "$arg")" + repo) EC_REPO="$arg" ;; + bodyfile) require_scannable_bodyfile "$arg"; BODYFILE_PATH="$arg"; FCONTENT="$(cat "$arg")" SCAN_PARTS+=("$FCONTENT"); BODY_PARTS+=("$FCONTENT") ;; + *) if [ "$VERB" = "edit-comment" ] && [ -z "$EC_ID" ]; then + case "$arg" in ''|*[!0-9]*) : ;; *) EC_ID="$arg" ;; esac + fi ;; esac next_is=""; shift; continue ;; esac @@ -220,7 +234,7 @@ if [ "${#BODY_PARTS[@]}" -gt 0 ] && [ "$ALLOW_EMPTY_BODY" != "1" ]; then if [ -z "$_BODY_STRIPPED" ]; then echo "✗ gh-egress: REFUSED — the body channel is empty (whitespace-only)." >&2 echo " This is the signature of upstream composition failing silently (e.g. \"\$(cat file)\" on an empty file)." >&2 - [ "$VERB" = "edit" ] && echo " edit has OVERWRITE semantics — dispatching this would wipe the target body." >&2 + { [ "$VERB" = "edit" ] || [ "$VERB" = "edit-comment" ]; } && echo " edit has OVERWRITE semantics — dispatching this would wipe the target body." >&2 echo " If an empty body is genuinely intended, re-dispatch with --allow-empty-body." >&2 exit 15 fi @@ -228,7 +242,7 @@ if [ "${#BODY_PARTS[@]}" -gt 0 ] && [ "$ALLOW_EMPTY_BODY" != "1" ]; then # comment/create ("done" is a legitimate short comment; a 3-char issue body # overwrite almost never is). Byte-length check — multibyte text inflates the # count, which only ever RELAXES the floor (safe direction). - if [ "$VERB" = "edit" ] && [ "${#_BODY_STRIPPED}" -lt 10 ]; then + if { [ "$VERB" = "edit" ] || [ "$VERB" = "edit-comment" ]; } && [ "${#_BODY_STRIPPED}" -lt 10 ]; then echo "✗ gh-egress: REFUSED — edit body is near-empty (<10 chars after stripping whitespace)." >&2 echo " edit OVERWRITES the target; a near-empty body is far more often a composition bug than intent." >&2 echo " If this tiny body is genuinely intended, re-dispatch with --allow-empty-body." >&2 @@ -409,4 +423,13 @@ fi GH_BIN="${IDD_GH_BIN:-gh}" # ${arr[@]+...} idiom: empty array expands to NOTHING (":-" would yield one # phantom '' positional, #203 item 5); bash-3.2 safe. +if [ "$VERB" = "edit-comment" ]; then + # #273: comment-PATCH surgery — same nets, different dispatch shape. The + # #226 rollout whitelisted this channel as "tracked separately"; retired here. + if [ -z "$EC_ID" ] || [ -z "$EC_REPO" ] || [ -z "$BODYFILE_PATH" ]; then + echo "✗ gh-egress: edit-comment needs --repo owner/repo --body-file ." >&2 + usage; exit 14 + fi + exec "$GH_BIN" api "repos/${EC_REPO}/issues/comments/${EC_ID}" -X PATCH -F body=@"$BODYFILE_PATH" +fi exec "$GH_BIN" issue "$VERB" ${GH_ARGS[@]+"${GH_ARGS[@]}"} diff --git a/plugins/issue-driven-dev/scripts/tests/gh-egress-rollout/test.sh b/plugins/issue-driven-dev/scripts/tests/gh-egress-rollout/test.sh index 010387b..65f2e0e 100755 --- a/plugins/issue-driven-dev/scripts/tests/gh-egress-rollout/test.sh +++ b/plugins/issue-driven-dev/scripts/tests/gh-egress-rollout/test.sh @@ -12,9 +12,9 @@ # Whitelist (deliberately NOT wired / not egress): # - `gh issue close` — not a content egress verb (wrapper scope is # create|comment|edit per #202 D2) -# - `gh api ... PATCH comments` — idd-edit / audit-block PATCH surgery goes -# through gh api (comment-id scoped), tracked -# separately; wrapper wraps `gh issue` verbs +# - audit-block PATCH surgery (non-idd-edit sites) — tracked separately; +# idd-edit's Step-6 comment PATCH was RETIRED from this whitelist by #273: +# it now routes through the wrapper's edit-comment verb (asserted below) # - prose/table MENTIONS of `gh issue comment` (rules text, rationale) — only # executable call lines were wired # @@ -31,7 +31,11 @@ HELPERS="$HERE/../../lib/assert-helpers.sh" . "$HELPERS" # ── every rolled-out skill routes egress through the wrapper ── -for sk in idd-comment idd-diagnose idd-implement idd-verify idd-close idd-update; do +# #273: comment surgery wired — the raw Step-6 PATCH line is refuted verbatim +refute_output_grep "idd-edit: raw Step-6 comment PATCH gone (#273)" 'issues/comments/$COMMENT_ID \' "$SK/idd-edit/SKILL.md" +assert_output_grep "idd-edit: Step 6 dispatches via edit-comment verb" 'gh-egress.sh" edit-comment' "$SK/idd-edit/SKILL.md" + +for sk in idd-comment idd-diagnose idd-implement idd-verify idd-close idd-update idd-edit; do assert_output_grep "$sk: routes egress via gh-egress.sh" "gh-egress.sh" "$SK/$sk/SKILL.md" assert_output_grep "$sk: carries a scrub attestation" "--scrub-attested" "$SK/$sk/SKILL.md" done diff --git a/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh b/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh index 0ac0623..d1a2c0b 100644 --- a/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh +++ b/plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh @@ -413,4 +413,32 @@ assert_exit "edit with NO body channel (label-only) → guard silent, dispatch" assert_output_grep "header documents refusal code 15" "15 empty/near-empty body" "$SCRIPT" +# ── §2.7 edit-comment verb (#273) — comment-PATCH surgery enters the nets ──── +# The #226 rollout whitelisted `gh api ... PATCH comments` as "tracked +# separately"; that debt is retired here: a dedicated verb dispatches the +# PATCH so every net (privacy / mention / attestation / tier-floor / #275 +# empty-body) applies to comment surgery too. + +printf '%s' "reasonable replacement body for the comment surgery path" > "$WORK/patch-body.md" +bash "$SCRIPT" edit-comment 9 --repo o/r --body-file "$WORK/patch-body.md" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit-comment happy path → dispatch (exit 0)" 0 $? +ARGV="$(tr '\0' '\n' < "$FAKE_GH_ARGV")" +assert_grep "edit-comment dispatch is gh api" "api" "$ARGV" +assert_grep "edit-comment targets the comment endpoint" "repos/o/r/issues/comments/9" "$ARGV" +assert_grep "edit-comment method is PATCH" "PATCH" "$ARGV" +assert_grep "edit-comment body rides -F body=@file" "body=@$WORK/patch-body.md" "$ARGV" + +printf '%s' "leak /Users/fixtureuser/secret-lab inside surgery body padded long" > "$WORK/leaky-body.md" +bash "$SCRIPT" edit-comment 9 --repo o/r --body-file "$WORK/leaky-body.md" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit-comment privacy net applies (exit 10)" 10 $? + +bash "$SCRIPT" edit-comment 9 --repo o/r --body-file "$WORK/empty-body.md" "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit-comment empty body → overwrite floor (exit 15)" 15 $? + +bash "$SCRIPT" edit-comment 9 --repo o/r --body-file "$WORK/patch-body.md" >/dev/null 2>&1 +assert_exit "edit-comment without attestation → refuse (exit 13)" 13 $? + +bash "$SCRIPT" edit-comment 9 --repo o/r "${ATT[@]}" >/dev/null 2>&1 +assert_exit "edit-comment without --body-file → usage (exit 14)" 14 $? + print_summary "gh-egress" diff --git a/plugins/issue-driven-dev/scripts/tests/idd-edit/test.sh b/plugins/issue-driven-dev/scripts/tests/idd-edit/test.sh index 8c4f341..b8ee1dd 100755 --- a/plugins/issue-driven-dev/scripts/tests/idd-edit/test.sh +++ b/plugins/issue-driven-dev/scripts/tests/idd-edit/test.sh @@ -164,13 +164,21 @@ f14() { # name mode(assert|refute) needle } f14 "f14a: R5 refuse records + continues (no batch kill)" assert 'REFUSED_TARGETS+=("$COMMENT_ID")' f14 "f14a2: refuse branch continues the loop" assert '不中斷 batch(#158 語意 (i))' -f14 "f14b: batch outcome report format present" assert 'edited: $EDITED_COUNT / refused: ${#REFUSED_TARGETS[@]}' -f14 "f14b2: refused rows highlighted" assert '✗ REFUSED comment:' +f14 "f14b: batch outcome report sums both refusal buckets" assert 'edited: $EDITED_COUNT / refused: $REFUSED_TOTAL' +f14 "f14b2: R5 refused rows highlighted with source tag" assert '✗ REFUSED(R5) comment:' f14 "f14c: override semantics = all-targets + shared reason" assert 'all-targets + reason 共用' f14 "f14d: deferred-to-158 note removed (decision landed)" refute '設計 deferred to **[#158]' f14 "f14e: single-target degenerate equivalence documented" assert '單 target 退化等價' f14 "f14f: exit contract — 4 iff any refused" assert 'M>0 → exit 4' +# ── fixture 15 (#273): egress refusal band enters the batch outcome ── +f14 "f15a: egress refusal bucket exists" assert 'EGRESS_REFUSED_TARGETS+=' +f14 "f15b: rc>=10 classified as refusal (band consumed)" assert '-ge 10' +f14 "f15c: egress refused rows highlighted with source tag" assert '✗ REFUSED(egress)' +f14 "f15d: Step 6 dispatches via the edit-comment verb" assert 'gh-egress.sh" edit-comment' +f14 "f15e: band-consumption doctrine sentence present" assert 'per-comment 分類消費 band(10–15)' +f14 "f15f: final exit covers both buckets" assert '[ "$REFUSED_TOTAL" -gt 0 ] && exit 4' + echo "" echo "================================" echo "Results: $PASS passed, $FAIL failed" diff --git a/plugins/issue-driven-dev/skills/idd-edit/SKILL.md b/plugins/issue-driven-dev/skills/idd-edit/SKILL.md index ee1624f..f110e7a 100644 --- a/plugins/issue-driven-dev/skills/idd-edit/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-edit/SKILL.md @@ -110,7 +110,7 @@ TaskCreate(name="fetch_body_and_backup", description="gh api 取現 body 並寫 TaskCreate(name="show_original", description="顯示原 comment 前 30 行讓使用者看清楚要動什麼") TaskCreate(name="build_new_body", description="按 mode(append / replace / prepend-note)組新 body 字串") TaskCreate(name="preview_and_confirm", description="顯示新 body 並用 AskUserQuestion 確認;--replace 模式必須通過") -TaskCreate(name="execute_patch", description="gh api PATCH /repos/.../issues/comments/ 用 -F body=@file 避免 escape") +TaskCreate(name="execute_patch", description="經 gh-egress.sh edit-comment 派送 PATCH(#273 — 全網套用;rc>=10 → EGRESS_REFUSED 桶 warn-continue);-F body=@file 避免 escape") TaskCreate(name="verify_and_report", description="re-fetch comment 比對寫入結果,輸出 ✓ Edit applied + diff summary") ``` @@ -185,6 +185,7 @@ done ```bash EDITED_COUNT=0 # incremented after each successful per-target Step 7 verify REFUSED_TARGETS=() # R5-refused comment ids (#158 semantics (i) — recorded, not fatal) +EGRESS_REFUSED_TARGETS=() # egress-band refusals rc>=10(#273 — tier-floor/privacy/mention/empty-body),warn-continue for COMMENT_ID in "${RESOLVED_COMMENT_IDS[@]}"; do # === Steps 1.5 through 7 run here, per-target === # The bash blocks below show single-target templates; in batch mode @@ -199,14 +200,18 @@ done ```bash echo "── idd-edit batch outcome ──" -echo "edited: $EDITED_COUNT / refused: ${#REFUSED_TARGETS[@]}" +REFUSED_TOTAL=$(( ${#REFUSED_TARGETS[@]} + ${#EGRESS_REFUSED_TARGETS[@]} )) +echo "edited: $EDITED_COUNT / refused: $REFUSED_TOTAL" for rid in ${REFUSED_TARGETS[@]+"${REFUSED_TARGETS[@]}"}; do - echo " ✗ REFUSED comment:$rid — non-OWNER(override: --override-user-content --reason='...')" + echo " ✗ REFUSED(R5) comment:$rid — non-OWNER(override: --override-user-content --reason='...')" done -# Exit contract(fixture 14):M=0 → exit 0;M>0 → exit 4。 -# 單 target refuse 走同一路徑 = 現行可觀察行為不變。idd-edit 沿用自身碼空間 -# (R5 refuse=4)— 不套 #227 的 gh-egress 碼帶(那是 egress wrapper 專屬)。 -[ "${#REFUSED_TARGETS[@]}" -gt 0 ] && exit 4 # M>0 → exit 4 +for rid in ${EGRESS_REFUSED_TARGETS[@]+"${EGRESS_REFUSED_TARGETS[@]}"}; do + echo " ✗ REFUSED(egress) $rid — band 語意見 #227(10 privacy / 11 mention / 13 tier-floor #272 / 15 empty-body #275)" +done +# Exit contract(fixture 14/15;M = REFUSED_TOTAL;M>0 → exit 4,單 target 退化等價): +# final exit 仍不套 #227 碼帶 — batch 的聚合碼是 idd-edit 自身空間(任一 refusal = 4); +# per-comment 分類消費 band(10–15)— 這正是 band 存在的目的(#273)。 +[ "$REFUSED_TOTAL" -gt 0 ] && exit 4 exit 0 ``` ``` @@ -354,17 +359,31 @@ Confirm edit? (y/n) ### Step 6: Execute PATCH +> **Scrub level**:`$SCRUB_LEVEL` 依 [`rules/privacy-scrubbing.md`](../../rules/privacy-scrubbing.md) 解析(repo visibility → tier;#226 rollout 慣例),互動情境預設 `warn`。 + **關鍵**:用 `-F body=@file`(不是 `-f body=""`)避免 backtick / 多行字串的 escape bug。 ```bash TMP_BODY_FILE="/tmp/idd-edit-new-${COMMENT_ID}.md" echo "$NEW_BODY" > "$TMP_BODY_FILE" -gh api repos/$REPO/issues/comments/$COMMENT_ID \ - -X PATCH \ - -F body=@"$TMP_BODY_FILE" - +# #273:comment surgery 進 egress 網 — 原 raw `gh api` PATCH 完全繞過所有網 +# (#226 rollout 的 tracked-separately 欠帳,於此結清)。 +SCRUB_LEVEL="${SCRUB_LEVEL:-warn}" # 依 rules/privacy-scrubbing.md 解析(repo tier;互動預設 warn,#226 慣例) +bash "$CLAUDE_PLUGIN_ROOT/scripts/gh-egress.sh" edit-comment "$COMMENT_ID" \ + --repo "$REPO" --body-file "$TMP_BODY_FILE" --scrub-attested "$SCRUB_LEVEL" +EGRESS_RC=$? rm "$TMP_BODY_FILE" +if [ "$EGRESS_RC" -ge 10 ]; then + # wrapper refusal band(#227)— per-comment 分類消費 band(10–15): + # 記錄 + warn-continue(#158 語意 (i) 同款)。wrapper 已對本 target 印明確 stderr。 + EGRESS_REFUSED_TARGETS+=("comment:$COMMENT_ID rc=$EGRESS_RC") + continue +elif [ "$EGRESS_RC" -ne 0 ]; then + # 0 < rc < 10 = gh 自身錯誤(auth / network),非 refusal — 誠實分流,不進 refused 桶 + echo "✗ gh api PATCH failed for comment:$COMMENT_ID (rc=$EGRESS_RC, gh 自身碼) — skip this target" >&2 + continue +fi ``` ### Step 7: Verify edit + report @@ -466,7 +485,9 @@ Target comment 頂部加警示「⚠️ See errata below」 ls -la /tmp/idd-edit-backup/ # 回復某次 edit (set REPO=owner/repo first) -gh api repos/$REPO/issues/comments/ \ +# (災難復原配方**刻意**走 raw gh api、不經 egress 網 — 還原的是先前已公開發佈 +# 的內容,wrapper refusal 不應阻擋緊急回復;#273 接線範圍是 Step 6 正常路徑) +gh api "repos/$REPO/issues/comments/" \ -X PATCH \ -F body=@/tmp/idd-edit-backup/comment--.md ```