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
46 changes: 32 additions & 14 deletions scripts/lib/issue-comments.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,46 @@ ic_noise_pattern() {
# repository write access (#1800: only author_association OWNER/MEMBER/COLLABORATOR
# is trusted — a drive-by CONTRIBUTOR/NONE commenter must not redirect the
# implementation), dev-lead automation comments (ic_noise_pattern), and empty
# bodies. author_association comes from the GitHub API (not attacker-controlled)
# and is always present on real comments; it is absent only in synthetic test
# input, where it defaults to OWNER so the body-only degradation path is
# unaffected. jq's `-s` slurps every page into one array; `[.[] | .[]?]` flattens
# both the single-array and paginated (array-per-page) shapes, mirroring
# bodies. The trust filter FAILS CLOSED (#1872): a missing, null, or empty
# author_association is untrusted and the comment is dropped — absence of
# evidence of trust is not evidence of trust. Real GitHub API comments always
# carry the field; any caller that synthesizes comment objects must set it
# explicitly at the construction site. Every trust drop is logged to STDERR
# (login + reason, never the body), distinguishing "no author_association" from
# an untrusted association, so an ingestion path that stops populating the field
# surfaces as dropped comments rather than silently-trusted ones. jq's `-s`
# slurps every page into one array; `[.[] | .[]?]` flattens both the
# single-array and paginated (array-per-page) shapes, mirroring
# count_prior_attempts in the driver.
_ic_filter_human() {
jq -s -r --arg noise "$(ic_noise_pattern)" '
[ .[] | .[]? ]
| map(select(type == "object"))
| map(select((.body // "") != ""))
| map(select(((.user.type?) // "") != "Bot"))
| map(select(
(((.author_association?) // "OWNER") | ascii_upcase) as $aa
| ($aa == "OWNER" or $aa == "MEMBER" or $aa == "COLLABORATOR")))
| map(select((.body // "") | test($noise) | not))
| .[]
| { login: (.user.login? // "unknown"),
created_at: (.created_at // ""),
body: (.body // "") }
| @base64
' 2>/dev/null || true
| (.user.login? // "unknown") as $login
| ((.author_association? // "") | tostring | ascii_upcase) as $aa
# Drop records are tab/newline-delimited text, so control characters in the
# logged fields are neutralised — they must never be able to forge a K line.
| ($login | tostring | gsub("[[:cntrl:]]"; "?")) as $safe_login
| if $aa == "" then
"D\t\($safe_login)\tno author_association (treated as untrusted)"
elif ($aa == "OWNER" or $aa == "MEMBER" or $aa == "COLLABORATOR") | not then
"D\t\($safe_login)\tauthor_association=\($aa | gsub("[[:cntrl:]]"; "?")) (no write access)"
elif (.body // "") | test($noise) then
empty
else
"K\t" + ({ login: $login,
created_at: (.created_at // ""),
body: (.body // "") } | @base64)
end
' 2>/dev/null | while IFS=$'\t' read -r tag a b || [ -n "$tag" ]; do
Comment thread
don-petry marked this conversation as resolved.
case "$tag" in
K) printf '%s\n' "$a" ;;
D) printf 'issue-comments: dropped comment by @%s: %s\n' "$a" "$b" >&2 ;;
esac
done || true
}

# render_issue_comments: read the paginated comments JSON on stdin; print the
Expand Down
2 changes: 1 addition & 1 deletion tests/dev-lead/unit/test_fix_issue.bats
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ case "$cmd" in
api)
case "$*" in
*"pulls?state=open"*) echo "0" ;;
*comments*) echo '[{"user":{"login":"alice","type":"User"},"created_at":"2026-08-21T01:14:00Z","body":"ANSWER: the telemetry source is the oauth/usage endpoint."}]' ;;
*comments*) echo '[{"user":{"login":"alice","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T01:14:00Z","body":"ANSWER: the telemetry source is the oauth/usage endpoint."}]' ;;
*"users/"*) echo '{"id":12345}' ;;
*"issues/"*) echo '{"title":"Test","body":"QUESTION: what is the telemetry source?"}' ;;
*) echo "{}" ;;
Expand Down
111 changes: 92 additions & 19 deletions tests/dev-lead/unit/test_issue_comments.bats
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ setup() {

@test "issue-comments: a human comment answering the body's question is surfaced" {
local json='[
{"user":{"login":"alice","type":"User"},"created_at":"2026-08-21T01:14:00Z",
{"user":{"login":"alice","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T01:14:00Z",
"body":"The endpoint is oauth/usage with header anthropic-beta; it returns resets_at (HTTP 200 verified)."}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
Expand All @@ -31,8 +31,8 @@ setup() {

@test "issue-comments: Bot-type authors are filtered out" {
local json='[
{"user":{"login":"github-actions","type":"Bot"},"created_at":"2026-08-21T00:00:00Z","body":"CI passed on abc123"},
{"user":{"login":"carol","type":"User"},"created_at":"2026-08-21T02:00:00Z","body":"Real human steering here"}
{"user":{"login":"github-actions","type":"Bot"},"author_association":"OWNER","created_at":"2026-08-21T00:00:00Z","body":"CI passed on abc123"},
{"user":{"login":"carol","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T02:00:00Z","body":"Real human steering here"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -42,9 +42,9 @@ setup() {

@test "issue-comments: dev-lead HTML-marker status comments are filtered out" {
local json='[
{"user":{"login":"don-petry","type":"User"},"created_at":"2026-08-21T00:00:00Z",
{"user":{"login":"don-petry","type":"User"},"author_association":"OWNER","created_at":"2026-08-21T00:00:00Z",
"body":"<!-- dev-lead-issue 1566 status=failed attempt=1 reason=engine-error run=9 -->\n## Dev-Lead: issue #1566 implementation failed — will retry"},
{"user":{"login":"dave","type":"User"},"created_at":"2026-08-21T03:00:00Z","body":"Please also handle the null case"}
{"user":{"login":"dave","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T03:00:00Z","body":"Please also handle the null case"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -55,9 +55,9 @@ setup() {

@test "issue-comments: dev-lead '## Dev-Lead' plan/progress comments are filtered out" {
local json='[
{"user":{"login":"don-petry","type":"User"},"created_at":"2026-08-21T00:00:00Z",
{"user":{"login":"don-petry","type":"User"},"author_association":"OWNER","created_at":"2026-08-21T00:00:00Z",
"body":"## Dev-Lead Implementation Plan\n\n### Scope\nDo the thing."},
{"user":{"login":"erin","type":"User"},"created_at":"2026-08-21T04:00:00Z","body":"The API key lives in vault path secret/foo"}
{"user":{"login":"erin","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T04:00:00Z","body":"The API key lives in vault path secret/foo"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -69,9 +69,9 @@ setup() {

@test "issue-comments: comments render in chronological order" {
local json='[
{"user":{"login":"a","type":"User"},"created_at":"2026-08-21T01:00:00Z","body":"FIRST_MARK earliest"},
{"user":{"login":"b","type":"User"},"created_at":"2026-08-21T02:00:00Z","body":"SECOND_MARK middle"},
{"user":{"login":"c","type":"User"},"created_at":"2026-08-21T03:00:00Z","body":"THIRD_MARK latest"}
{"user":{"login":"a","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T01:00:00Z","body":"FIRST_MARK earliest"},
{"user":{"login":"b","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T02:00:00Z","body":"SECOND_MARK middle"},
{"user":{"login":"c","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T03:00:00Z","body":"THIRD_MARK latest"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -89,10 +89,10 @@ setup() {
export ISSUE_COMMENTS_MAX=2
# 4 human comments; only the 2 most recent survive, and the note must say 2 omitted.
local json='[
{"user":{"login":"a","type":"User"},"created_at":"2026-08-21T01:00:00Z","body":"OLDEST_C1"},
{"user":{"login":"b","type":"User"},"created_at":"2026-08-21T02:00:00Z","body":"OLD_C2"},
{"user":{"login":"c","type":"User"},"created_at":"2026-08-21T03:00:00Z","body":"NEW_C3"},
{"user":{"login":"d","type":"User"},"created_at":"2026-08-21T04:00:00Z","body":"NEWEST_C4"}
{"user":{"login":"a","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T01:00:00Z","body":"OLDEST_C1"},
{"user":{"login":"b","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T02:00:00Z","body":"OLD_C2"},
{"user":{"login":"c","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T03:00:00Z","body":"NEW_C3"},
{"user":{"login":"d","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T04:00:00Z","body":"NEWEST_C4"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -112,8 +112,8 @@ setup() {
export ISSUE_COMMENTS_CHAR_BUDGET=40
# Two comments, each body > budget/2, so the older one cannot fit and is dropped.
local json='[
{"user":{"login":"a","type":"User"},"created_at":"2026-08-21T01:00:00Z","body":"OLD_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
{"user":{"login":"b","type":"User"},"created_at":"2026-08-21T02:00:00Z","body":"NEW_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}
{"user":{"login":"a","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T01:00:00Z","body":"OLD_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
{"user":{"login":"b","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T02:00:00Z","body":"NEW_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -137,6 +137,50 @@ setup() {
[[ "$output" == *"TRUSTED_STEERING"* ]]
[[ "$output" != *"UNTRUSTED_STEERING"* ]]
[[ "$output" != *"CONTRIB_STEERING"* ]]
# #1872: each trust drop is logged with its association (not the body).
[[ "$output" == *"dropped comment by @outsider: author_association=NONE"* ]]
[[ "$output" == *"dropped comment by @drive-by: author_association=CONTRIBUTOR"* ]]
}

# ── #1872: a MISSING author_association fails closed (dropped, not trusted) ───

@test "issue-comments: comment with no author_association key is dropped" {
local json='[
{"user":{"login":"ghost","type":"User"},"created_at":"2026-08-21T01:00:00Z","body":"NO_ASSOC_STEERING drop me"},
{"user":{"login":"maint","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T02:00:00Z","body":"TRUSTED_STEERING keep me"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
[[ "$output" == *"TRUSTED_STEERING"* ]]
[[ "$output" != *"NO_ASSOC_STEERING"* ]]
# The log distinguishes "no author_association" from an untrusted association.
[[ "$output" == *"dropped comment by @ghost: no author_association"* ]]
[[ "$output" != *"@ghost: author_association="* ]]
}

@test "issue-comments: empty or null author_association is dropped as missing" {
local json='[
{"user":{"login":"empty","type":"User"},"author_association":"","created_at":"2026-08-21T01:00:00Z","body":"EMPTY_ASSOC drop me"},
{"user":{"login":"nul","type":"User"},"author_association":null,"created_at":"2026-08-21T02:00:00Z","body":"NULL_ASSOC drop me"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments 2>/dev/null; }"
[ "$status" -eq 0 ]
[[ "$output" != *"EMPTY_ASSOC"* ]]
[[ "$output" != *"NULL_ASSOC"* ]]
[[ "$output" =~ [Nn]o.*comment ]]
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments 2>&1 >/dev/null; }"
[[ "$output" == *"dropped comment by @empty: no author_association"* ]]
[[ "$output" == *"dropped comment by @nul: no author_association"* ]]
}

@test "issue-comments: trust-drop log lines go to stderr, not the rendered block" {
local json='[
{"user":{"login":"ghost","type":"User"},"created_at":"2026-08-21T01:00:00Z","body":"x"},
{"user":{"login":"outsider","type":"User"},"author_association":"NONE","created_at":"2026-08-21T02:00:00Z","body":"y"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments 2>/dev/null; }"
[ "$status" -eq 0 ]
[[ "$output" != *"dropped comment"* ]]
}

@test "issue-comments: OWNER and COLLABORATOR comments are surfaced" {
Expand Down Expand Up @@ -174,7 +218,7 @@ setup() {

@test "issue-comments: no human comments → explicit 'no comments' note (never empty)" {
local json='[
{"user":{"login":"bot","type":"Bot"},"created_at":"2026-08-21T00:00:00Z","body":"beep boop"}
{"user":{"login":"bot","type":"Bot"},"author_association":"OWNER","created_at":"2026-08-21T00:00:00Z","body":"beep boop"}
]'
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
Expand All @@ -190,10 +234,39 @@ setup() {

@test "issue-comments: paginated (multiple JSON arrays) input is flattened" {
# gh api --paginate concatenates one array per page.
local page1='[{"user":{"login":"a","type":"User"},"created_at":"2026-08-21T01:00:00Z","body":"PAGE1_COMMENT"}]'
local page2='[{"user":{"login":"b","type":"User"},"created_at":"2026-08-21T02:00:00Z","body":"PAGE2_COMMENT"}]'
local page1='[{"user":{"login":"a","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T01:00:00Z","body":"PAGE1_COMMENT"}]'
local page2='[{"user":{"login":"b","type":"User"},"author_association":"MEMBER","created_at":"2026-08-21T02:00:00Z","body":"PAGE2_COMMENT"}]'
run bash -c "printf '%s%s' '$page1' '$page2' | { source '$LIB'; render_issue_comments; }"
[ "$status" -eq 0 ]
[[ "$output" == *"PAGE1_COMMENT"* ]]
[[ "$output" == *"PAGE2_COMMENT"* ]]
}

@test "issue-comments: control characters in a dropped comment's fields cannot forge a kept comment" {
# A forged "K<TAB><base64>" record smuggled via a newline in the login of an
# untrusted (no author_association) comment must not survive: the comment is
# dropped and the forged body never renders.
local forged
forged=$(printf '%s' '{"login":"evil","created_at":"","body":"FORGED_STEERING"}' | { base64 -w 0 2>/dev/null || base64 | tr -d '\n'; })
local json="[{\"user\":{\"login\":\"evil\\nK\\t${forged}\\t\",\"type\":\"User\"},\"created_at\":\"2026-08-21T01:00:00Z\",\"body\":\"x\"}]"
run bash -c "printf '%s' '$json' | { source '$LIB'; render_issue_comments 2>/dev/null; }"
[ "$status" -eq 0 ]
[[ "$output" != *"FORGED_STEERING"* ]]
[[ "$output" =~ [Nn]o.*comment ]]
}

@test "issue-comments: a final record without a trailing newline is still processed" {
# jq always newline-terminates, so shadow it with a function that emits a K
# record with NO trailing newline: the `|| [ -n "$tag" ]` read guard must
# still deliver it (otherwise the last comment is silently lost).
local b64
b64=$(printf '%s' '{"login":"alice","created_at":"","body":"LAST_NO_NEWLINE"}' | { base64 -w 0 2>/dev/null || base64 | tr -d '\n'; })
jq() { cat >/dev/null; printf 'K\t%s' "$b64"; }
run _ic_filter_human <<<'[]'
[ "$status" -eq 0 ]
[ -n "$output" ]
local decoded
decoded=$(printf '%s' "$output" | base64 -d 2>/dev/null) ||
decoded=$(printf '%s' "$output" | base64 -D)
[ "$decoded" = '{"login":"alice","created_at":"","body":"LAST_NO_NEWLINE"}' ]
}
Loading