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
2 changes: 1 addition & 1 deletion plugins/issue-driven-dev/rules/tagging-collaborators.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ User picks from the **actual list**. The "Other" free-text option is fine for ge
- **Unconditional scan(v2.92.0+, #117)**:這個 pre-post 掃描**不是** intent-gated — 即使沒有 `--mention` flag、沒有 tagging 意圖,AI-generated body 中**附帶**的 `@xxx` token(內部 codename 如 `@codex`、引用對話原文、動態值如 `@assignee`)一樣會通知同名真實 user。每個 raw token 二擇一:
1. **不是 mention** → backtick-escape 成 `` `@xxx` ``(inline code 對 GitHub notification 惰性)或放進 code fence
2. **是 mention** → 走完 5-step 協定後,經 `scripts/gh-egress.sh` 派送時帶 `--mention-attested <login1,login2>`(涵蓋**每一個**意圖 mention;部分涵蓋一樣 refuse)
- **機械 backstop**:`scripts/gh-egress.sh` 的 mention net(#117)會在派送前剝除 fence/inline-code 後掃 `@login` token,未被 `--mention-attested` 涵蓋者 refuse(exit 4)。此網對 email-like `user@host` 不誤觸(prefix guard)。未接線 gh-egress 的 skill 依本 rule 自律(prose 層),機械覆蓋隨 gh-egress rollout 生效。
- **機械 backstop**:`scripts/gh-egress.sh` 的 mention net(#117)會在派送前剝除 fence/inline-code 後掃 `@login` token,未被 `--mention-attested` 涵蓋者 refuse(exit 11 — #227 refusal 碼帶 ≥10,<10 為 gh 原生碼透傳)。此網對 email-like `user@host` 不誤觸(prefix guard)。未接線 gh-egress 的 skill 依本 rule 自律(prose 層),機械覆蓋隨 gh-egress rollout 生效。

```bash
# Verification step
Expand Down
95 changes: 69 additions & 26 deletions plugins/issue-driven-dev/scripts/gh-egress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@
# byte-for-byte the same as calling `gh issue <verb> ...` directly (backward
# compat: callers that capture `URL=$(... )` are unaffected).
#
# EXIT CODES
# 0 dispatched (exec gh) 2 usage error (bad/missing verb / malformed args)
# 5 unscannable --body-file (not a readable regular file, #203 item 3)
# 3 attestation missing/invalid 4 mechanical net hit (refused)
# EXIT CODES — wrapper-origin codes live in a dedicated refusal band >=10 (#227).
# INVARIANT: this wrapper never exits with its own code below 10; any exit <10
# observed by a caller is gh's OWN code flowing through the final `exec` (so an
# unattended caller can mechanically split "gate refusal -> fix content/args"
# from "gh failure -> fix auth/network" on $? alone).
# 0 dispatched (exec gh -- gh's exit codes flow through from here, all <10)
# 10 privacy net hit (absolute /Users/<name> path / verbatim ~/.claude.json content)
# 11 mention net hit (unattested @login token / entity-encoded @ form)
# 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)
#
# TEST OVERRIDES (test-only; never set in production)
# IDD_GH_BIN gh binary to exec (default: gh)
Expand All @@ -76,8 +83,8 @@ usage() {
VERB="${1:-}"
case "$VERB" in
create|comment|edit) shift ;;
"") echo "✗ gh-egress: missing egress verb." >&2; usage; exit 2 ;;
*) echo "✗ gh-egress: unknown egress verb '$VERB' (only create|comment|edit route through this gate)." >&2; usage; exit 2 ;;
"") 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 ;;
esac

# --- parse: pull out --scrub-attested, forward everything else verbatim -------
Expand All @@ -89,7 +96,7 @@ require_scannable_bodyfile() {
echo "✗ gh-egress: REFUSED — --body-file '$1' is not a readable regular file." >&2
echo " stdin ('-'), FIFOs and process substitutions cannot be scanned without consuming the stream." >&2
echo " Write the body to a regular file first, then re-dispatch." >&2
exit 5
exit 12
fi
}

Expand All @@ -107,11 +114,11 @@ while [ $# -gt 0 ]; do
# Same malformed-shape guard as --scrub-attested (#203 item 6 / #117).
if [ -n "$next_is" ]; then
echo "✗ gh-egress: malformed args — '--mention-attested' found where a value for --body/--title/--body-file was expected." >&2
exit 2
exit 14
fi
case "$arg" in
--mention-attested)
[ $# -ge 2 ] || { echo "✗ gh-egress: --mention-attested needs a value." >&2; exit 3; }
[ $# -ge 2 ] || { echo "✗ gh-egress: --mention-attested needs a value." >&2; exit 14; }
MENTION_ATTESTED="$2"; shift 2; continue ;;
*)
MENTION_ATTESTED="${arg#--mention-attested=}"; shift; continue ;;
Expand All @@ -123,11 +130,11 @@ while [ $# -gt 0 ]; do
# prose would silently escape the scan. Refuse as a usage error.
if [ -n "$next_is" ]; then
echo "✗ gh-egress: malformed args — '--scrub-attested' found where a value for --body/--title/--body-file was expected (split-token attestation)." >&2
exit 2
exit 14
fi
case "$arg" in
--scrub-attested)
[ $# -ge 2 ] || { echo "✗ gh-egress: --scrub-attested needs a value." >&2; exit 3; }
[ $# -ge 2 ] || { echo "✗ gh-egress: --scrub-attested needs a value." >&2; exit 14; }
ATTESTED="$2"; shift 2; continue ;;
*)
ATTESTED="${arg#--scrub-attested=}"; shift; continue ;;
Expand Down Expand Up @@ -169,9 +176,9 @@ case "$ATTESTED" in
"") echo "✗ gh-egress: REFUSED — privacy self-review attestation missing." >&2
echo " Run the privacy-scrubbing self-review (rules/privacy-scrubbing.md), then pass" >&2
echo " --scrub-attested <enforce|warn|light> (the resolved repo-visibility strictness)." >&2
exit 3 ;;
exit 13 ;;
*) echo "✗ gh-egress: REFUSED — invalid attestation level '$ATTESTED' (expected enforce|warn|light)." >&2
exit 3 ;;
exit 13 ;;
esac

# --- (b) mechanical last-resort net (3 zero-tolerance mechanical items) -------
Expand All @@ -185,7 +192,7 @@ for p in "${SCAN_PARTS[@]:-}"; do SCAN+="$p"$'\n'; done
net_refuse() { echo "✗ gh-egress: REFUSED — mechanical net caught $1." >&2
echo " Zero-tolerance literal leak (belt-and-suspenders backstop; the LLM self-review normally catches this)." >&2
echo " Redact it (e.g. /Users/<name> → ~, drop the ~/.claude.json excerpt), then re-dispatch." >&2
exit 4; }
exit 10; }

# 1. absolute macOS home path /Users/<name> — require a real name char right
# after the slash so the /Users/<name> placeholder (angle bracket) and a bare
Expand All @@ -200,26 +207,62 @@ fi
# this gate itself, e.g. #202/#203).
# A project-path string copied verbatim out of the user's actual
# ~/.claude.json `projects` object (the "project basename leaks local folder
# structure" threat). When jq is available the extraction is scoped to the
# projects object so PUBLIC tool paths (mcpServers[].command etc.) are not
# false-flagged (#203 item 2); without jq, fall back to the original
# whole-file wide net (fail-closed: over-refusing beats leaking).
# structure" threat), or a path-shaped value under a sensitive key name
# (mcpServers[].env secret files etc. — #225 taxonomy). Extraction is ONE
# python3 parser (#225) so every machine scans the same set; PUBLIC tool
# paths (mcpServers[].command etc.) are not false-flagged (#203 item 2).
# ${HOME:-} guard: both IDD_CLAUDE_JSON and HOME unset must not crash under
# set -u — the probe just skips (#203 item 4).
# #225 unified scan: ONE python3 parser replaces the old jq/no-jq dual path
# whose results diverged per machine (jq: projects-only; no-jq: whole-file
# wide — a secret path under mcpServers[].env dispatched on one machine and
# refused on the other). Taxonomy: projects keys ∪ path-shaped string values
# whose key (or any ancestor key) matches the tight sensitive-name set
# (key|token|secret|credential|password|auth|env). Public tool paths
# (mcpServers[].command / args) stay un-flagged (#203 item 2 preserved).
# python3 absent OR parse failure → fail CLOSED to the bash-only whole-file
# wide net (over-refusing beats leaking, #203 verify sec-2) — the degraded
# path only ever over-matches, never under-matches the unified set.
CJSON="${IDD_CLAUDE_JSON:-${HOME:-}/.claude.json}"
if [ -n "$SCAN" ] && [ -r "$CJSON" ]; then
JQ_OK=0
if command -v jq >/dev/null 2>&1; then
if KEYS_RAW="$(jq -r '(.projects // {}) | keys[]' "$CJSON" 2>/dev/null)"; then
JQ_OK=1
PY_OK=0
if command -v python3 >/dev/null 2>&1; then
if KEYS_RAW="$(python3 - "$CJSON" <<'PYEOF' 2>/dev/null
import json, re, sys
try:
cfg = json.load(open(sys.argv[1]))
if not isinstance(cfg, dict):
raise ValueError("top-level not an object")
except Exception:
sys.exit(3) # parse failure -> bash falls CLOSED to the wide net
SENS = re.compile(r"(?i)(key|token|secret|credential|password|auth|env)")
out = set()
proj = cfg.get("projects")
if isinstance(proj, dict):
out.update(k for k in proj if isinstance(k, str))
def walk(node, sens):
if isinstance(node, dict):
for k, v in node.items():
walk(v, sens or bool(SENS.search(str(k))))
elif isinstance(node, list):
for v in node:
walk(v, sens)
elif isinstance(node, str) and sens:
out.add(node)
walk({k: v for k, v in cfg.items() if k != "projects"}, False)
for s_ in sorted(out):
print(s_)
PYEOF
)"; then
PY_OK=1
KEYS="$(printf '%s\n' "$KEYS_RAW" \
| grep -E '^/.{11,}$' \
| grep -E '/[^/]+/' \
| sort -u)"
fi
fi
if [ "$JQ_OK" -eq 0 ]; then
# jq absent OR jq parse failure (malformed config) — fail CLOSED to the
if [ "$PY_OK" -eq 0 ]; then
# python3 absent OR parse failure (malformed config) — fail CLOSED to the
# whole-file wide net; a silently disabled net would leak (#203 verify sec-2).
KEYS="$(grep -oE '"(/[^"]{11,})"' "$CJSON" 2>/dev/null \
| sed -E 's/^"//; s/"$//' \
Expand Down Expand Up @@ -263,7 +306,7 @@ MSCAN="$(printf '%s' "$MBODY" | awk '/^ ? ? ?```/{infence=!infence; next} !infen
if printf '%s' "$MSCAN" | grep -qiE '&#0*64;([a-z0-9-]|&#)|&#x0*40;([a-z0-9-]|&#)|&commat;([a-z0-9-]|&#)'; then
echo "✗ gh-egress: REFUSED — entity-encoded @-mention (e.g. &#64;login) in body." >&2
echo " Encoded forms can decode into live mentions on GitHub. Spell it as literal text in backticks instead." >&2
exit 4
exit 11
fi
UNATTESTED_MENTIONS=""
while IFS= read -r login; do
Expand All @@ -283,7 +326,7 @@ if [ -n "$UNATTESTED_MENTIONS" ]; then
echo " - escape non-mention tokens in backticks (\`@name\`) — inert on GitHub, or" >&2
echo " - run the rules/tagging-collaborators.md 5-step protocol, then re-dispatch with" >&2
echo " --mention-attested <login1,login2> covering every intended mention." >&2
exit 4
exit 11
fi

# --- dispatch: byte-for-byte identical to raw `gh issue <verb> ...` -----------
Expand Down
64 changes: 64 additions & 0 deletions plugins/issue-driven-dev/scripts/tests/gh-egress-rollout/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# test.sh — drift-guard for the #226 gh-egress wider rollout (Phase 2 of #202).
#
# Phase 1 wired only idd-issue; every other skill's comment/edit egress went
# raw `gh issue comment|edit` — no attestation gate, no privacy/mention nets,
# leaving #117's headline scenario (comment @mention) mechanically unenforced.
# This suite locks the Phase-2 wiring: each rolled-out SKILL routes its
# executable comment/edit call sites through scripts/gh-egress.sh with a
# --scrub-attested level, and the exact pre-rollout raw call lines are GONE
# (refuted verbatim, so a revert or a copy-paste regression turns RED).
#
# 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
# - prose/table MENTIONS of `gh issue comment` (rules text, rationale) — only
# executable call lines were wired
#
# Usage: bash test.sh (exit 0 = all pass, 1 = any fail)

set -u

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT="$HERE/../../.."
SK="$PLUGIN_ROOT/skills"

HELPERS="$HERE/../../lib/assert-helpers.sh"
[ -f "$HELPERS" ] || { echo "✗ missing $HELPERS — cannot run suite" >&2; exit 1; }
. "$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
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

# ── the exact pre-rollout raw call lines are gone (verbatim refutes) ──
refute_output_grep "idd-comment: raw body-file comment gone" \
'gh issue comment $NUMBER --repo $GITHUB_REPO --body-file /tmp/idd-comment-$$.md' \
"$SK/idd-comment/SKILL.md"
refute_output_grep "idd-diagnose: raw diagnosis-report comment gone" \
'gh issue comment $NUMBER --repo $GITHUB_REPO --body "$DIAGNOSIS_REPORT"' \
"$SK/idd-diagnose/SKILL.md"
refute_output_grep "idd-implement: raw implementation-plan comment gone" \
'gh issue comment $NUMBER --repo $GITHUB_REPO --body "$IMPLEMENTATION_PLAN"' \
"$SK/idd-implement/SKILL.md"
refute_output_grep "idd-verify: raw merged-findings comment gone" \
'gh issue comment $NUMBER --repo $GITHUB_REPO --body "$MERGED_FINDINGS"' \
"$SK/idd-verify/SKILL.md"
refute_output_grep "idd-close: raw closing-comment capture gone" \
'CLOSING_COMMENT_URL=$(gh issue comment $NUMBER --repo $GITHUB_REPO --body "$CLOSING_COMMENT")' \
"$SK/idd-close/SKILL.md"
refute_output_grep "idd-update: raw body edit gone" \
'gh issue edit $NUMBER --repo $GITHUB_REPO --body "$UPDATED_BODY"' \
"$SK/idd-update/SKILL.md"

# ── level resolution is cited, not re-invented per skill ──
for sk in idd-comment idd-diagnose idd-implement idd-verify idd-close idd-update; do
assert_output_grep "$sk: cites privacy-scrubbing rule for \$SCRUB_LEVEL" "privacy-scrubbing" "$SK/$sk/SKILL.md"
done

print_summary "gh-egress-rollout (#226)"
Loading
Loading