Skip to content
Closed
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
182 changes: 182 additions & 0 deletions .github/scripts/stop-agent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# stop-agent.sh — Apply fullsend-no-* labels for /fs-stop and /fs-fix-stop.
# Invoked by the shim stop-agent job. Requires env:
# GH_TOKEN, REPO, ISSUE_NUMBER, COMMENT_USER_LOGIN, ISSUE_USER_LOGIN,
# COMMENT_BODY, ISSUE_IS_PR ("true"|"false")
set -euo pipefail

post_comment() {
local body_file="$1"
if [[ "${ISSUE_IS_PR}" == "true" ]]; then
gh pr comment "${ISSUE_NUMBER}" --repo "${REPO}" --body-file "${body_file}"
else
gh issue comment "${ISSUE_NUMBER}" --repo "${REPO}" --body-file "${body_file}"
fi
}

make_body_file() {
local f
if ! f="$(mktemp)"; then
echo "::warning::Failed to create temp file for stop-agent comment body"
return 1
fi
printf '%s' "$f"
}

# Trim leading blank lines and leading whitespace on the first non-blank line
# so copy/paste / markdown-quoted comments still work (matches awk tokenization
# used by other slash commands in dispatch).
FIRST="$(printf '%s\n' "${COMMENT_BODY}" | sed '/^[[:space:]]*$/d' | head -1 | sed 's/^[[:space:]]*//' | tr -d '\r')"
CMD="$(printf '%s\n' "${FIRST}" | awk '{print $1}')"
ARG="$(printf '%s\n' "${FIRST}" | awk '{print $2}')"
# Sanitize for workflow-command interpolation (defense in depth).
SAFE_CMD="${CMD//::/_}"
SAFE_USER="${COMMENT_USER_LOGIN//::/_}"

# Agents with auto-trigger paths gated by fullsend-no-* in dispatch.
# prioritize is slash-only and has no auto-trigger to suppress.
# Bare /fs-stop only applies labels meaningful for this item type.
VALID_ALL="triage code review fix retro"
VALID_ISSUE="triage code"
VALID_PR="review fix retro"
if [[ "${ISSUE_IS_PR}" == "true" ]]; then
VALID_BARE="${VALID_PR}"
else
VALID_BARE="${VALID_ISSUE}"
fi
VALID="${VALID_ALL}"
AGENTS=()
CROSS_CONTEXT=false
if [[ "${CMD}" == "/fs-fix-stop" ]]; then
AGENTS=(fix)
if [[ "${ISSUE_IS_PR}" != "true" ]]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] user-experience-complexity

Cross-context label application: script allows /fs-stop fix on issues (where fix doesn't auto-trigger) and warns 'has no effect on this item type.' Label pollution is possible but the warning is clearly surfaced.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — no code change. Cross-context labels are allowed with an explicit user-facing note that the label has no effect on this item type and does not carry over to a linked issue/PR.

CROSS_CONTEXT=true
fi
elif [[ "${CMD}" == "/fs-stop" ]]; then
if [[ -z "${ARG:-}" ]]; then
# shellcheck disable=SC2206
AGENTS=(${VALID_BARE})
elif [[ "${ARG}" =~ ^[a-z]+$ ]] && [[ " ${VALID} " == *" ${ARG} "* ]]; then
AGENTS=("${ARG}")
if [[ "${ISSUE_IS_PR}" == "true" ]]; then
if [[ " ${VALID_ISSUE} " == *" ${ARG} "* ]] && [[ " ${VALID_PR} " != *" ${ARG} "* ]]; then
CROSS_CONTEXT=true
fi
else
if [[ " ${VALID_PR} " == *" ${ARG} "* ]] && [[ " ${VALID_ISSUE} " != *" ${ARG} "* ]]; then
CROSS_CONTEXT=true
fi
fi
else
AGENTS=()
UNKNOWN_AGENT=true
fi
else
echo "::notice::Ignoring unrecognized stop command: ${SAFE_CMD}"
exit 0
fi

# ADR 0054: authorize via the collaborator permission API
# (admin|maintain|write), not author_association — the latter grants
# contributor status to anyone with a single merged PR (issue #5421).
# Mirrors has_repo_permission() in dispatch.yml; keep the two in sync.
#
# Author escape hatch is intentionally limited to stopping *fix* only
Comment thread
ascerra marked this conversation as resolved.
# (historical /fs-fix-stop behavior). Stopping review/triage/code/retro —
# including bare /fs-stop — requires write-level permission so PR authors
# cannot unilaterally suppress security-relevant auto-gates.
authorized=false
is_author=false
if [[ -n "${COMMENT_USER_LOGIN}" && "${COMMENT_USER_LOGIN}" == "${ISSUE_USER_LOGIN}" ]]; then
is_author=true
fi
author_fix_only=false
if [[ "${is_author}" == "true" && "${UNKNOWN_AGENT:-}" != "true" && "${#AGENTS[@]}" -eq 1 && "${AGENTS[0]}" == "fix" ]]; then
author_fix_only=true
authorized=true
fi
if [[ "${authorized}" != "true" ]]; then
if api_err=$(mktemp); then
if role=$(gh api "repos/${REPO}/collaborators/${COMMENT_USER_LOGIN}/permission" \
--jq '.role_name' 2>"${api_err}"); then
case "${role}" in
admin|maintain|write) authorized=true ;;
esac
else
api_err_safe="$(tr -d '\r' <"${api_err}" | tr '\n' ' ')"
api_err_safe="${api_err_safe//::/_}"
echo "::warning::Permission API call failed for ${SAFE_USER}: ${api_err_safe}"
fi
rm -f "${api_err}"
else
echo "::warning::Failed to create temp file for permission check of ${SAFE_USER}"
fi
fi
if [[ "${authorized}" != "true" ]]; then
if [[ "${is_author}" == "true" && "${author_fix_only}" != "true" ]]; then
echo "::notice::User ${SAFE_USER} is not authorized to stop these agents (PR/issue authors may only /fs-stop fix or /fs-fix-stop; write access required otherwise)"
else
echo "::notice::User ${SAFE_USER} is not authorized to stop agents (requires write access, or authorship for /fs-stop fix only)"
fi
exit 0
fi

if [[ "${UNKNOWN_AGENT:-}" == "true" ]]; then
BODY_FILE="$(make_body_file)" || exit 0
{
printf 'Unknown or unsupported agent.'
printf ' Valid auto-stop targets: %s.' "${VALID}"
printf ' Usage: `/fs-stop <agent>` or `/fs-stop` for all meaningful on this item.'
printf ' Note: prioritize is slash-only (`/fs-prioritize`); there is no auto-trigger to stop.'
} >"${BODY_FILE}"
post_comment "${BODY_FILE}" || true
rm -f "${BODY_FILE}"
exit 0
fi

APPLIED=()
for agent in "${AGENTS[@]}"; do
label="fullsend-no-${agent}"
gh label create "${label}" --repo "${REPO}" \
--description "Skip auto-triggered ${agent} agent runs" --color "FBCA04" \
--force 2>/dev/null || true
if [[ "${ISSUE_IS_PR}" == "true" ]]; then
if gh pr edit "${ISSUE_NUMBER}" --repo "${REPO}" --add-label "${label}"; then
APPLIED+=("\`${label}\`")
else
echo "::warning::Failed to apply label ${label}"
fi
else
if gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --add-label "${label}"; then
APPLIED+=("\`${label}\`")
else
echo "::warning::Failed to apply label ${label}"
fi
fi
done

BODY_FILE="$(make_body_file)" || exit 0
if [[ "${#APPLIED[@]}" -eq 0 ]]; then
printf 'Agent stop requested for #%s, but no labels were applied (label API calls failed — see workflow run logs).\n' \
"${ISSUE_NUMBER}" >"${BODY_FILE}"
else
LIST="$(printf '%s, ' "${APPLIED[@]}")"
LIST="${LIST%, }"
{
printf 'Agent stop applied for #%s: %s.\n' "${ISSUE_NUMBER}" "${LIST}"
printf 'Auto-triggers for these agents are skipped while the label(s) remain.\n'
printf 'On-demand `/fs-<agent>` commands still work.\n'
printf 'In-flight runs are not cancelled by this command — remove the label(s) or re-run `/fs-<agent>` to continue.\n'
if [[ "${CROSS_CONTEXT}" == "true" ]]; then
printf 'Note: this label has no effect on this item type'
if [[ "${ISSUE_IS_PR}" == "true" ]]; then
printf ' (auto-triggers for that agent run on issues, not PRs)'
else
printf ' (auto-triggers for that agent run on PRs, not issues)'
fi
printf ', and it does not carry over to a linked issue or PR.\n'
fi
} >"${BODY_FILE}"
fi
post_comment "${BODY_FILE}" || true
rm -f "${BODY_FILE}"
78 changes: 32 additions & 46 deletions .github/workflows/fullsend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ jobs:
|| github.event.pull_request.head.ref != 'fullsend/scaffold-install')
&& (github.event_name != 'issue_comment'
|| github.event.comment.user.type != 'Bot')
&& (
github.event_name != 'issue_comment'
|| (
!contains(github.event.comment.body, '/fs-stop')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

The dispatch job's if: condition uses !contains(github.event.comment.body, '/fs-stop') to skip dispatch. Substring matching means a multi-line comment with /fs-review on line 1 and /fs-stop on a later line would suppress dispatch for the entire comment. Intentionally coarse per YAML comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — no code change. The dispatch contains('/fs-stop') skip is intentionally coarse YAML gating; exact command parsing stays in stop-agent.sh. Multi-command comments that also mention /fs-stop defer to the stop-agent job by design.

Comment thread
ascerra marked this conversation as resolved.
&& !contains(github.event.comment.body, '/fs-fix-stop')
)
)
&& (
github.event.action != 'labeled'
|| startsWith(github.event.label.name, 'ready-')
Expand All @@ -61,63 +68,42 @@ jobs:
with:
event_action: ${{ github.event.action }}

stop-fix:
# Job-level if: is intentionally coarse — it only screens for the
# /fs-fix-stop command on a PR from a non-bot. The authoritative
# authorization decision (collaborator permission API + PR-author escape
# hatch) is made in the step below, so a maintainer whose author_association
# is not MEMBER (e.g. private org membership) is not filtered out (ADR 0054).
stop-agent:
# Generalized /fs-stop <agent> (and /fs-fix-stop alias). Job-level if: is
# intentionally coarse — authorization uses the collaborator permission API
# + issue/PR-author escape hatch for /fs-stop fix only (ADR 0054 / #5421).
# Broad contains: exact token matching (incl. leading-whitespace trim) is in
# .github/scripts/stop-agent.sh so /fs-stopper is rejected there, not here.
if: >-
Comment thread
ascerra marked this conversation as resolved.
github.event_name == 'issue_comment'
&& github.event.issue.pull_request
&& github.event.comment.user.type != 'Bot'
&& github.event.comment.body == '/fs-fix-stop'
&& (
contains(github.event.comment.body, '/fs-stop')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] edge-case

The stop-agent job-level if: uses contains() which matches any comment containing /fs-stop anywhere. Intentionally coarse with exact matching deferred to the script. The job runs but takes no action on false positives.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — no code change. Job-level contains() is intentionally coarse; false positives reach the script, which exact-matches /fs-stop / /fs-fix-stop and exits as a no-op when unmatched.

|| contains(github.event.comment.body, '/fs-fix-stop')
)
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Add fullsend-no-fix label and notify
- name: Checkout stop-agent script
Comment thread
ascerra marked this conversation as resolved.
# Pin default branch explicitly (ADR 0054): issue_comment must not take
# stop-agent logic from an attacker-controlled ref.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
ascerra marked this conversation as resolved.
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
sparse-checkout: |
.github/scripts/stop-agent.sh
sparse-checkout-cone-mode: false
- name: Apply fullsend-no-* labels and notify
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_USER_LOGIN: ${{ github.event.comment.user.login }}
ISSUE_USER_LOGIN: ${{ github.event.issue.user.login }}
run: |
set -euo pipefail
# ADR 0054: authorize via the collaborator permission API
# (admin|maintain|write), not author_association — the latter grants
# contributor status to anyone with a single merged PR (issue #5421).
# Mirrors has_repo_permission() in dispatch.yml; keep the two in sync.
# The PR author may always stop the fix agent on their own PR.
authorized=false
if [[ -n "$COMMENT_USER_LOGIN" && "$COMMENT_USER_LOGIN" == "$ISSUE_USER_LOGIN" ]]; then
authorized=true
else
if api_err=$(mktemp); then
if role=$(gh api "repos/$REPO/collaborators/$COMMENT_USER_LOGIN/permission" \
--jq '.role_name' 2>"$api_err"); then
case "$role" in
admin|maintain|write) authorized=true ;;
esac
else
echo "::warning::Permission API call failed for $COMMENT_USER_LOGIN: $(cat "$api_err")"
fi
rm -f "$api_err"
else
echo "::warning::Failed to create temp file for permission check of $COMMENT_USER_LOGIN"
fi
fi
if [[ "$authorized" != "true" ]]; then
echo "::notice::User $COMMENT_USER_LOGIN is not authorized to stop the fix agent (requires write access or PR authorship)"
exit 0
fi
gh label create "fullsend-no-fix" --repo "$REPO" \
--description "Skip bot-triggered fix agent runs" --color "FBCA04" \
--force 2>/dev/null || true
gh pr edit "$PR_NUMBER" --repo "$REPO" \
--add-label "fullsend-no-fix"
gh pr comment "$PR_NUMBER" --repo "$REPO" \
--body "Fix agent disabled for this PR. Remove the \`fullsend-no-fix\` label or use \`/fs-fix\` to re-engage."
COMMENT_BODY: ${{ github.event.comment.body }}
ISSUE_IS_PR: ${{ github.event.issue.pull_request && 'true' || 'false' }}
run: bash .github/scripts/stop-agent.sh
41 changes: 21 additions & 20 deletions .github/workflows/reusable-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ jobs:
return 1
}

# Extract the first word of the comment as the command
COMMAND=""
if [[ -n "${COMMENT_BODY:-}" ]]; then
COMMAND="$(printf '%s\n' "${COMMENT_BODY}" | head -1 | tr -d '\r' | awk '{print $1}')"
Expand Down Expand Up @@ -229,11 +230,12 @@ jobs:
STAGE="prioritize"
fi
;;
/fs-stop|/fs-fix-stop) ;; # shim stop-agent job — do not route
*)
# Intentionally weaker gate: allows external reporters to
# re-trigger triage by providing clarification on needs-info
# issues. Full write-permission check is not required here.
if has_label "needs-info" && ! has_label "feature"; then
if has_label "needs-info" && ! has_label "feature" && ! has_label "fullsend-no-triage"; then
if [[ "${COMMENT_USER_TYPE}" != "Bot" ]]; then
if [[ "${COMMENT_AUTHOR_ASSOC}" != "NONE" ]] || is_issue_author; then
STAGE="triage"
Expand All @@ -246,23 +248,23 @@ jobs:

issues)
if [[ "${EVENT_ACTION}" == "opened" ]]; then
if is_event_actor_authorized "${ISSUE_USER_LOGIN}" triage; then
if is_event_actor_authorized "${ISSUE_USER_LOGIN}" triage && ! has_label "fullsend-no-triage"; then
STAGE="triage"
fi
elif [[ "${EVENT_ACTION}" == "edited" ]]; then
if is_event_actor_authorized "${EVENT_SENDER_LOGIN}" triage; then
if is_event_actor_authorized "${EVENT_SENDER_LOGIN}" triage && ! has_label "fullsend-no-triage"; then
STAGE="triage"
fi
elif [[ "${EVENT_ACTION}" == "labeled" ]]; then
if [[ "${TRIGGERING_LABEL}" == "ready-for-triage" ]]; then
if [[ "${TRIGGERING_LABEL}" == "ready-for-triage" ]] && ! has_label "fullsend-no-triage"; then
STAGE="triage"
elif [[ "${TRIGGERING_LABEL}" == "ready-to-code" ]]; then
# Mutation stage: write+ labeler, or bots (agent handoff).
if [[ "${EVENT_SENDER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${EVENT_SENDER_LOGIN}"; then
if { [[ "${EVENT_SENDER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${EVENT_SENDER_LOGIN}"; } && ! has_label "fullsend-no-code"; then
STAGE="code"
fi
elif [[ "${TRIGGERING_LABEL}" == "ready-for-review" ]]; then
if [[ "${ISSUE_IS_PR}" == "true" ]]; then
if [[ "${ISSUE_IS_PR}" == "true" ]] && ! has_label "fullsend-no-review"; then
STAGE="review"
fi
fi
Expand All @@ -272,19 +274,22 @@ jobs:
pull_request_target)
case "${EVENT_ACTION}" in
opened|synchronize|ready_for_review)
if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${PR_USER_LOGIN}" triage; then
# fullsend-no-review skips auto review; /fs-review bypasses.
if { [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${PR_USER_LOGIN}" triage; } && ! has_label "fullsend-no-review" "${PR_LABELS}"; then
STAGE="review"
fi
;;
labeled)
if [[ "${TRIGGERING_LABEL}" == "ready-for-review" ]]; then
if [[ "${TRIGGERING_LABEL}" == "ready-for-review" ]] && ! has_label "fullsend-no-review" "${PR_LABELS}"; then
STAGE="review"
fi
;;
closed)
# Intentional ungated: any closer may trigger read-only retro
# (lifecycle accounting). See ADR 0054 #5223 note.
STAGE="retro"
if ! has_label "fullsend-no-retro" "${PR_LABELS}"; then
STAGE="retro"
fi
;;
esac
;;
Expand All @@ -294,17 +299,13 @@ jobs:
REVIEW_BOT="${ORG_NAME}-review[bot]"
if [[ "${REVIEW_USER_LOGIN}" == "${REVIEW_BOT}" ]]; then
# Human PRs need fullsend-fix + write+ author (not triage-only).
if [[ -n "${PR_HEAD_REPO}" && -n "${PR_BASE_REPO}" ]]; then
if [[ "${PR_HEAD_REPO}" == "${PR_BASE_REPO}" ]]; then
if ! has_label "fullsend-no-fix" "${PR_LABELS}"; then
if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]]; then
STAGE="fix"
TRIGGER_SOURCE="${REVIEW_USER_LOGIN}"
elif has_label "fullsend-fix" "${PR_LABELS}" && has_repo_permission "${PR_USER_LOGIN}" write; then
STAGE="fix"
TRIGGER_SOURCE="${REVIEW_USER_LOGIN}"
fi
fi
if [[ -n "${PR_HEAD_REPO}" && -n "${PR_BASE_REPO}" && "${PR_HEAD_REPO}" == "${PR_BASE_REPO}" ]] && ! has_label "fullsend-no-fix" "${PR_LABELS}"; then
if [[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]]; then
STAGE="fix"
TRIGGER_SOURCE="${REVIEW_USER_LOGIN}"
elif has_label "fullsend-fix" "${PR_LABELS}" && has_repo_permission "${PR_USER_LOGIN}" write; then
STAGE="fix"
TRIGGER_SOURCE="${REVIEW_USER_LOGIN}"
fi
fi
fi
Expand Down
1 change: 1 addition & 0 deletions docs/ADRs/0033-per-repo-installation-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ Ordered by the project's threat priority (external injection > insider > drift >
### `stop-fix` job placement

**Resolved (PR #799):** The `stop-fix` job lives in the target repo's shim workflow (same location as per-org) since it only needs the default `GITHUB_TOKEN` — no mint or reusable workflow involvement.
(Renamed to `stop-agent` when generalized for `/fs-stop`; see [ADR 0086](0086-fs-stop-agent-and-no-agent-labels.md).)

### CLI command design

Expand Down
Loading
Loading