Skip to content

fix(link-check,queue-monitor-liveness,tool-version-drift-check): restrict tracking-issue adoption to the workflow's own token author - #235

Merged
kyle-sexton merged 5 commits into
mainfrom
task/221-tracking-issue-author-restrict
Jul 24, 2026
Merged

fix(link-check,queue-monitor-liveness,tool-version-drift-check): restrict tracking-issue adoption to the workflow's own token author#235
kyle-sexton merged 5 commits into
mainfrom
task/221-tracking-issue-author-restrict

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #221

Summary

  • Restricts tracking-issue adoption to the workflow's own token identity in link-check.yml, queue-monitor-liveness.yml, and tool-version-drift-check.yml: candidates are filtered to user.login == ISSUE_AUTHOR_LOGIN && user.type == "Bot" BEFORE any marker/title matching, in every lookup path (close paths reuse the single filtered lookup). Marker/title strings are public in workflow source, so without this filter any issue author could craft a decoy that gets adopted (then closed on recovery) or a duplicate decoy that trips the fail-closed ambiguity guard to suppress a real alert — the pattern ported from the stuck-automerge alert (feat(standards-sync): arm auto-merge at PR creation and alert on stuck armed PRs #213).
  • The filter lands at the owning source find-tracking-issue.sh; both generated consumer blocks are re-rendered and stay byte-identical (render-find-tracking-issue.cjs --check green). ISSUE_AUTHOR_LOGIN is a required input (:?), so an unset value fails closed rather than silently dropping the restriction; a null/missing user never matches and never throws.
  • Scope note: the issue named two workflows; tool-version-drift-check.yml is a third consumer of the same shared script with the identical exposure, so the port covers it too (its diff is exactly the regenerated block plus the one ISSUE_AUTHOR_LOGIN env line — no pins, versions, or checksums touched).
  • Token identity verified, not assumed: none of the three workflows override the ambient GITHUB_TOKEN (create-issue-from-file@v6.0.0 defaults token: ${{ github.token }}), and the live API confirms users/github-actions[bot]type: "Bot", matching real tracking issues in this repo.

Test plan

  • bash .github/scripts/find-tracking-issue.test.sh: all cases pass, including new decoy-by-marker (User), decoy-by-marker (other bot), decoy-by-title, decoy-cannot-trip-ambiguity, and unset-ISSUE_AUTHOR_LOGIN-fails-closed cases.
  • New .github/scripts/link-check-tracking-author.test.cjs (executing harness — extracts and runs the inline lookup): 8/8 — decoy-by-marker, decoy-by-different-bot, decoy-by-title, decoy-cannot-suppress-real-report, genuine-double-match-still-fails-closed, PR-never-adopted, plus positive controls.
  • node --test .github/scripts/*.test.cjs: 260/260. node .github/scripts/render-find-tracking-issue.cjs --check: both consumers byte-identical. shellcheck on the shared script + test: clean. actionlint (YAML/expr) on all three workflows: clean.
  • Independent fresh-context review of the full diff: no issues (author-filter precedence, fail-closed posture, jq/JS null-safety, generated-block sync, no unexpected edits).

Related

🤖 Generated with Claude Code

kyle-sexton and others added 4 commits July 23, 2026 18:57
The marker and title strings are public in workflow source, so any issue
author could craft a decoy the lookup adopts, overwrites, or closes — or a
duplicate decoy that trips the fail-closed ambiguity guard to suppress a real
report. Filter candidates to the workflow token's own bot identity
(github-actions[bot], type Bot) before marker and title matching, mirroring the
standards-sync-stuck-automerge-alert lookup. The close path reuses the single
lookup's resolved issue-number, so this covers both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
…ng-issue adoption to own token author

The marker and title strings are public in workflow source, so any issue author
could craft a decoy the shared resolver adopts, overwrites, or closes — or a
duplicate decoy that trips the fail-closed ambiguity guard to suppress a real
report. Restrict candidates in find-tracking-issue.sh to issues authored by the
workflow's own token identity (ISSUE_AUTHOR_LOGIN, a Bot) before marker and
title matching, and pass github-actions[bot] from both generated-block
consumers. ISSUE_AUTHOR_LOGIN is a required input, so an unset value fails closed
rather than silently dropping the restriction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
Droppable process meta for the orchestrator to surface at PR time; not intended
to land on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 8s —— View job


PR Review

  • Gather context and read changed files
  • Review find-tracking-issue.sh core logic
  • Review test files (find-tracking-issue.test.sh, link-check-tracking-author.test.cjs)
  • Review workflow YAML changes
  • Check security posture and CLAUDE.md alignment
  • Post findings

Summary

This is a well-scoped security hardening PR. The author-restriction filter closes a real attack surface (crafted decoys abusing public marker/title strings) and the implementation is correct across all three consumers. No blocking issues.


Core logic — find-tracking-issue.sh

Correct. The filter select(.user.login == $author and .user.type == "Bot") lands before both the marker match and the title-fallback match, so neither path can be exploited independently. The PR description is accurate on filter precedence.

Null-safety passes. In jq, .user.login on a null .user returns null; null == $author is false. No panic, correct exclusion. Lines 27–28

Fail-closed for missing input. ISSUE_AUTHOR_LOGIN gets the same :? assertion as the other three inputs, so an unset variable aborts with a clear error rather than silently dropping the author restriction. Lines 20–23


Workflow YAML changes

link-check.yml — JavaScript path. The filter issue.user?.login === issueAuthorLogin && issue.user?.type === "Bot" is applied against candidates before both the marker scan and the title-fallback scan; candidates is what both matches assignments draw from. Lines 174–179 and 193

queue-monitor-liveness.yml / tool-version-drift-check.yml — Generated bash blocks are byte-identical regenerations of the updated source script; verified visually and confirmed by render-find-tracking-issue.cjs --check. The ISSUE_AUTHOR_LOGIN env line and :? assertion are in both. ✓

Hardcoded 'github-actions[bot]' is correct and expected: neither workflow overrides the ambient GITHUB_TOKEN, and create-issue-from-file@v6.0.0 defaults token: ${{ github.token }}, which always produces author.login = github-actions[bot]. The comments explain the reasoning clearly.


Tests

find-tracking-issue.test.sh — 13 cases. New cases (9–13) cover every threat vector: user-decoy-by-marker, other-bot-decoy-by-marker, user-decoy-by-title, decoy-cannot-trip-ambiguity, and all four unset-required-input variants. Each case checks both exit code and output value; the ambiguity case checks the error message text. Thorough.

link-check-tracking-author.test.cjs — 8 cases. The extractLookupScript() function pulls the github-script step body out of the live YAML and executes it with mock github/core/context objects — so the test exercises the exact code that runs in CI, not a copy. Positive controls (own-bot marker match, own-bot title fallback) are present alongside all decoy cases. The genuine-double-match failure path correctly asserts outputs["issue-number"] === undefined rather than "", matching the setFailed-without-setOutput code path in the script.

One minor fragility (non-blocking): extractLookupScript() finds the script body by scanning for - name: Find existing tracking issue and then a line matching ^ {10}script: \|$ (exactly 10 spaces). If either the step name or indentation ever changes, the extraction fails at the assert.notEqual(stepIndex, -1) guard — which is a loud failure, not a silent pass-through. That's the right behavior, and the technique matches the existing pattern in this repo, so it's acceptable as-is.


Security / CLAUDE.md alignment

Requirement Status
No pull_request_target / workflow_run triggers touched
All uses: remain SHA-pinned (no new uses: added)
No secrets echoed or widened
ISSUE_AUTHOR_LOGIN is a non-secret env value, not a secret
Fail-closed on missing input
Filter applies before any marker/title comparison in all paths

Approved. No action required.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 45s —— View job


Security Review

  • Gather context and understand request
  • Fetch PR diff
  • Read changed files
  • Analyze for security issues (logic flaws, injection, trust boundary, token handling)
  • Post findings

Reviewed: find-tracking-issue.sh, link-check.yml, queue-monitor-liveness.yml, tool-version-drift-check.yml, and test files.

No exploitable security vulnerabilities found. Rationale below.


Author-filter correctness

The filter is applied at the right place in both execution paths (marker match and title-fallback). An attacker who opens a decoy issue with the public marker string or known title is excluded before either select() in jq and before either .filter() in JS — there is no path where the identity check comes after matching on user-controlled strings.

find-tracking-issue.sh:24-29 / find-tracking-issue.sh:42-47

ISSUE_AUTHOR_LOGIN is hardcoded, not caller-controlled

In all three workflows the value is a YAML string literal ('github-actions[bot]'), not derived from ${{ inputs.* }} or any other expression. A caller of link-check.yml (a workflow_call reusable) cannot override it via inputs, and in a workflow_call context the callee's job environment is isolated from the caller's job-level env. The identity used to open issues (create-issue-from-file, github-script) is the ambient GITHUB_TOKEN, which in these configurations is always github-actions[bot], consistent with the hardcoded filter.

link-check.yml:129 / queue-monitor-liveness.yml:157 / tool-version-drift-check.yml:446

Fail-closed on unset ISSUE_AUTHOR_LOGIN

The :? assertion (": ${ISSUE_AUTHOR_LOGIN:?ISSUE_AUTHOR_LOGIN is required}") fires before any jq query, so an unset variable exits non-zero before the restriction could be dropped. The JS side fails closed via an empty issueAuthorLogin that will never equal a real login string.

find-tracking-issue.sh:22

No injection surfaces in variable handling

  • jq receives ISSUE_AUTHOR_LOGIN via --arg author (properly quoted argument), not via string interpolation into the filter expression. The API response is consumed via stdin herestring — no shell word-splitting applies.
  • The JS side reads process.env.ISSUE_AUTHOR_LOGIN, not a template literal. inputs.issue-title and inputs.issue-labels also flow through process.env rather than being interpolated into run: blocks.

Null/missing user field handled safely

In jq: .user.login == $author on a null .user returns false (null comparison), never throws. In JS: issue.user?.login === issueAuthorLogin short-circuits to undefined !== "github-actions[bot]" = false. Neither path can match a ghost/deleted-user issue.

Issue number written to GITHUB_OUTPUT is injection-safe

The value is .[0] | tostring on a JSON integer — jq guarantees a decimal string with no newline, =, or other characters that could poison the output file format.

find-tracking-issue.sh:55

Residual design note (not a finding)

queue-monitor-liveness.yml and tool-version-drift-check.yml fetch all open issues without a label pre-filter (unlike link-check.yml, which applies a label filter at the API query level as an additional layer). The author filter alone is the trust boundary for those two workflows. This is consistent with the PR's stated design and is sufficient — but operators should be aware that in a public repo where many issues exist, the full list is fetched before filtering, which is a performance consideration rather than a security one.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 45s —— View job


PR Review

  • Gather context
  • Diff changed files against origin/main
  • Review find-tracking-issue.sh core logic
  • Review test files
  • Review workflow YAML changes
  • Check security posture and CLAUDE.md alignment
  • Post findings

This is a clean, well-scoped security hardening. No blocking issues.


find-tracking-issue.sh — core logic

Filter precedence is correct. In both lookup paths, select(.user.login == $author and .user.type == "Bot") lands before the marker or title select(). A decoy cannot match on content because it is excluded by identity first.

Marker path / Title-fallback path

Null-safety passes. .user.login on a null .user returns null in jq; null == $author is false. Ghost/deleted-user issues are quietly excluded, no panic.

Fail-closed posture preserved. ISSUE_AUTHOR_LOGIN gets the same :? assertion as the other three required inputs. An unset value aborts before any jq query runs, so the restriction cannot be silently dropped by misconfiguration.

Lines 20–23

Injection-safe. The value flows through --arg author "$ISSUE_AUTHOR_LOGIN", which jq treats as a literal string — not interpolated into the filter expression. No shell word-splitting on the jq input side either (herestring <<<).


Test coverage

find-tracking-issue.test.sh — adds 5 cases (9–13), covering every threat vector:

# Case Expected
9 Marker decoy by human user empty (not adopted)
10 Marker decoy by different bot empty (login mismatch)
11 Title decoy by human user empty (not adopted)
12 Decoy cannot trip ambiguity guard own issue still adopted
13 Unset ISSUE_AUTHOR_LOGIN non-zero exit

Case 13 correctly expands run_unset to cover all four required inputs, each omitted in isolation while the other three are provided.

link-check-tracking-author.test.cjs (new) — 8 cases exercise the live github-script body extracted directly from link-check.yml via extractLookupScript(). The markerFor() helper replicates the exact SHA-256 algorithm used in the workflow, so the marker it generates is the real one the step would compute. Both positive controls (marker match, title-fallback match) and all decoy/ambiguity cases are covered.

One known fragility: extractLookupScript() anchors to "- name: Find existing tracking issue" and exactly 10 spaces of indentation. If the step name or YAML indentation changes, the extraction fails loudly at assert.notEqual(stepIndex, -1) — a loud failure, not a silent pass-through. This matches the established pattern in the repo and is acceptable as-is.

Lines 15–39


Workflow YAML changes

link-check.ymlISSUE_AUTHOR_LOGIN is a YAML string literal ('github-actions[bot]'), not an expression, so it cannot be overridden by a workflow_call caller. The candidates filter is applied once and drives both the marker scan and the title-fallback; the close/recovery paths consume the issue-number output rather than doing their own lookup. ✓

queue-monitor-liveness.yml / tool-version-drift-check.yml — Both inline blocks are byte-identical regenerations of the updated source script; confirmed by visual inspection against the find-tracking-issue.sh diff. The ISSUE_AUTHOR_LOGIN env line and comment are present in each. ✓


Security / CLAUDE.md alignment

Requirement Status
No pull_request_target / workflow_run triggers touched
All uses: remain SHA-pinned; no new uses: added
No secrets echoed or widened
ISSUE_AUTHOR_LOGIN is a literal, not expression-derived
Fail-closed on missing input (bash :? and JS empty-string guard)
Author filter applied before content matching in every path

Approved. No action required.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 14s —— View job


Security Review

  • Gather context and understand request
  • Fetch PR diff
  • Read changed files
  • Analyze for security issues (logic flaws, injection, trust boundary, token handling)
  • Post findings

Reviewed: find-tracking-issue.sh, link-check.yml, queue-monitor-liveness.yml, tool-version-drift-check.yml, and test files.

No exploitable security vulnerabilities found. Reasoning below.


Author-filter placement and completeness

The filter is applied in all four candidate-selection paths — bash marker path, bash title-fallback path, JS marker path, JS title-fallback path — before either matching condition is evaluated. There is no path where an attacker-controlled string (marker or title) is tested against an issue before the .user.login / .user.type gate has already run.

find-tracking-issue.sh:24–30 / find-tracking-issue.sh:42–48 / link-check.yml:174–181


ISSUE_AUTHOR_LOGIN cannot be caller-controlled

In all three workflows the value is a YAML string literal: ISSUE_AUTHOR_LOGIN: 'github-actions[bot]' inside the step's env: block. Step-level env: takes precedence over job- and workflow-level env:. For link-check.yml (a workflow_call reusable), the GitHub Actions isolation model does not propagate the caller's job environment into the callee's jobs, so a caller cannot inject a different value via any legitimate mechanism. ISSUE_AUTHOR_LOGIN is not declared as a workflow_call input, removing that surface entirely.

link-check.yml:129 / queue-monitor-liveness.yml:157 / tool-version-drift-check.yml:446


No injection surface in variable handling

  • Shell (bash workflows): ISSUE_AUTHOR_LOGIN reaches jq via --arg author "$ISSUE_AUTHOR_LOGIN". jq --arg treats the value as a literal string, not as part of the filter expression; there is no jq-injection surface. The open_issues JSON body is passed via herestring <<<"$open_issues" — no shell word-splitting or further interpolation applies.
  • JavaScript (link-check): issueAuthorLogin = process.env.ISSUE_AUTHOR_LOGIN is compared with === in a .filter() predicate and never passed to eval or any execution context. Caller-supplied inputs.issue-title feeds process.env.ISSUE_TITLE, which is used in crypto.createHash (output is a fixed-length hex string) and a strict-equality .filter() — no injection vector.

Null-safety and fail-closed behavior

  • Null .user: In jq, .user.login on a JSON null user returns null; null == $author is false — no panic, correct exclusion. In JS, issue.user?.login short-circuits to undefined, which is !== "github-actions[bot]".
  • Unset ISSUE_AUTHOR_LOGIN: The :? bash assertion fires before any jq query, exiting non-zero. On the JS side an empty env value can never equal a real login string.
  • GITHUB_OUTPUT write: .[0] | tostring on a JSON integer produces a decimal string with no newline, =, or other characters that could poison the output file.

Close path uses the filtered output

The "Close recovered tracking issue" and "Open or update tracking issue" steps in all three workflows consume steps.tracking.outputs.issue-number (or steps.find-issue.outputs.issue-number), which is the output of the filtered lookup. An issue that failed the author filter can never produce a non-empty output, so the close and update paths are transitively restricted to bot-authored issues with no additional code needed.


Residual design observation (not a finding)

The user.type == "Bot" check is redundant given the hardcoded user.login == "github-actions[bot]" match — the [bot] suffix in the login already correlates with type: Bot in GitHub's API. It is harmless and adds marginal defense in depth against hypothetical future API behavioral changes; no action needed.

No security findings. Approved from a security standpoint.

@kyle-sexton
kyle-sexton merged commit 932e831 into main Jul 24, 2026
41 checks passed
@kyle-sexton
kyle-sexton deleted the task/221-tracking-issue-author-restrict branch July 24, 2026 00:24
kyle-sexton added a commit that referenced this pull request Jul 30, 2026
…ompts already instruct (#319)

## Summary

The default review prompt (`claude-review.yml`'s `prompt` input) tells
the model "Use \`gh pr diff\` to identify what changed," but the default
`claude-args` `--allowedTools` grants no Bash at all — only the
inline-comment MCP tool. On a large PR the model has no sanctioned way
to enumerate the diff. `claude-security-review.yml`'s default prompt
gives the identical "use \`gh pr diff\`" instruction with the same
MCP-only default allowlist, so the security lane hits the same denial.

## Verification

Fetched run 30503910653 (melodic-software/provisioning#235, reusable at
c136b27, v0.9.1):

- The composed SDK options log the effective `allowedTools`: `Glob,
Grep, LS, Read, mcp__github_comment__update_claude_comment,
mcp__github_ci__get_ci_status, mcp__github_ci__get_workflow_run_details,
mcp__github_ci__download_job_log,
mcp__github_inline_comment__create_inline_comment, Bash(git add:*),
Bash(git commit:*), Bash(<git-push.sh>:*), Bash(git rm:*)` — no
`Bash(gh:*)` of any kind.
- The result entry for that run records `"permission_denials_count": 4`,
consistent with the model attempting and being denied tool calls the
prompt instructed it to make.
- Confirmed the prompt's own text is unchanged at `gh pr diff` in the
current `main` default.
- Confirmed via `anthropics/claude-code-action`'s own `docs/security.md`
(line 18) that the upstream-recommended pattern for exactly this
situation is a scoped grant like `Bash(gh issue view:*)` — not rewriting
the prompt to use `git diff`, which would face its own problem: the
checkout step uses `fetch-depth: 1` (single commit, no base ref
available locally), so `git diff <base>...HEAD` would not work today
either even if granted.

## Fix

Added `Bash(gh pr diff:*)` to the default `claude-args` `--allowedTools`
of both `claude-review.yml` and `claude-security-review.yml`, aligning
each lane's grant with what its prompt already instructs (the security
lane was this PR's own review finding — same root cause, fixed here
rather than deferred). Read-only command, so it adds no exposure for a
fork PR's read-only token (fork PRs aren't reviewed at all per this
repo's `CLAUDE.md`). `claude-e2e-verify.yml` is deliberately untouched:
its default `claude-args` sets no `--allowedTools` at all, a different
grant model whose defaults this fix does not own.

## Test plan

- `yq -e '.' .github/workflows/claude-review.yml` / `yq -e '.'
.github/workflows/claude-security-review.yml` — valid YAML.
- `node --test .github/scripts/claude-lane-incident.test.cjs
.github/scripts/claude-lane-retry-gate.test.cjs
.github/scripts/claude-review-superseded-guard.test.cjs` — 89/89 pass.

## Related

- `melodic-software/provisioning`#235 (run 30503910653) — the review run
whose composed SDK options supplied the `allowedTools` evidence above.
- #310 / #320 — the companion prompt-side defect surfaced by that same
review run.

Fixes #309

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 30, 2026
…tion as the review's own (#320)

## Summary

A posted review (provisioning#235, run 30503910653) closed with: "No
findings — this is an accurate, well-scoped correction backed by
verified evidence (\`gh variable list\`, \`gh api
orgs/.../actions/variables\`, and direct \`ci.yml\` reads). Nothing
blocking."

## Verification

- The reviewed run's effective `allowedTools` (confirmed in #309's
verification) grants no `Bash(gh:*)` at all, so the review could not
have run either `gh` command itself.
- The PR body's own Test plan section
(melodic-software/provisioning#235) reads verbatim:
  - `gh variable list --repo melodic-software/provisioning` → empty.
- `gh api orgs/melodic-software/actions/variables` → no
`CI_PESTER_ENABLED`.
- The posted review's closing sentence names exactly those two commands
as "verified evidence" it is claiming credit for, despite having no tool
grant to run them. It restated the author's self-reported verification
as its own.

## Fix

Added one instruction to the default review prompt: never restate a PR
author's own claimed verification as the review's own conclusion; label
anything not independently run as author-claimed and unverified. Kept
minimal — no other prompt or grant changes (the companion tool-grant gap
is #309 / #319).

## Test plan

- `yq -e '.' .github/workflows/claude-review.yml` — valid YAML.
- `node --test .github/scripts/claude-lane-incident.test.cjs
.github/scripts/claude-lane-retry-gate.test.cjs
.github/scripts/claude-review-superseded-guard.test.cjs` — 89/89 pass.

## Related

- `melodic-software/provisioning`#235 (run 30503910653) — the reviewed
PR whose posted review restated the author's claimed verification as its
own.
- #309 / #319 — the companion tool-grant gap surfaced by that same
review run.

Fixes #310

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harden link-check and queue-monitor-liveness tracking-issue adoption against decoy issues (author restriction)

1 participant