Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions plugins/issue-driven-dev/scripts/gh-egress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
#
# USAGE
# gh-egress.sh <create|comment|edit> [gh issue <verb> args...] \
# gh-egress.sh edit-comment <comment-id> --repo owner/repo --body-file <f> \ # #273: comment-PATCH surgery, all nets apply
# --scrub-attested <enforce|warn|light>
#
# On success the wrapper `exec`s the real gh so stdout / stderr / exit code are
Expand All @@ -73,6 +74,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)
Expand All @@ -81,15 +86,15 @@
set -u

usage() {
echo "✗ gh-egress: usage: gh-egress.sh <create|comment|edit> [gh args...] --scrub-attested <enforce|warn|light>" >&2
echo "✗ gh-egress: usage: gh-egress.sh <create|comment|edit|edit-comment> [gh args...] --scrub-attested <enforce|warn|light>" >&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 -------
Expand All @@ -106,6 +111,10 @@ 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
Expand Down Expand Up @@ -144,6 +153,18 @@ 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 ;;
--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
Expand All @@ -159,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
Expand All @@ -193,6 +220,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" ] || [ "$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
# 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" ] || [ "$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
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
Expand Down Expand Up @@ -366,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 <comment-id> --repo owner/repo --body-file <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[@]}"}
12 changes: 8 additions & 4 deletions plugins/issue-driven-dev/scripts/tests/gh-egress-rollout/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand All @@ -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
Expand Down
60 changes: 60 additions & 0 deletions plugins/issue-driven-dev/scripts/tests/gh-egress/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,64 @@ 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"

# ── §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"
12 changes: 10 additions & 2 deletions plugins/issue-driven-dev/scripts/tests/idd-edit/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading