Skip to content

fix(branch-protection): add security checks to required status checks - #1232

Merged
myakove merged 29 commits into
mainfrom
fix/issue-1231-security-checks-branch-protection
Sep 29, 2026
Merged

myakove merged 29 commits into
mainfrom
fix/issue-1231-security-checks-branch-protection

Conversation

@myakove

@myakove myakove commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1231

This PR bundles more than its title suggests. It started as a focused fix for the security checks missing from branch protection, then grew as adjacent defects surfaced. The unrelated work is tracked separately in #1233 and #1234. If you only care about the branch-protection fix, the first two commits are it.

The original problem

security-suspicious-paths and security-committer-identity ran on every PR but were never marked required in GitHub. The Checks tab showed both as optional, and a maintainer using the native merge box got no signal until can-be-merged had already flipped to failure.

There are two independent notions of "required", and only one was wired up:

Server-side (worked). all_required_status_checks() in webhook_server/libs/handlers/check_run_handler.py appends both names when security_mandatory is true, feeding required_check_failed_or_no_status() and blocking can-be-merged. The security-checks.mandatory config key already existed with a true default.

GitHub branch protection (missing). get_required_status_checks() in webhook_server/utils/github_repository_settings.py builds the context list applied to protected branches at startup. It handled tox, verified-job, container, pypi, pre-commit, conventional-title and pre-commit.ci - pr — but never read security-checks, so both names were absent. Confirmed against the live repo before the fix:

$ gh api repos/myk-org/github-webhook-server/branches/main/protection/required_status_checks
"contexts":["can-be-merged","verified","build-container","pre-commit.ci - pr",
            "tox","python-module-install","pre-commit","conventional-title"]

Merge was only blocked transitively via can-be-merged.

Note: branch protection is applied during server startup, not per webhook. This takes effect on the next restart that runs repository setup, not on merge.

What is in here

Commit Change
2b412f2, a903a1c Security checks added to required status checks, on both the auto and include-runs paths (#1231)
cc09d4d prek run --all-files replaces standalone uv run mypy as the documented lint gate
dee49fc Eight empty doc pages recovered; broken code fences, missing H1s, duplicated reference, wrong port (#1233)
945e4aa docsfy renderer vendored into scripts/; AGENTS.md documents the docs workflow (#1233)
2fc2d5a Site regenerated from the repaired markdown (#1233)
177efda /automerge cancel now removes the label; 401→403 docstring (#1234)
b0e7f74 pygments declared — the docs renderer imported it but relied on it transitively (#1233)
c78d654 exclude-runs is now applied to the include-runs list

Changes worth reviewing carefully

exclude-runs semantics changed — this is a behaviour change, not just a bug fix. set_repository() passed exclude_status_checks only to get_required_status_checks(). When protected-branches set include-runs, that call was skipped entirely, so exclude-runs was silently discarded and the exclusions never reached GitHub. This affected every check, not only the security ones, and examples/config.yaml shipped exactly the pattern that does not work.

exclude-runs is now subtracted from the assembled list on the include-runs path too. Verified against the real set_repository() call: include: [a, b] with exclude: [b] previously yielded [a, b] and now yields [a] plus the security checks. The auto path already subtracted inside get_required_status_checks() and is unchanged, so exclusion is applied exactly once per branch.

An explicit exclude-runs entry is honoured even for the security checks. security-checks.mandatory remains the switch for not requiring them at all.

security-checks is read through the repository-scoped Config, not the raw repositories entry, which required a keyword-only config parameter on get_required_status_checks(). Deliberate: reading from the raw entry would ignore a repo's local .github-webhook-server.yaml, so a repo setting security-checks: {mandatory: false} there would still have its checks marked required. For a security gate, silently over-blocking is the wrong failure direction.

pygments was never declared. scripts/docs_render/renderer.py enables markdown's codehilite extension, which imports pygments at render time. It reached the environment only transitively via ipython (dev group), pytest (tests group) and rich. Now declared in the dev group. The docs site is built ahead of time and never rendered at build or runtime — the Dockerfile and entrypoint.py do not reference it — so the dev group is the correct home.

Docs

docs/*.md is the source of truth. *.html, search-index.json, llms.txt and llms-full.txt are generated by scripts/generate_docs.py, which vendors the deterministic rendering half of myk-org/docsfy so the site rebuilds from this repo alone. Rendering is idempotent and never writes a markdown file.

One local divergence from upstream: _indent_fenced_blocks indents fenced code bodies in llms-full.txt only, so YAML comments like # config.yaml are not mistaken for headings when an LLM reads the file as plain text. The HTML path is byte-identical to upstream.

AGENTS.md documents the workflow, including that navigation is not auto-discovered — a new page must be added to the list in generate_docs.py or it will not appear in the sidebar or search.

Verification

  • prek run --all-files — all 22 hooks passed
  • uv run --group tests pytest -n auto — 1805 passed, coverage 90.31% (fail_under = 90), stable across repeated parallel and serial runs
  • scripts/generate_docs.py — idempotent, byte-identical output on re-run
  • All 15 doc pages render with content; every YAML block parses; code fences balanced; no duplicated sections
  • The new /automerge cancel test was confirmed to fail against the pre-fix code
  • The exclude-runs fix was verified by exercising the real set_repository() path, not by reading the code

Still open

exclude-runs is now honoured, but examples/config.yaml shipped the broken pattern for a long time, so any repo copied from it may have believed it was excluding checks that were never excluded. Those exclusions need re-checking by hand — this PR cannot tell which repos were affected.

get_required_status_checks() built the required-context list sent to GitHub
branch protection but never read the security-checks config, so
security-suspicious-paths and security-committer-identity were never marked
required. The server-side gate in all_required_status_checks() already
honoured them, so merge was only blocked transitively via can-be-merged and
the GitHub UI showed both checks as optional.

security-checks is read through the repository-scoped Config rather than the
raw repositories entry, so repo-local .github-webhook-server.yaml overrides
win. A repo setting security-checks.mandatory: false locally would otherwise
still have its checks marked required.

The appends land before the exclude-runs removal loop, so protected-branches
exclude-runs can still drop them.

Closes #1231

Assisted-by: PI (gpt-6-luna) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
@myakove-bot

Copy link
Copy Markdown
Collaborator

Report bugs in Issues

Welcome! 🎉

This pull request will be automatically processed with the following features:

🔄 Automatic Actions

  • Reviewer Assignment: Reviewers are automatically assigned based on the OWNERS file in the repository root
  • Size Labeling: PR size labels (XS, S, M, L, XL, XXL) are automatically applied based on changes
  • Issue Creation: Disabled for this repository
  • Pre-commit Checks: pre-commit runs automatically if .pre-commit-config.yaml exists
  • Branch Labeling: Branch-specific labels are applied to track the target branch
  • Auto-verification: Auto-verified users have their PRs automatically marked as verified
  • Labels: All label categories are enabled (default configuration)

📋 Available Commands

PR Status Management

  • /wip - Mark PR as work in progress (adds WIP: prefix to title)
  • /wip cancel - Remove work in progress status
  • /hold - Block PR merging (PR author or approvers)
  • /hold cancel - Unblock PR merging (PR author or approvers)
  • /verified - Mark PR as verified
  • /verified cancel - Remove verification status
  • /reprocess - Trigger complete PR workflow reprocessing (useful if webhook failed or configuration changed)
  • /regenerate-welcome - Regenerate this welcome message
  • /security-override - Set security check runs to pass (maintainers only)
  • /security-override cancel - Re-run security checks

Review & Approval

  • /lgtm - Approve changes (looks good to me)
  • /approve - Approve PR (approvers only)
  • /automerge - Enable automatic merging when all requirements are met (maintainers and approvers only)
  • /assign-reviewers - Assign reviewers based on OWNERS file
  • /assign-reviewer @username - Assign specific reviewer
  • /check-can-merge - Check if PR meets merge requirements

Testing & Validation

  • /retest tox - Run Python test suite with tox
  • /retest build-container - Rebuild and test container image
  • /retest python-module-install - Test Python package installation
  • /retest pre-commit - Run pre-commit hooks and checks
  • /retest conventional-title - Validate commit message format
  • /retest all - Run all available tests

Container Operations

  • /build-and-push-container - Build and push container image (tagged with PR number)
    • Supports additional build arguments: /build-and-push-container --build-arg KEY=value

Cherry-pick Operations

  • /cherry-pick <branch> - Schedule cherry-pick to target branch when PR is merged
    • Multiple branches: /cherry-pick branch1 branch2 branch3
  • /cherry-pick-retry <branch> - Retry a failed cherry-pick (merged PRs only)

Branch Management

  • /rebase - Rebase this PR branch onto its base branch

Label Management

  • /<label-name> - Add a label to the PR
  • /<label-name> cancel - Remove a label from the PR

✅ Merge Requirements

This PR will be automatically approved when the following conditions are met:

  1. Approval: /approve from at least one approver
  2. LGTM Count: Minimum 1 /lgtm from reviewers
  3. Status Checks: All required status checks must pass
  4. No Blockers: No wip, hold, has-conflicts labels and PR must be mergeable (no conflicts)
  5. Verified: PR must be marked as verified

📊 Review Process

Approvers and Reviewers

Approvers:

  • myakove
  • rnetser

Reviewers:

  • myakove
  • rnetser
Available Labels
  • hold
  • verified
  • wip
  • lgtm
  • approve
  • automerge
AI Features
  • Conventional Title: Mode: fix (cursor/cursor-grok-4.6-high)
  • Cherry-Pick Conflict Resolution: Enabled (cursor/cursor-grok-4.6-high)
Security Checks
  • Suspicious Path Detection: Monitors paths: .claude/, .vscode/, .cursor/, .devcontainer/, .pi/, .github/workflows/, .github/actions/
  • Committer Identity Check: Verifies last committer matches PR author
  • Mandatory: Security checks block merge (use /security-override to bypass — maintainers only)

💡 Tips

  • WIP Status: Use /wip when your PR is not ready for review
  • Verification: The verified label is removed on new commits unless the push is detected as a clean rebase
  • Cherry-picking: Cherry-pick labels are processed when the PR is merged
  • Container Builds: Container images are automatically tagged with the PR number
  • Permission Levels: Some commands require approver permissions
  • Auto-verified Users: Certain users have automatic verification and merge privileges

For more information, please refer to the project documentation or contact the maintainers.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Require built-in security checks in GitHub branch protection

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Add both security checks to GitHub’s required contexts when enabled, instead of relying solely on
 can-be-merged.
• Honor security-check opt-outs and branch exclusions; warn and use defaults for malformed settings.
• Test default, disabled, excluded, and malformed configuration cases.
Diagram

graph TD
  Setup["Repository setup"] --> Config["Scoped config"] --> Mandatory{"Checks mandatory?"}
  Mandatory -- Yes --> Select["Select security checks"] --> Filter["Deduplicate and exclude"] --> Protection["GitHub branch protection"]
  Mandatory -- No --> Filter
Loading
High-Level Assessment

Adding the checks to branch protection is preferable to relying only on the existing can-be-merged gate: it makes their required status visible in GitHub while retaining configuration opt-outs and exclusions.

Files changed (2) +166 / -9

Bug fix (1) +47 / -0
github_repository_settings.pyAdd enabled security checks to branch protection contexts +47/-0

Add enabled security checks to branch protection contexts

• Reads security-check settings through the repository-scoped Config, validates their types, and appends enabled check names before deduplication and exclusions. Passes Config from repository setup into required-context generation.

webhook_server/utils/github_repository_settings.py

Tests (1) +119 / -9
test_github_repository_settings.pyCover security checks in required-context generation +119/-9

Cover security checks in required-context generation

• Updates existing calls for the new config argument and tests mandatory defaults, individual opt-outs, exclusions, and malformed boolean settings.

webhook_server/tests/test_github_repository_settings.py

@qodo-code-review

qodo-code-review Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

⚠️ 28 lower-priority findings omitted to fit the comment size limit; re-run the review or view the findings in the Qodo portal.

Grey Divider


Action required

1. Malformed local config blocks merges ✓ Resolved
Description
set_repository() catches yaml.YAMLError, replaces the repository-local configuration with {},
and writes branch protection with the default mandatory security contexts. The webhook constructor
re-raises that same malformed YAML error before it can schedule either check, so those newly
required contexts remain absent on every PR.
Code

webhook_server/utils/github_repository_settings.py[R350-354]

+    except yaml.YAMLError:
+        # Never abort startup on a broken repo-local file, the config.yaml values are used instead
+        LOGGER.warning(
+            f"[API user {api_user}] - {full_repository_name}: Invalid YAML in .github-webhook-server.yaml, ignoring it"
+        )
Relevance

●●● Strong

Accepted reliability precedents favor defensive malformed-config handling and consistent
repository-level configuration resolution.

PR-#998
PR-#1109

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new YAML-error fallback causes startup to resolve security checks from defaults, while runtime
uses the same local file and propagates its parse error before pull-request tasks can queue check
runs.

webhook_server/utils/github_repository_settings.py[137-186]
webhook_server/utils/github_repository_settings.py[226-232]
webhook_server/libs/config.py[124-140]
webhook_server/libs/github_api.py[186-199]
webhook_server/libs/handlers/pull_request_handler.py[1271-1280]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A malformed `.github-webhook-server.yaml` currently falls back to `{}` only while applying branch protection. Runtime webhook initialization still raises `yaml.YAMLError`, preventing the security checks from being queued; the startup fallback has already made those missing checks required, leaving pull requests permanently pending.

## Fix Focus Areas
- webhook_server/utils/github_repository_settings.py[350-355]
- webhook_server/libs/config.py[124-140]

## Recommended Fix
Do not write or update branch protection when repository-local YAML is malformed. Handle `yaml.YAMLError` like an indeterminate local-config read: log the failure, set `apply_branch_protection = False`, and continue the unrelated repository setup. Alternatively, establish one shared malformed-YAML fallback policy used by both startup and webhook processing; do not let startup require checks that runtime cannot report.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Docs visitors execute heading scripts ✓ Resolved
Description
_md_to_html() sanitizes the document body but returns md.toc without sanitizing it. When a
Markdown heading contains an event handler, page.html inserts that handler into the table of
contents with |safe, where a visitor can trigger it.
Code

scripts/docs_render/renderer.py[R368-369]

+    toc_html = getattr(md, "toc", "")
+    return content_html, toc_html
Relevance

●●● Strong

An unsanitized safe-rendered TOC creates an XSS bypass; prior sanitizer findings were fixed
incrementally.

PR-#890

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The TOC extension derives HTML from headings, but only content_html passes through
_sanitize_html; the template explicitly marks the separate TOC as safe. For example, a heading
containing <span onmouseover="alert(1)">Hover me</span> retains its handler in the TOC even though
the body sanitizer removes event-handler attributes.

scripts/docs_render/renderer.py[93-95]
scripts/docs_render/renderer.py[356-370]
scripts/docs_render/templates/page.html[34-37]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The renderer sanitizes Markdown body HTML but publishes the separately generated table of contents without sanitization, allowing executable heading markup.
## Fix Focus Areas
- scripts/docs_render/renderer.py[367-370]
- scripts/docs_render/templates/page.html[34-37]
## Recommended Fix
Apply the HTML sanitizer to the generated table of contents before passing it to the template, and add a test using a heading with an event-handler attribute.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Unclosed frames run injected scripts ✓ Resolved
Description
_sanitize_html() removes paired or self-closing iframe tags but leaves an unclosed `<iframe
srcdoc> tag intact. If Markdown contains <iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">`,
the generated page emits it as HTML and a visitor's browser executes the script inside the frame.
Code

scripts/docs_render/renderer.py[R92-93]

+        html = re.sub(rf"<{tag}[^>]*>.*?</{tag}>", "", html, flags=re.DOTALL | re.IGNORECASE)
+        html = re.sub(rf"<{tag}[^>]*/>", "", html, flags=re.IGNORECASE)
Relevance

●●● Strong

Concrete sanitizer bypass involving executable iframe content warrants fixing, consistent with this
PR's security-hardening intent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The iframe-removal expressions require either a closing tag or />, while the test records that raw
Markdown HTML reaches this sanitizer. The rendering path then inserts the surviving content into the
page with Jinja's safe filter.

scripts/docs_render/renderer.py[85-93]
scripts/docs_render/test_renderer.py[82-87]
scripts/docs_render/renderer.py[363-376]
scripts/docs_render/templates/page.html[8-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An unclosed raw-Markdown iframe with an entity-encoded script in `srcdoc` survives regex sanitization and executes in the published page.

## Fix Focus Areas
- scripts/docs_render/renderer.py[85-158]
- scripts/docs_render/test_renderer.py[75-87]

## Recommended Fix
Use an HTML parser-based sanitizer that excludes iframe elements and dangerous attributes regardless of whether tags are closed. Add a regression test using the unclosed `srcdoc` payload.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (2)
4. Docs visitors can run injected scripts ✓ Resolved
Description
_sanitize_html() removes only script tags with an exact </script> closing tag, leaving valid
HTML such as <script src="https://attacker.example/x.js"></script > intact. If that markup enters
a Markdown source used for a generated page, render_page() places it in the published HTML and a
visitor's browser loads the external script.
Code

scripts/docs_render/renderer.py[87]

+    html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
Relevance

●●● Strong

The sanitizer’s exact closing-tag match permits valid script markup, constituting a concrete
security bypass.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The removal expression requires the literal </script> sequence; the Markdown conversion passes its
result to that sanitizer, and the page template trusts the resulting content. The generator reads
Markdown files and writes the rendered pages.

scripts/docs_render/renderer.py[84-87]
scripts/docs_render/renderer.py[361-375]
scripts/docs_render/templates/page.html[8-12]
scripts/generate_docs.py[105-108]
scripts/generate_docs.py[144-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The script-removal regex misses valid HTML closing-tag variants, allowing Markdown-supplied scripts into published pages.
## Fix Focus Areas
- scripts/docs_render/renderer.py[84-155]
- scripts/docs_render/test_renderer.py[1-57]
## Recommended Fix
Replace regex-based HTML sanitization with a parser-based allowlist that removes script elements and unsafe attributes regardless of HTML formatting. Add a regression test for a closing tag containing whitespace.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Local overrides leave merges blocked ✓ Resolved
Description
get_required_status_checks() calls config.get_value() without loading the repository-local file,
so its security settings cannot affect the contexts written at startup. When
.github-webhook-server.yaml disables a check or sets mandatory: false, webhook processing honors
that override but branch protection still requires the omitted check.
Code

webhook_server/utils/github_repository_settings.py[163]

+    _security_checks: Any = config.get_value(value="security-checks", return_on_none={})
Relevance

●●● Strong

Accepted precedent favors Config.get_value() for effective repository-local overrides, especially
security settings.

PR-#1114

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Config.get_value() reads the local file only when supplied through extra_dict; settings
synchronization creates a Config without fetching that file. Webhook processing fetches it
separately and uses it when configuring security checks, so the two paths disagree.

webhook_server/libs/config.py[89-124]
webhook_server/libs/config.py[126-151]
webhook_server/utils/github_repository_settings.py[260-284]
webhook_server/libs/github_api.py[187-193]
webhook_server/utils/github_repository_settings.py[161-200]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Startup branch protection ignores repository-local security settings, potentially requiring checks that webhook processing does not emit.
## Fix Focus Areas
- webhook_server/utils/github_repository_settings.py[161-164]
- webhook_server/utils/github_repository_settings.py[352-355]
## Recommended Fix
Fetch each repository's local configuration during settings synchronization and pass it to `Config.get_value()` as `extra_dict` when resolving security checks. Cover locally disabled and advisory checks in tests.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. Startup YAML error log lacks traceback ✓ Resolved
Description
set_repository() catches yaml.YAMLError but records the exception with LOGGER.error(... {ex})
instead of LOGGER.exception(). When repository-local YAML is malformed, the startup message says
branch protection was skipped but does not include the traceback for that failure.
Code

webhook_server/utils/github_repository_settings.py[R356-359]

+        LOGGER.error(
+            f"[API user {api_user}] - {full_repository_name}: Invalid YAML in .github-webhook-server.yaml, ex: {ex}, "
+            "skipping branch protection"
+        )
Relevance

●●● Strong

Accepted precedent favors LOGGER.exception for caught failures, including in this exact repository
settings module.

PR-#1114

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule requires traceback-aware logging when an exception handler records a caught failure. The
YAML handler includes the exception in an error message but does not use LOGGER.exception() or
exc_info=True.

Rule 1523270: Use logger.exception() when logging caught exceptions with tracebacks
webhook_server/utils/github_repository_settings.py[350-361]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The startup handler logs a caught YAML error without a traceback.

## Fix Focus Areas
- webhook_server/utils/github_repository_settings.py[350-359]

## Recommended Fix
Use `LOGGER.exception()` for the invalid-YAML message while retaining the repository name and the decision to skip branch protection.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Excluded checks still block merging ✓ Resolved
Description
set_repository() removes mandatory security contexts from the include-runs branch-protection
list when they appear in exclude-runs, even though the runtime does not apply that exclusion. With
security-checks.mandatory: true and an excluded security context, GitHub treats the check as
optional while pull-request processing still runs it and can-be-merged still requires it.
Code

webhook_server/utils/github_repository_settings.py[R423-425]

+                    # exclude-runs wins over include-runs, security checks included
+                    required_status_checks = [
+                        check for check in required_status_checks if check not in exclude_status_checks
Relevance

●●● Strong

Security-check consistency bugs are accepted when runtime behavior can undermine merge protection.

PR-#1109
PR-#1123

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The added exclusion filter removes contexts after the PR adds the security checks to an explicit
include list. The runtime queues the suspicious-paths check independently of branch exclusions, and
the server-side merge calculation independently adds enabled mandatory security checks, so an
excluded context can remain merge-blocking despite no longer being required by GitHub branch
protection.

webhook_server/utils/github_repository_settings.py[411-425]
webhook_server/libs/handlers/pull_request_handler.py[1274-1275]
webhook_server/libs/handlers/pull_request_handler.py[1313-1314]
webhook_server/libs/handlers/check_run_handler.py[433-441]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `include-runs` handling removes security contexts through `exclude-runs`, but the webhook's server-side required-check calculation still treats configured mandatory security checks as required. This makes the native GitHub protection list disagree with the check that ultimately blocks a merge.

## Fix Focus Areas
- webhook_server/utils/github_repository_settings.py[411-425]
- webhook_server/libs/handlers/check_run_handler.py[433-441]

## Recommended Fix
Do not remove a security context from branch protection while `security-checks.mandatory` is true. Keep `security-suspicious-paths` and `security-committer-identity` in the effective `include-runs` contexts whenever their individual settings enable them; use `mandatory: false`, an empty `suspicious-paths` list, or `committer-identity-check: false` for intentional opt-out. Update the related configuration documentation to describe that behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Docs visitors are sent off-site ✓ Resolved
Description
_is_safe_url() rejects only literal // protocol-relative URLs and accepts scheme-less
\\evil.example or /\evil.example values without normalizing browser path separators. The
sanitizer then emits those accepted href or src values into the generated page, where browsers
resolve them as external protocol-relative URLs despite the stated same-site restriction.
Code

scripts/docs_render/renderer.py[R155-160]

+    if decoded.startswith("//"):
+        return False  # protocol-relative: re-points the request at another host
+    if decoded.startswith("/"):
+        return True
+    # No scheme = relative URL, which can only ever resolve against this site.
+    return not urllib.parse.urlsplit(decoded).scheme
Relevance

●●● Strong

Security sanitizer bypass is concrete and conflicts with the PR’s explicit same-site URL policy; no
rejection precedent exists.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The URL policy only rejects a literal double slash and accepts all no-scheme values, while allowed
link and image attributes serialize the accepted input unchanged into sanitized HTML that templates
mark safe.

scripts/docs_render/renderer.py[140-160]
scripts/docs_render/renderer.py[180-192]
scripts/docs_render/renderer.py[503-519]
scripts/docs_render/templates/page.html[8-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The URL sanitizer intends to reject protocol-relative external hosts, but it checks only URLs beginning with literal `//`. Browsers normalize backslashes in special-scheme URLs, so `\\host` and `/\host` can be interpreted as external protocol-relative URLs after being emitted by the sanitizer.

## Fix Focus Areas
- scripts/docs_render/renderer.py[148-160]
- scripts/docs_render/renderer.py[180-192]

## Recommended Fix
Normalize backslashes to forward slashes before evaluating the URL, then reject protocol-relative forms after normalization. Apply the normalized value consistently to scheme validation and serialization, and add tests for `\\evil.example` and `/\evil.example` in both `href` and `src` attributes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (10)
9. Draft command settings are ignored ✓ Resolved
Description
The draft-command example labels allow-commands-on-draft-prs as .github-webhook-server.yaml, but
draft-event handling calls Config.get_value() without the loaded local configuration. A maintainer
following that example leaves draft commands blocked because only root or repository entries in
config.yaml can supply the value.
Code

docs/run-pull-request-commands.md[R69-74]

+```yaml
+# .github-webhook-server.yaml
+allow-commands-on-draft-prs:
+  - build-and-push-container
+  - retest
+```
Relevance

●●● Strong

Accepted precedent requires repository-local settings via extra_dict; this finding matches that
established configuration pattern.

PR-#1109
PR-#980

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The example uses a repository-local file, but both the early draft-event gate and command handler
retrieve the setting without an extra_dict; Config.get_value() only consults repository-local
data when that argument is explicitly supplied.

docs/run-pull-request-commands.md[64-84]
webhook_server/libs/github_api.py[665-687]
webhook_server/libs/handlers/issue_comment_handler.py[186-205]
webhook_server/libs/config.py[147-168]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The documentation shows `allow-commands-on-draft-prs` in a repository-local `.github-webhook-server.yaml` file. The runtime draft gate reads the setting directly from `Config.get_value()` without passing the repository-local dictionary, so that documented local setting is ignored.

## Fix Focus Areas
- docs/run-pull-request-commands.md[69-74]
- webhook_server/libs/github_api.py[665-687]

## Recommended Fix
Change the example heading and surrounding text to place `allow-commands-on-draft-prs` in the repository entry of server-side `config.yaml`. State that it is not a supported repository-local override, consistently with the configuration-resolution rules.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Docs previews use bare Python ✓ Resolved
Description
The preview command in AGENTS.md invokes python3 -m http.server directly instead of using `uv
run. Contributors following the documented workflow need a separate python3` command and bypass
the project's selected environment for this step.
Code

AGENTS.md[47]

+- Preview: serve `docs/` over HTTP and open a page (e.g. `python3 -m http.server -d docs 8000`) — do not open the `.html` via `file://`, the sidebar and search need HTTP
Relevance

●●● Strong

Clear workflow compliance issue; repository documentation already favors managed uv commands.

PR-#1150

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The newly added preview instruction contains a direct python3 invocation, while the checklist
requires Python commands in project workflow configuration to run through uv.

Rule 1523305: Use uv instead of python/pip in scripts and config
AGENTS.md[45-47]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The documented docs-preview command invokes bare `python3`, contrary to the project's uv-based execution workflow.

## Fix Focus Areas
- AGENTS.md[47-47]

## Recommended Fix
Change the example to `uv run python -m http.server -d docs 8000`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Failed docs enter AI indexes ✓ Resolved
Description
scripts/generate_docs.py passes every page to the LLM index builders, including docsfy’s standard
generation-failure stubs. If a docsfy-generated stub is brought into docs/ and the site is
rebuilt, the published indexes present that failure notice as documentation instead of excluding it
as docsfy does.
Code

scripts/generate_docs.py[R167-168]

+    _write("llms.txt", renderer._build_llms_txt(plan))  # noqa: SLF001
+    _write("llms-full.txt", renderer._build_llms_full_txt(plan, pages))  # noqa: SLF001
Relevance

●●● Strong

Failure stubs should be excluded from AI indexes to avoid publishing non-documentation as content.

PR-#1152

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Docsfy defines a recognizable failure stub and excludes matching pages from both AI-readable
indexes. The new local generator passes its unfiltered navigation and pages to those same index
builders.

github-webhook-server -> docsfy
scripts/generate_docs.py[162-168]
scripts/docs_render/renderer.py[54-59]
External repo: myk-org/docsfy, src/docsfy/generator.py [243-255]
External repo: myk-org/docsfy, src/docsfy/renderer.py [569-585]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The local generator includes docsfy generation-failure stubs in AI-readable indexes, unlike docsfy’s renderer.

## Fix Focus Areas
- scripts/generate_docs.py[162-168]
- scripts/docs_render/renderer.py[54-59]

## Recommended Fix
Filter navigation entries whose Markdown matches the standard docsfy failure stub before passing navigation to both LLM index builders. Keep the failure notice visible on its HTML page, matching docsfy’s behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. The documented security-scan recipe fails ✗ Dismissed
Description
In the recipe command: TOKEN=xyz DEBUG=true uv tool run ..., the leading assignments are skipped
only when the server checks at startup that the program exists. At run time the tokens go straight
to create_subprocess_exec, so TOKEN=xyz is treated as the program name and the check fails.
Code

docs/automation-recipes.md[92]

+        command: TOKEN=xyz DEBUG=true uv tool run --from bandit bandit -r .
Relevance

●●● Strong

Documented recipe is demonstrably unusable with the runner’s non-shell subprocess execution.

PR-#919

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The command string is passed through unchanged and run without a shell.

webhook_server/utils/helpers.py[329-352]
webhook_server/libs/handlers/runner_handler.py[305-317]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The recipe's inline env assignments break when the command runs, because no shell is used.

## Fix Focus Areas
- docs/automation-recipes.md[92-96]

## Recommended Fix
Change the command to `env TOKEN=xyz DEBUG=true uv tool run --from bandit bandit -r .`. Reword the bullet to say assignments are skipped only during the startup program-exists check. Then regenerate the site.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Custom checks using shell syntax fail ✗ Dismissed
Description
The guide says custom check commands run through /bin/sh -c, so pipes, subshells and inline env
vars work, but run_command splits them with shlex.split and calls create_subprocess_exec
without a shell. A maintainer who writes a check with |, $(...) or VAR=value cmd gets a
failing or misbehaving check on every PR.
Code

docs/set-up-checks-and-release-workflows.md[161]

+Custom commands run through `/bin/sh -c` with the worktree as the working directory, so pipes, subshells, and inline environment variables work. `{worktree_path}` is not needed for custom checks, though it is substituted if present. The check run title is `Custom Check: <name>`.
Relevance

●●● Strong

Guide promises shell features that the actual subprocess runner cannot execute.

PR-#919

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The runner passes the command string to run_command, which splits it with shlex.split and runs the
tokens with create_subprocess_exec, explicitly not shell=True.

webhook_server/libs/handlers/runner_handler.py[305-317]
webhook_server/utils/helpers.py[329-352]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The docs claim custom checks run via `/bin/sh -c`, but `run_command` uses `shlex.split` and `create_subprocess_exec` with no shell.

## Fix Focus Areas
- docs/set-up-checks-and-release-workflows.md[161-161]

## Recommended Fix
Say that commands run without a shell. Explain that shell syntax needs an explicit wrapper such as `sh -c '...'`, and inline env vars need `env VAR=value cmd`. Then regenerate the HTML and llms files.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Docs visitors are redirected off-site ✓ Resolved
Description
_sanitize_html() removes several active HTML elements but leaves Markdown-supplied `<meta
http-equiv="refresh">` elements intact. When such Markdown is published, the page template emits the
element as HTML and a visitor's browser follows its off-site redirect.
Code

scripts/docs_render/renderer.py[R82-84]

+    # Remove iframe, object, embed, form tags
+    for tag in ["iframe", "object", "embed", "form"]:
+        html = re.sub(rf"<{tag}[^>]*>.*?</{tag}>", "", html, flags=re.DOTALL | re.IGNORECASE)
Relevance

●●● Strong

Unfiltered meta refresh enables an off-site redirect in published user-controlled documentation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The generator reads docs Markdown and passes it to the renderer; Python-Markdown permits raw HTML,
the sanitizer does not remove meta elements, and the template inserts the result unescaped.

scripts/generate_docs.py[106-108]
scripts/docs_render/renderer.py[81-95]
scripts/docs_render/renderer.py[379-392]
scripts/docs_render/templates/page.html[8-14]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new sanitizer preserves Markdown-supplied meta-refresh elements, allowing a generated documentation page to redirect visitors.
## Fix Focus Areas
- scripts/docs_render/renderer.py[77-95]
- scripts/docs_render/test_renderer.py[72-95]
## Recommended Fix
Remove meta elements from rendered Markdown with an HTML-aware sanitizer, and add a regression test for a meta-refresh element.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Malformed logs bypass documented errors ✓ Resolved
Description
docs/log-viewer-and-mcp-api.md says malformed JSON workflow summaries fail fast with `500
Malformed log entry. When that JSON parser raises the error, get_workflow_steps()` catches it and
tries text logs instead, so the request can return reconstructed data or a different error.
Code

docs/log-viewer-and-mcp-api.md[272]

+`pr` is `null` when the delivery had no pull request. `token_spend` is only present when the delivery recorded one. Malformed JSON summaries fail fast rather than returning partial data — a missing `timing`, a missing `timing.started_at`/`timing.duration_ms`, a missing `workflow_steps`, a non-dict `pr`, or a step without a `timestamp` all produce `500 Malformed log entry`.
Relevance

●●● Strong

Documentation promises fail-fast errors contradicted by the public method’s fallback behavior.

PR-#840

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The JSON parser raises a 500 for malformed entries, but the public workflow-steps method catches
HTTPException from that parser before reading text logs.

docs/log-viewer-and-mcp-api.md[240-242]
docs/log-viewer-and-mcp-api.md[272-276]
webhook_server/web/log_viewer.py[713-718]
webhook_server/web/log_viewer.py[884-895]
webhook_server/web/log_viewer.py[899-923]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The log API guide promises an immediate 500 for malformed JSON summaries, but the endpoint catches that error and falls back to text logs.
## Fix Focus Areas
- docs/log-viewer-and-mcp-api.md[240-276]
## Recommended Fix
Explain that malformed JSON triggers the text-log fallback, which may return reconstructed data or a later error; update the generated documentation outputs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Fresh environment setup fails at startup ✓ Resolved
Description
The new WEBHOOK_SERVER_DATA_DIR example creates an empty directory and immediately runs
entrypoint.py without creating config.yaml. Following it with a fresh directory raises
FileNotFoundError during the entrypoint's configuration initialization, before the server starts.
Code

docs/environment-variables.md[R29-33]

+```bash
+export WEBHOOK_SERVER_DATA_DIR="$HOME/webhook-server-data"
+mkdir -p "$WEBHOOK_SERVER_DATA_DIR"
+WEBHOOK_SERVER_DATA_DIR="$WEBHOOK_SERVER_DATA_DIR" uv run entrypoint.py
+```
Relevance

●●● Strong

Fresh-start documentation that crashes before startup is a concrete correctness defect, matching
this PR’s adjacent documentation fixes.

PR-#1152

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
entrypoint.py constructs Config at import time; Config.exists() raises if the data directory
lacks config.yaml. The new example creates only the directory.

docs/environment-variables.md[21-33]
entrypoint.py[10-15]
webhook_server/libs/config.py[17-30]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The environment-variable setup example launches the server with an empty data directory, although startup requires config.yaml there.
## Fix Focus Areas
- docs/environment-variables.md[21-33]
## Recommended Fix
Create or copy a valid config.yaml in the example before invoking entrypoint.py, or explicitly refer readers to the prerequisite configuration steps.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. Tilde code gains blank lines ✓ Resolved
Description
_ensure_blank_lines() tracks only backtick fences, although the Markdown converter also accepts
tilde-delimited fences. When a tilde code sample contains a list item or blockquote after another
nonblank line, the preprocessing inserts an unintended blank line inside the displayed code.
Code

scripts/docs_render/renderer.py[R315-326]

+        if stripped.startswith("```"):
+            backtick_count = len(stripped) - len(stripped.lstrip("`"))
+            rest = stripped[backtick_count:].strip()
+            if fence_depth == 0:
+                # Opening a new fence
+                fence_depth = 1
+                opening_fence_len = backtick_count
+            elif backtick_count >= opening_fence_len and not rest:
+                # Closing the current fence (same or more backticks, nothing after)
+                fence_depth = 0
+                opening_fence_len = 0
+            # else: inner fence marker inside outer block, ignore
Relevance

●●● Strong

Unrecognized tilde fences cause deterministic corruption of rendered code examples; this is an
obvious local parser bug.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The converter enables the fenced_code extension, but preprocessing enters fence state only for
lines beginning with three backticks. Outside that state, list and blockquote markers cause inserted
blank lines, including when they are code inside a tilde fence.

scripts/docs_render/renderer.py[307-350]
scripts/docs_render/renderer.py[354-368]
scripts/generate_docs.py[60-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Blank-line preprocessing changes the contents of valid tilde-delimited code blocks because it does not recognize them as fences.
## Fix Focus Areas
- scripts/docs_render/renderer.py[307-350]
- scripts/docs_render/test_renderer.py[100-113]
## Recommended Fix
Track opening and closing tilde fences as well as backtick fences, and test that list and blockquote markers inside either fence remain unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Contributors cannot run required lint ✓ Resolved
Description
AGENTS.md makes bare prek run --all-files the required lint and verification command, but the
documented uv sync setup does not install prek. On a fresh checkout without a separate global
installation, contributors cannot run the gate the instructions require.
Code

AGENTS.md[R10-11]

+- Lint + static checks: `prek run --all-files` (covers ruff, ruff-format, mypy, flake8, gitleaks, detect-secrets, eslint — the only supported way to run them)
+- Full verify: `prek run --all-files && uv run --group tests pytest -n auto`
Relevance

●●● Strong

Accepted setup-command corrections previously in AGENTS.md; missing prek installation is a concrete
contributor-blocking defect.

PR-#1145

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The setup lists only uv sync before the bare prek command, while neither the project
dependencies nor the dev dependency group declares prek. The repository's check runner invokes it
through uvx instead.

AGENTS.md[4-17]
pyproject.toml[45-85]
pyproject.toml[108-119]
docs/set-up-checks-and-release-workflows.md[34-40]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The required lint command depends on an executable absent from the documented setup and declared dependencies.
## Fix Focus Areas
- AGENTS.md[5-17]
## Recommended Fix
Invoke prek through `uvx` in the documented commands, or install it as part of the documented setup and declare how its version is managed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 13 rules
✅ Cross-repo context — repo relationships
  Explored: repo: myk-org/docsfy (sha: 8a581ae2) — View relationship
  Explored: repo: myk-org/pi-config (sha: 59847735) — View relationship
Review mode: ⚖️ Balanced: This behavioral security-related change affects branch-protection requirements and error handling across multiple paths, warranting a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread webhook_server/utils/github_repository_settings.py Outdated
Comment thread webhook_server/utils/github_repository_settings.py Outdated
Comment thread webhook_server/utils/github_repository_settings.py Outdated
AGENTS.md documented `uv run mypy webhook_server/` as a Definition of Done
gate, but that invocation cannot pass. mypy is not a declared dependency in
pyproject.toml while the types-* stubs are, and the command runs over
webhook_server/tests/ which the prek mypy hook excludes. It reports 943
errors on an unmodified main, almost entirely import-not-found on missing
github and pytest stubs, so the gate was unsatisfiable and never measured
anything real.

`prek run --all-files` runs every hook in .pre-commit-config.yaml — ruff,
ruff-format, mypy, flake8, gitleaks, detect-secrets, eslint — in isolated
envs with their own declared dependencies. It is green on the current tree,
mypy included.

The [tool.mypy] block in pyproject.toml is unchanged and prek\x27s hook still
reads it, so strictness is preserved; only the way it is invoked is fixed.

Assisted-by: PI (gpt-6-luna) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit cc09d4d

get_required_status_checks() built the required-context list sent to GitHub
branch protection but never read the security-checks config, so
security-suspicious-paths and security-committer-identity were never marked
required. The server-side gate in all_required_status_checks() already
honoured them, so merge was only blocked transitively via can-be-merged and
the GitHub UI showed both checks as optional.

security-checks is read through the repository-scoped Config rather than the
raw repositories entry, so repo-local .github-webhook-server.yaml overrides
win. A repo setting security-checks.mandatory: false locally would otherwise
still have its checks marked required.

The security check names are also applied when protected-branches sets
include-runs, which previously short-circuited past them entirely.

Closes #1231

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Eight documents had been reduced to a "Documentation generation failed"
placeholder. Recreated them from the codebase: automation-recipes,
environment-variables, log-viewer-and-mcp-api, run-pull-request-commands,
set-up-checks-and-release-workflows, set-up-owners-and-reviewers,
supported-github-events, webhook-and-health-api.

Also repaired pre-existing corruption present on main since 9b6469b:
- four files were missing the opening fence on their first code block, which
  shifted every later block and rendered pages as run-on text
- the same four had no H1, so pages opened on a bare "config.yaml" line
- configuration-reference.md was byte-duplicated; now 595 lines, one copy
- docs cited port 500; the real default is 5000

The protected-branches.include-runs row now states that security checks
governed by security-checks.mandatory are added on top of the explicit list,
which is what the code now does.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
docs/*.html, search-index.json, llms.txt and llms-full.txt are generated
from docs/*.md, but the generator was external and the committed HTML had
drifted badly out of sync with the markdown — eight pages rendered empty.

Vendor the deterministic rendering half of myk-org/docsfy into
scripts/docs_render/ so the site can be rebuilt from this repo alone, and
add scripts/generate_docs.py as the entry point. Rendering is idempotent and
never writes a markdown file.

Local divergence from upstream: _indent_fenced_blocks indents fenced code
bodies in llms-full.txt only, so that YAML comments such as "# config.yaml"
are not mistaken for headings when an LLM reads the file as plain text. The
HTML path is byte-identical to upstream.

Adds markdown and jinja2 to the dev group; pygments and pyyaml were already
present.

Two supporting changes so the vendored package passes the existing gate:
[tool.mypy] namespace_packages = false stops mypy mapping
scripts/docs_render/renderer.py under both docs_render and scripts.docs_render,
and types-Markdown joins the prek mypy hook's additional_dependencies alongside
the other stub packages.

AGENTS.md gains a Documentation section covering the markdown-only source of
truth, the rebuild command, and the structural rules the renderer depends on.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Rebuilt with scripts/generate_docs.py. All fifteen pages now render from the
corrected markdown; the eight previously empty pages have real content, and
configuration-reference.html drops from 146KB to 83KB with the duplicated
copy gone.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
The process_webhook docstring claimed HTTPException 401 for a failed
signature check, but app_utils.py raises 403 in both paths (missing
X-Hub-Signature-256 header, and HMAC mismatch). The behaviour was correct;
only the documented status was wrong.

/automerge ignored the `remove` flag that every other label command honours,
so `/automerge cancel` added the automerge label instead of removing it. It
now calls _remove_label when cancel is given, matching the verified pattern.
The maintainer/approver permission check still gates both add and remove.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
scripts/docs_render/renderer.py enables markdown's codehilite extension,
which imports pygments at render time. Pygments was never declared; it
reached the environment transitively through ipython (dev group), pytest
(tests group) and rich. A production or --no-dev install would fail to
render the documentation site.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
set_repository() passed exclude_status_checks only to
get_required_status_checks(). When protected-branches set include-runs, that
call was skipped entirely, so exclude-runs was silently discarded and the
exclusions never reached GitHub. This affected every check, not just the
security ones, and examples/config.yaml shipped exactly the pattern that
does not work.

exclude-runs is now subtracted from the assembled list on the include-runs
path as well, after the security checks are added. The auto path already
subtracted inside get_required_status_checks() and is unchanged, so the
exclusion is applied exactly once per branch.

An explicit exclude-runs entry is honoured even for the security checks;
security-checks.mandatory remains the switch for not requiring them at all.

Verified against the real set_repository() call: include [a,b] with exclude
[b] previously yielded [a,b] and now yields [a] plus the security checks.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Comment thread scripts/docs_render/renderer.py Outdated
Comment thread scripts/generate_docs.py Outdated
Comment thread scripts/docs_render/renderer.py Outdated
Comment thread scripts/generate_docs.py Outdated
Comment thread scripts/docs_render/renderer.py Outdated
Comment thread docs/supported-github-events.md Outdated
Comment thread docs/set-up-checks-and-release-workflows.md Outdated
Comment thread scripts/generate_docs.py
Comment thread webhook_server/libs/handlers/issue_comment_handler.py
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c78d654

Three defects in the security-checks startup path, found by Qodo review.

The warning branches read repo.name from a live PyGithub Repository. Those
warnings only fire on malformed config, so any API access was unretried and
outside the github_api_call wrapper the project requires. The helper now takes
a plain repository identifier string.

get_security_status_checks() called config.get_value() with no extra_dict.
Config.repository_data is the config.yaml repository entry, not the repo-local
.github-webhook-server.yaml — the local file is only read via
repository_local_data(), which needs a GitHub API and previously ran solely at
webhook time. Startup branch protection therefore never saw the local file, so
a repo disabling a check there had it written into branch protection anyway
while webhook processing treated it as advisory, leaving the PR mergeable in one
place and blocked in the other. The local config is now fetched during settings
synchronisation and passed as extra_dict, and invalid YAML is logged and ignored
rather than aborting startup.

suspicious-paths was tested for truthiness without normalising, so a
schema-valid [""] added security-suspicious-paths to the required contexts.
github_api.py strips blanks, so the runner saw an empty list and never reported
the check, leaving the PR pending forever. Both places now normalise
identically.

Closes the findings from Qodo on #1232.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
@myakove

myakove commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

/qodo

The following review comments were reviewed and a decision was made:

webhook_server/utils/github_repository_settings.py:163 (qodo bug) — Local overrides leave merges blocked

Addressed: Fixed in 7608205 — this was correct and the root cause is what you identified. Config.repository_data is root_data["repositories"][repo], the config.yaml entry, not the repo-local file; the local file is only read by repository_local_data(), which needs a GitHub API and previously ran only at webhook time (github_api.py:189). set_repository() now fetches the local config during settings synchronisation and passes it as extra_dict to config.get_value("security-checks", ...), so Local → Repo → Global resolution holds at startup too. A yaml.YAMLError in the local file is logged and ignored rather than aborting startup. Tests cover local mandatory: false yielding no required checks, local mandatory: true yielding both, and no local file falling back to the config.yaml value.

webhook_server/utils/github_repository_settings.py:166 (qodo rule violation) — Security warnings skip GitHub API retries

Addressed: Fixed in 7608205. get_security_status_checks() no longer takes a PyGithub Repository; it takes a repository_full_name: str and uses that in every warning, so no live API property is read on the malformed-config path. The pre-existing repo.name in the .pre-commit-config.yaml exception handler was left untouched, as it is outside this change.

webhook_server/utils/github_repository_settings.py:188 (qodo bug) — Blank paths leave merges pending

Addressed: Fixed in 7608205. get_security_status_checks() now normalises suspicious-paths exactly as github_api.py does — str(p).strip() filtered on a non-blank result — before the truthiness test, so a blank-only list adds no required context and a list like [" ", "x/"] still adds security-suspicious-paths. Both sites carry a cross-reference comment so they cannot drift again. Tests added for both directions.


Assisted-by: PI (stealth/space-bunny-alpha)

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ad7eb05

These docs told maintainers things the code does not do. Each one misleads
in a way that costs real time when followed literally.

- `prek` and `tox` were documented as bare commands but are not declared
  dependencies, so the Definition of Done was unrunnable on a fresh checkout;
  both now go through `uvx`
- the WEBHOOK_SERVER_DATA_DIR example created an empty directory and ran
  entrypoint.py, which raises FileNotFoundError without config.yaml
- the security checks are active by default (mandatory and
  committer-identity-check both default true); the docs said opt-in, so
  maintainers would wrongly believe their checks were inactive
- /logs/api/* routes are registered unconditionally and gated per request
  with a 404, not registration-gated; the "exactly two routes" claim
  contradicted the rest of the same file
- an approver is only required when the OWNERS path configures at least one

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Three renderer functions tracked fenced code blocks by looking only for
backtick fences, but the Markdown converter also accepts ~~~ fences. A
tilde block containing a list item or blockquote had a blank line
inserted into the displayed code by _ensure_blank_lines(), and its
contents escaped indentation in _indent_fenced_blocks(), which builds
llms-full.txt — a YAML # comment inside a ~~~ block read as a heading.

A shared _FENCE_RE and _fence_marker() helper now recognises both
characters. A fence closes only on a run of the same character that is at
least as long as the opening one, so ~~~ can no longer close a ``` block
or the reverse. Backtick behaviour is unchanged.

Verified: 1840 passed, coverage 90.35%, uvx tox both envs OK, prek clean.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: OPENAI <noreply@openai.com>
Regenerated HTML, search index and llms-full.txt after the source
corrections in 2c33325. Verified idempotent: a second generator run
produces no further diff, so these artifacts are in sync with
docs/*.md and not carrying unrelated drift.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Comment thread AGENTS.md Outdated
Comment thread scripts/docs_render/renderer.py Outdated
Comment thread docs/log-viewer-and-mcp-api.md Outdated
Comment thread docs/set-up-checks-and-release-workflows.md
Comment thread docs/automation-recipes.md
Comment thread docs/run-pull-request-commands.md Outdated
Comment thread scripts/generate_docs.py Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9891e16

The vendored docsfy sanitizer stripped dangerous HTML with regexes, and
four separate bypasses walked through it: <base href>, a </script >
with a space before the closing bracket, an unclosed <iframe srcdoc>,
and md.toc being returned unsanitised while the template injected it
with |safe. Patching the regexes was not converging, so the mechanism
changed.

A subclass of stdlib html.parser.HTMLParser now re-serialises the
document against an explicit allowlist. Attributes are deny-by-default,
which covers on* handlers plus style/srcdoc/formaction without an
enumeration that could miss a variant. URLs are restricted to http,
https, mailto, fragments and absolute paths.

Tag handling is three-way, and getting this wrong is what made the
first attempt unusable:

- raw-text carriers (script, style, iframe, object, svg, ...) drop
  their contents as well as the tag
- known dangerous void tags (base, embed, link, meta, input) drop
  silently — printing an escaped <meta http-equiv=refresh> into a
  published page as literal prose helps nobody
- genuinely unknown tags are re-emitted as escaped text, so a
  placeholder like "No <name> configured" still reads as a placeholder

The middle rule is the fix for a regression the first attempt caused:
dropping the CONTENTS of every unknown tag deleted 155 lines of real
documentation from run-pull-request-commands, because python-markdown
passes raw HTML through and "<name>" in prose parses as an unrecognised
start tag. The drop-contents rule is now limited to elements that
actually hold code or markup.

md.toc is sanitised like the body, closing the fourth bypass.

Verified: 1897 passed, coverage 90.35%, uvx tox both envs OK, prek
clean, and the regenerated page is confirmed to keep its table and its
full prose.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Upstream docsfy excludes its generation-failure stub page from
llms.txt and llms-full.txt so a failed generation is never published as
if it were real documentation. That filter was lost when the renderer
and generator were vendored, so a stub brought into docs/ would be
presented to AI readers as documentation.

Both index builders now filter through a single predicate that reuses
the renderer's existing _FAILURE_STUB_RE rather than keeping a second
copy of the pattern that could drift.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Two round 7 accuracy fixes.

/test-oracle was documented as always running on draft PRs. It is not.
GithubWebhook.process returns early for a draft whenever
allow-commands-on-draft-prs is absent, so the webhook is dropped before
any command is evaluated and the per-command exemption in
user_commands is never reached. The exception only applies when the key
is set, even to an empty list.

The AGENTS.md preview command used bare python3, which contradicts the
rule the same file states two sections earlier: Python runs through uv.
A contributor following it needed a separate Python on PATH.

Regenerated the site output for the changed page. Verified idempotent.

One Qodo finding from this round was checked and REJECTED as a false
positive rather than actioned: it claimed custom check commands do not
run through a shell, but runner_handler.py wraps them in /bin/sh -c via
shlex.quote() before run_command splits them, added deliberately in
a05ed3b. The existing documentation was correct.

Verified: 1897 passed, coverage 90.35%, uvx tox both envs OK, prek clean.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
The log API guide claimed malformed JSON workflow summaries fail fast
with 500 Malformed log entry. They do not, and there are three distinct
behaviours rather than one:

- A summary line that is not valid JSON never reaches the error path.
  get_raw_json_entry() catches JSONDecodeError and returns None, so a
  corrupt line is silently indistinguishable from a missing one.
- A structurally invalid summary does raise 500 Malformed log entry in
  get_workflow_steps_json(), but get_workflow_steps() catches HTTPException
  without qualification, so that 500 is swallowed and the request falls
  through to text-log parsing. The client gets a reconstructed timeline,
  or a 404 if the text logs do not cover the delivery.
- 500 Malformed log entry is only reachable through the step-logs
  endpoint, which calls get_workflow_steps_json() directly.

The bare except HTTPException is what makes the documented fail-fast
unreachable. It is doing double duty: a 404 "no JSON summary, try the
legacy path" is reasonable backward compatibility, but absorbing a 500
"this entry is corrupt" into the same handler is a separate question
this change deliberately does not answer. Only the documentation is
corrected here.

Verified: 1897 passed, coverage 90.35%, uvx tox both envs OK, prek clean.

Assisted-by: PI (Space Bunny) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
Comment thread webhook_server/utils/github_repository_settings.py
Comment thread scripts/docs_render/renderer.py Outdated
Comment thread docs/run-pull-request-commands.md
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 0e9854f

… report

A malformed repository-local .github-webhook-server.yaml was logged and
ignored, and startup fell back to the security-checks defaults. That made
branch protection require security-suspicious-paths and
security-committer-identity.

The runtime path reads the same file with raise_on_error=True and
propagates the error, so neither check is ever reported. The repository
was left requiring two checks that could never pass, making every pull
request permanently unmergeable with no visible cause.

A parse failure is not "no config", it is UNKNOWN, and it is now treated
as such: the error is logged at ERROR with the repository name and the
underlying exception, and branch protection is left untouched rather than
written from defaults nobody can trust. Startup still does not abort, and
labels and other repository settings are still applied.

When the config parses, behaviour is unchanged - mandatory and
committer-identity-check both still default to true.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
_is_safe_url() rejected protocol-relative URLs only when the value
literally started with "//". Browsers normalise backslashes to forward
slashes while parsing an authority, so \\evil.example, /\evil.example and
/\/evil.example navigate off-site exactly like //evil.example, but none
of them start with a literal "//" and all of them passed.

Backslashes are now folded to forward slashes before the policy checks.
The order is fixed and documented: HTML-entity unescape first (so
&sol;&sol;host is judged as the //host it is), then noise stripping, then
the backslash fold, then the checks. Doing it in any other order lets a
value change what it looks like after the check that should have
rejected it.

Relative links the docs depend on - quick-start.html, ../README.md and
/assets/... - still pass.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
The guide showed allow-commands-on-draft-prs in a repository-local
.github-webhook-server.yaml. Both sites that read it - GithubWebhook.process
in github_api.py and user_commands in issue_comment_handler.py - call
config.get_value() without an extra_dict, and get_value() consults
repository-local data only when extra_dict is passed. A repository-local
value was silently ignored and draft PRs stayed blocked.

The guide now shows the key in config.yaml, globally or under
repositories.<repo>, and states plainly that a repository-local copy has
no effect. Both schema locations were checked: root and repositories.<repo>
are both reachable through get_value's repository_data/root_data fallback.
Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>

Signed-off-by: Meni Yakove <myakove@gmail.com>
Comment thread webhook_server/utils/github_repository_settings.py Outdated
Comment thread webhook_server/utils/github_repository_settings.py Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit ea4ac0f

exclude-runs was removing security status contexts from the required
branch-protection list, in both get_required_status_checks() and the
include-runs path in set_repository(). The runtime never applies
exclude-runs to security contexts - the string does not appear in
github_api.py at all - so the two paths disagreed about the same
configuration:

- branch protection stopped requiring the check, so GitHub treated it
  as optional
- the pull-request handler still queued it
- can-be-merged still required it

The required-check list now excludes only non-security checks, and the
security contexts are appended afterwards. security-checks.mandatory:
false remains the supported way to turn them off, since
get_security_status_checks() already returns an empty list there and
cannot re-add anything.

This also corrects an earlier decision recorded in the code as
deliberate ("exclude-runs wins over include-runs, security checks
included"). It was never verified against the runtime, and it made
branch protection report a weaker guarantee than the server actually
enforces.

Also switches the invalid-repo-local-YAML log to LOGGER.exception so
the traceback is recorded, per the project's logging convention, and
drops the now-unused exception binding.

Verified: 1936 passed, coverage 90.35%, uvx tox both envs OK, prek clean.

Assisted-by: PI (stealth/space-bunny-alpha) <noreply@pi.dev>
Signed-off-by: Meni Yakove <myakove@gmail.com>
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 4093c37

@myakove
myakove merged commit 3ca3ec1 into main Sep 29, 2026
8 of 10 checks passed
@myakove
myakove deleted the fix/issue-1231-security-checks-branch-protection branch September 29, 2026 19:59
@myakove-bot

Copy link
Copy Markdown
Collaborator

New container for ghcr.io/myk-org/github-webhook-server:latest published

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(branch-protection): security check runs are never added to required status checks

2 participants