Skip to content

contributor-check returns UNKNOWN on transient failure with no retry, producing false needs-review flags #27

Description

@imran-siddique

Summary

contributor-check returns Profile: UNKNOWN on transient failures with no retry. Because the risk ordering is deliberately fail-closed, UNKNOWN outranks LOW, so a momentary GitHub API blip labels an established contributor as more suspicious than a genuinely low-risk one. This has already produced three false flags on real submissions.

Observed

Three issues from long-standing accounts were labeled needs-review:UNKNOWN, all reporting Profile: UNKNOWN, Credential: LOW:

Issue Author Account created Public repos
agentrust-io/agent-manifest#272 Mayur021 2014-05 57
agentrust-io/trace-spec#116 lywinged 2019-02 24
agentrust-io/trace-spec#117 lywinged 2019-02 24

None of these are plausible risk signals. For contrast, the same action returned a real, correctly-computed Profile: HIGH on integrations#46, so the check works when the lookup succeeds.

I have removed the misleading label from all three. They should be re-run once this is fixed.

Root cause

.github/actions/contributor-check/action.yml does not run its own logic. It checks out microsoft/agent-governance-toolkit at pinned ref 359a6b8cf453f95d6bc9caf932057e1e78ccffd9 (main as of 2026-07-31), sparse-checkout scripts, and executes _agt/scripts/contributor_check_action.py.

In that file:

# scripts/contributor_check_action.py
result = subprocess.run(
    ...
    timeout=120,
)
risk = data.get("risk", "UNKNOWN")
...
except Exception:
    return "UNKNOWN"

Any exception at all, including a timeout or a single failed API call, collapses to UNKNOWN. There is no retry.

The fail-closed ordering is intentional and correct in principle:

# Fail-closed ordering: UNKNOWN ("could not be determined") must outrank LOW
RISK_ORDER = {"LOW": 1, "MEDIUM": 2, "UNKNOWN": 3, "HIGH": 4}

The problem is that nothing tries hard enough before giving up, so fail-closed fires on noise.

Why this is not fixed by microsoft/agent-governance-toolkit#3571

AGT#3571 tracks contributor_check.py and credential_audit.py having diverged into two copies. The exponential-backoff retry added in AGT#2196 went to the packaged agent_compliance/cli/ copy only, and was never backported to scripts/.

This action consumes the scripts/ copy, which is the one without the retry. The only backoff-adjacent code in scripts/contributor_check.py is a single Retry-After header read on HTTPError (line 88), not a general retry loop.

So the two are related but distinct: resolving AGT#3571 upstream may fix this, but only if the reconciliation lands the retry in scripts/ and this action's pinned ref is advanced past it. Today it is pinned to 2026-07-31.

Suggested fix

  1. Add a bounded retry with exponential backoff around the profile lookup before returning UNKNOWN, so UNKNOWN means "checked and could not determine" rather than "one call failed".
  2. Distinguish UNKNOWN from ERROR in the output, so a genuine indeterminate result and an infrastructure failure are not the same label. Only the former deserves a needs-review label.
  3. Decide whether to keep consuming AGT scripts/ at a pinned SHA. That couples our contributor gating to an upstream repo's internal script layout and to whichever copy of a known-diverged pair happens to be there. Vendoring the checker, or depending on the published package instead, would decouple us.
  4. Once fixed, re-run against Mayur021 and lywinged to confirm they resolve to a real level.

Items 1 and 2 are the fix. Item 3 is worth a separate decision.

Activity

  1. imran-siddique commented on Aug 9, 2026

    @imran-siddique
    MemberAuthor

    Second round of false flags, and they are all on one person.

    Every open issue carrying needs-review:UNKNOWN right now was lywinged's: trace-spec#124, #128, #138, #142 and trace-tests#53. Removed all five.

    Worth recording because it sharpens the cost. Three of those four trace-spec issues were accurate enough to become PRs today (#146, #147, #148), and #138 found a real signature-verification defect in trace-verify. The check is applying its most suspicious label, on every submission, to the most productive external contributor this project has, purely because a transient lookup outranks LOW under the fail-closed ordering.

    That is now five false flags across two rounds, versus one correctly-computed HIGH on integrations#46. Items 1 and 2 in the description are still the fix; this comment is only to note the pattern is recurring rather than one-off, and that it lands on the same account every time.

  2. imran-siddique commented on Sep 7, 2026

    @imran-siddique
    MemberAuthor

    Fix transient contributor-check failures and retry handling. Removing individual UNKNOWN labels does not resolve the underlying bot behavior.

  3. Mayur021 commented on Sep 14, 2026

    @Mayur021
    Member

    One detail on the root cause, because it makes the fix smaller than the description suggests.

    scripts/contributor_check.py does have a retry loop, for attempt in range(3) at line 82. It only covers one case. A 403 retries on Retry-After, a 404 returns None, and everything else reaches raise on the first attempt. Two gaps follow:

    • 5xx is not retried, so a GitHub 502 exits immediately.
    • URLError is not caught. Line 31 imports HTTPError only, and URLError does not appear in the file at all, so the timeout=15 on line 84 raises straight through the loop. A socket timeout is the likeliest source of what you saw.

    The script then exits non-zero, _run_check in contributor_check_action.py reads empty stdout, json.loads throws, and the blanket except Exception returns UNKNOWN. So the retry has to live in _api. By the time it reaches the action wrapper the subprocess is already gone.

    The packaged copy is already right. agent-governance-python/agent-compliance/src/agent_compliance/cli/contributor_check.py has _RETRY_MAX_ATTEMPTS at line 76, a full-jitter _retry_sleep_seconds() at 81, and three branches: 403 on Retry-After clamped to 5 to 60, 5xx with backoff, and URLError with backoff. Porting _api and those two helpers over is most of item 1.

    Mine is the first row in your table, so if you would rather this came from someone else that is fair. It is assigned to you, so tell me if you are already on it, otherwise I can open the AGT PR.

  4. imran-siddique commented on Sep 20, 2026

    @imran-siddique
    MemberAuthor

    Mayur, there are already two related PRs: microsoft/agent-governance-toolkit#3838 adds retries around the subprocess, and #3876 consolidates the scripts onto the packaged checker. Could you check whether #3876 covers the failures you identified and add any missing regression cases there? Our action still pins the old revision, so updating that pin and checking the sparse checkout will also be needed once the fix lands.

  5. Mayur021 commented on Sep 21, 2026

    @Mayur021
    Member

    Yes, #3876 covers it. The packaged _api it consolidates onto keeps all three branches, so 5xx retries with backoff and URLError is caught, which were the two gaps. Line numbers are on the PR.

    One thing it introduces for us, worth catching before it merges. The new scripts/contributor_check.py is a shim that imports the package from agent-governance-python/agent-compliance/src. Their PR adds that path to their own workflow's sparse-checkout. Ours checks out scripts and nothing else, so advancing the pin past it means the shim imports a package that is not on disk, and _run_check returns UNKNOWN on every run rather than on a blip.

    So the pin bump and the sparse-checkout widening are one change, not two.

    Neither PR touches item 2 in your description, and that is the one I would rather see land. An import failure and a rate limit still produce the same label.

    Happy to write the regression test for the scripts-only checkout against their branch.

  6. imran-siddique commented on Sep 23, 2026

    @imran-siddique
    MemberAuthor

    Mayur, please add the regression test against #3876: show the scripts-only checkout failing and the expanded checkout working. Our pin update must include the package source in the same change.

    Keep item 2 open separately. Infrastructure failures should produce an operational error without a contributor-risk label; they must not silently pass the check either. The retry and consolidation PRs do not resolve that distinction.

  7. Mayur021 commented on Sep 24, 2026

    @Mayur021
    Member

    Ran it down against #3876's head, b246a69a. The scripts-only checkout does not just fail, it fails into the exact label this issue is about.

    scripts/contributor_check.py is now a shim. It does sys.path.insert(0, ...) on parents[1] / "agent-governance-python" / "agent-compliance" / "src" and then importlib.import_module("agent_compliance.cli.contributor_check"). scripts/contributor_check_allowlist.json is removed in the same PR, having moved into the package.

    Under sparse-checkout: scripts that path is not on disk, so the import raises and the subprocess exits non-zero with empty stdout.

    _run_check in scripts/contributor_check_action.py calls subprocess.run without check=True and never reads result.returncode. It goes straight to json.loads(result.stdout), which raises on the empty string, and the bare except Exception returns "UNKNOWN". _aggregate_risk is fail-closed, so UNKNOWN outranks LOW and the label lands.

    So bumping the pin past #3876 without changing the checkout reproduces this issue on every run rather than on an API blip. Same symptom, opposite cause: deterministic, not transient.

    The pin update needs this in the same change, as you said:

    sparse-checkout: |
      scripts
      agent-governance-python/agent-compliance/src

    The shim resolves by sys.path rather than by an installed package, so the source tree is enough and no install step is needed.

    On item 2, the line that conflates the two states is the except Exception: return "UNKNOWN" in _run_check. A missing module and a rate-limited probe are not the same fact, and the comment in _run_check already says UNKNOWN is "reserved for checks that errored or could not be determined". Reading returncode is what lets an infrastructure failure exit as an operational error rather than a contributor-risk level, and it is also what stops it passing silently. Agreed it stays separate from the retry and consolidation PRs.

    Regression test to follow against b246a69a: scripts-only asserting UNKNOWN, expanded asserting a computed profile.

  8. imran-siddique commented on Sep 29, 2026

    @imran-siddique
    MemberAuthor

    @Mayur021 AGT#3876 was closed as stale on 26 September without merging, and AGT main still has no shim, so our pin at 359a6b8 with the scripts-only checkout is unaffected and no sparse-checkout change is needed. Your analysis stands if that consolidation comes back.

    Item 2 is the live fix. Since those scripts live in AGT and we only pin them, please open it here: vendor contributor_check.py and contributor_check_action.py into this repo at 359a6b8 (MIT, keep the header and note the source commit), point action.yml at the vendored copy, and make _run_check read returncode so an infrastructure failure exits as an operational error with no risk label. Include the scripts-only-style failure as the regression test. Aim for 10 October.

  9. imran-siddique commented on Sep 30, 2026

    @imran-siddique
    MemberAuthor

    Reopening: #46 landed item 2; item 1, the bounded retry in _api, is still open.

  10. Mayur021 commented on Oct 1, 2026

    @Mayur021
    Member

    Item 1 runs into the integrity contract #46 just set up, so flagging the choice before I write it.

    The retry has to go in _api in contributor_check.py, and that file is one of the four the vendor README records as byte-identical to upstream, with the vendor-integrity job checking its sha256. Patching it fails that job by design.

    Unless you would rather handle it another way, I will declare it a second local modification: the README gets a Local modifications section covering both files, contributor_check.py comes out of the checksum list, the other three stay pinned. Then offer the same patch to AGT so the divergence retires whenever we re-vendor.

    Upstream on its own is not a route right now. AGT#3838 and AGT#3876 were both closed unmerged on 26 September, AGT#3571 has not moved since 4 August, and _api in AGT's scripts/ copy on main is still the 403-only loop. The packaged copy still has the three branches to port from.

  11. imran-siddique commented on Oct 1, 2026

    @imran-siddique
    MemberAuthor

    @Mayur021 go ahead as a declared second modification, with the other three files still hash-pinned. Skip the upstream offer to AGT: we are not investing there, so this copy is ours now. Keep the retry bounded and limited to transient failures, so a genuine 404 or a rate-limit answer still reports as itself.

  12. imran-siddique commented on Oct 2, 2026

    @imran-siddique
    MemberAuthor

    Both items landed: #46 and #49.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

help wantedExtra attention is needed

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions