Skip to content

feat: source-side Strix → appguardrail per-finding issue emitter#358

Open
seonghobae wants to merge 5 commits into
mainfrom
feat/strix-appguardrail-emit
Open

feat: source-side Strix → appguardrail per-finding issue emitter#358
seonghobae wants to merge 5 commits into
mainfrom
feat/strix-appguardrail-emit

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

Summary

Replaces the coarse, never-provisioned per-failed-run collector with a
source-side, per-finding emitter. The central Strix workflow now turns each
strix_runs/<run>/vulnerabilities/*.md report into a deduplicated,
lifecycle-managed issue in ContextualWisdomLab/appguardrail.

  • Parser mirrors the existing Strix report fields (Model, Title, Severity,
    CVSS + vector, Target, Endpoint, Method, Description, Impact, Code Locations
    path:line[-range], Remediation); tolerant of plain and **bold** field
    styles; /workspace/<repo>/ and PR-scope prefixes normalized.
  • Dedup key sha256(source_repo + "\n" + title + "\n" + normalized_location),
    stored in a hidden <!-- strix-finding: <hash> --> body marker and a
    strix-finding:<shorthash> label. Existing open/closed issues are looked up by
    hash before creating.
  • Lifecycle: create one issue per finding; update the body on refresh;
    comment on severity change; reopen returning findings; close-on-fix by set
    difference between the current run's finding hashes and open scoped issues.
  • Close-on-fix guard: only runs when the scan completed cleanly
    (steps.run_strix.outcome == 'success'). A failed/skipped/infra-broken scan
    never closes issues.
  • Auth / DRY-RUN: token minted via actions/create-github-app-token
    (Issues: write on appguardrail). When the App/secrets are absent, the emitter
    runs DRY-RUN — logs every intended create/update/close, mutates nothing, exits
    0 — so the Strix pass/fail gate is unchanged. --dry-run forces this. Token
    shapes are scrubbed from logs.
  • Workflow steps are if: always() + continue-on-error; least-privilege token
    scoped to appguardrail only.

Tests

  • tests/test_strix_emit_appguardrail_issues.py: synthetic multi-finding
    fixtures (varying severity, a no-location finding), stable dedup hash,
    create/update/close op planning in dry-run (mocked GitHub API), close-on-fix
    set-difference logic, and the incomplete-scan guard (no closes). 39 tests
    pass, 100% coverage
    of the new module; interrogate clean. Workflow YAML and
    the embedded emit step pass bash -n.

⚠️ Provisioning required (manual, one-time)

The emitter stays in DRY-RUN until a human provisions:

  • GitHub App in ContextualWisdomLab (suggested strix-issue-emitter) with
    Repository → Issues: Read and write, installed on (at least) the
    appguardrail repo.
  • Secrets at the org (or .github) level:
    • STRIX_ISSUE_APP_ID — the App's numeric App ID
    • STRIX_ISSUE_APP_PRIVATE_KEY — the App's private-key .pem contents

Full steps in docs/strix-appguardrail-issues.md.

Follow-up in another repo

ContextualWisdomLab/appguardrail's .github/workflows/org-security-failure-collector.yml
should be disabled/removed once this lands to avoid duplicate issues (cannot
be changed from this repo). Its ORG_SECURITY_FAILURE_APP_ID/_PRIVATE_KEY
secrets can be retired.

🤖 Generated with Claude Code

seonghobae and others added 2 commits July 8, 2026 10:48
Add a source-side emitter that turns Strix vulnerability reports
(strix_runs/<run>/vulnerabilities/*.md) into deduplicated, lifecycle-managed
issues in ContextualWisdomLab/appguardrail, replacing the coarse per-failed-run
polling collector.

- scripts/ci/strix_emit_appguardrail_issues.py: parse each finding into a
  normalized record; dedup by sha256(source_repo, title, normalized_location)
  stored in a hidden body marker + strix-finding:<shorthash> label; create one
  issue per finding, update the body on refresh, comment on severity change,
  reopen returning findings; close-on-fix via set difference guarded to run only
  on a clean/complete scan. App token from actions/create-github-app-token; when
  absent it runs DRY-RUN (logs intended ops, never fails the Strix job). Token
  shapes are scrubbed from logs.
- .github/workflows/strix.yml: final best-effort steps (if: always()) mint the
  app token (owner ContextualWisdomLab, repositories: appguardrail) and run the
  emitter; continue-on-error so the pass/fail gate is unchanged. scan-complete is
  gated on the Strix step succeeding.
- tests/test_strix_emit_appguardrail_issues.py: synthetic fixtures cover parsing
  (plain/bold, no-location), stable dedup hashing, create/update/close planning,
  close-on-fix set difference, the incomplete-scan guard, dry-run and live
  execution. 100% coverage of the new module; interrogate clean.
- docs/strix-appguardrail-issues.md: mechanism, dedup/lifecycle, the App +
  secrets to provision, and retirement of appguardrail's old collector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P
Close-on-fix did a repo-wide set difference: it closed every open Strix
issue for repo:<name> whose finding-hash was absent from the current run,
guarded only by scan completion (run_strix.outcome == 'success'). But the
normal Strix trigger (pull_request_target, and workflow_dispatch+pr_number)
is PR-scoped (strix.yml __PR_SCOPE__ / STRIX_DISABLE_PR_SCOPING=0 /
IS_PR_EVIDENCE_RUN=true) and only scans the PR's changed files. A cleanly
completed PR-scoped scan therefore closed still-valid open findings in files
the PR never touched -- a clean PR with zero findings closed ALL open Strix
issues for that repo. Data loss.

Fix: plumb a scan-scope flag from strix.yml into the emitter and require BOTH
guards to close. Close-on-fix now runs only when the scan completed cleanly
AND covered the whole repo (scope=full).

- emitter: add SCOPE_FULL/SCOPE_PR, EmitContext.scan_scope (default pr =
  safe) and EmitContext.close_on_fix_enabled (scan_complete AND scope==full);
  gate the close-on-fix block on it; add --scope {full,pr} (default pr).
  PR/unknown scope still creates/updates/reopens findings, never closes.
- strix.yml: emit STRIX_SCAN_SCOPE mirroring the PR-scoping decision
  (pull_request_target || inputs.pr_number => pr, else full) and pass
  --scope; unknown/unset defaults to the safe pr.
- tests: rename the test that blessed the unsafe semantic to assert
  close-on-fix happens ONLY for full scope; add PR-scoped (zero + subset) and
  unknown-scope tests asserting nothing closes; add CLI full/pr close tests.
  100% line + docstring coverage retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P
@seonghobae

Copy link
Copy Markdown
Contributor Author

CRITICAL data-loss fix pushed (8987a3d)

Bug (confirmed): close-on-fix in scripts/ci/strix_emit_appguardrail_issues.py did a repo-wide set difference — it closed every open strix + repo:<name> issue whose finding-hash was absent from the current run — gated only by scan completion (run_strix.outcome == 'success'). But the normal Strix trigger (pull_request_target, and workflow_dispatch + pr_number) is PR-scoped: strix.yml sets __PR_SCOPE__ / STRIX_DISABLE_PR_SCOPING=0 / IS_PR_EVIDENCE_RUN=true and only scans the PR's changed files. So a cleanly-completed PR-scoped scan closed still-valid open findings in files the PR never touched — a clean PR with zero findings closed ALL open Strix issues for that repo. Data loss.

Fix: close-on-fix now requires both guards — the scan completed cleanly and it covered the whole repo.

  • Emitter: added SCOPE_FULL/SCOPE_PR, EmitContext.scan_scope (default pr = safe), and EmitContext.close_on_fix_enabled (scan_complete and scan_scope == SCOPE_FULL). The close-on-fix block is gated on that property. New --scope {full,pr} arg (default pr). PR-scoped or unknown-scope runs still create / update / reopen findings but never close — absence just means "outside this PR", not "fixed".
  • strix.yml: emits STRIX_SCAN_SCOPE mirroring the existing PR-scoping decision — (pull_request_target || inputs.pr_number) ? 'pr' : 'full' — and passes --scope. The push/schedule full-repo path is the only one that sends full. Unknown/unset defaults to the safe pr.
  • Tests: test_close_on_fix_closes_missing_open_issue_when_scan_complete..._only_for_full_scope (asserts close only at full scope); added test_pr_scoped_complete_scan_closes_nothing (zero + subset findings close nothing), test_unknown_scope_complete_scan_closes_nothing, and CLI-level full/pr close tests.

Verification: 43/43 emitter tests pass, 100% line coverage, 100% interrogate docstring coverage, dry-run smoke confirms full scope plans a close-eligible reconciliation while PR scope prints "close-on-fix is disabled" and closes nothing. (The 3 failing test_noema_review_gate / test_opencode_agent_contract tests are pre-existing and unrelated.)

CodeGraph (code-review-graph, full build: 38 files / 866 nodes) confirmed the blast radius — plan_operations and EmitContext are consumed only by run (CLI) and the make_context test helper, so the scope flag was wired through exactly those surfaces.

Emit per-finding Strix issues to appguardrail using the OpenCode GitHub
App token the job already OIDC-exchanges (steps.target_app_token),
instead of minting a token via actions/create-github-app-token from
to-be-provisioned STRIX_ISSUE_APP_ID / STRIX_ISSUE_APP_PRIVATE_KEY org
secrets.

- Remove the "Mint appguardrail issue app token" step and the new-secret
  dependency entirely -- no new GitHub App or org secret to provision.
- Feed STRIX_ISSUE_APP_TOKEN from the existing OpenCode-app OIDC exchange
  the workflow already runs for cross-repo reads/status.
- Emitter is unchanged and stays best-effort: if the OpenCode token is
  absent (OIDC unavailable) or lacks Issues: write on appguardrail, it
  degrades to DRY-RUN / swallows the write and never fails the gate.
- Per-finding parse, dedup hash, and close-on-fix full-repo scope guard
  are preserved unchanged.
- docs/strix-appguardrail-issues.md documents the one-time OpenCode App
  enablement (grant Issues: Read and write, install on appguardrail) with
  exact click-paths; no new App/secret creation.

Emitter tests: 43 passed, 100% coverage of the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P
@seonghobae

Copy link
Copy Markdown
Contributor Author

Reworked: no new GitHub App / secrets — reuse the OpenCode app token (097f418)

The emitter no longer mints a token via actions/create-github-app-token from to-be-provisioned STRIX_ISSUE_APP_ID / STRIX_ISSUE_APP_PRIVATE_KEY. Those secrets and the "Mint appguardrail issue app token" step are removed.

Instead the emit step reuses the existing OpenCode GitHub App token the job already OIDC-exchanges (steps.target_app_token, the Exchange OpenCode app token for target repository reads step — POST https://api.opencode.ai/exchange_github_app_token) and already uses for cross-repo reads/status:

env:
  STRIX_ISSUE_APP_TOKEN: ${{ steps.target_app_token.outputs.token || '' }}

No new App and no new org secret to provision.

Still best-effort / never fails the gate. The emitter is unchanged and auth-source-agnostic. If the OpenCode token is absent (OIDC unavailable) or lacks Issues: write on appguardrail, it degrades to DRY-RUN (a failed issue-list read plans in DRY-RUN; any rejected write is logged and swallowed). The Strix pass/fail gate is untouched. The per-finding parser, dedup hash, and the full-repo-scope close-on-fix guard (8987a3d) are all preserved unchanged.

One tiny manual enablement (no App/secret creation) — the emitter writes real issues once the existing OpenCode App can write issues to appguardrail. Verify once (exact click-paths in docs/strix-appguardrail-issues.md):

  1. Grant Issues: Read and write — org Settings → GitHub Apps → OpenCode → Permissions & events → Repository permissions → Issues → Read and write → Save.
  2. Ensure it's installed on appguardrail — org Settings → Installations → OpenCode → Repository access → confirm appguardrail is included → Save (accept the pending permission if prompted).

Until both hold, it stays DRY-RUN and only logs intended operations. No redeploy needed to flip to live.

Tests: tests/test_strix_emit_appguardrail_issues.py — 43 passed, 100% coverage of the emitter module. (The emitter module and its tests are unchanged; only the workflow auth wiring and docs changed.)

🤖 Generated with Claude Code

seonghobae and others added 2 commits July 8, 2026 22:16
…heme SSRF reject

The coverage-evidence gate runs the full pytest suite before measuring
coverage. Three assertions had drifted from the intentional source:

- opencode-review.yml grew the `if:` block between `opencode-review-target:`
  and `timeout-minutes: 360`, exceeding the 240-char regex window (widen to 400).
- pr-review-merge-scheduler.yml defaults `review_dispatch_limit` to "1", not "-1".
- noema rejects non-http(s) schemes with "URL scheme must be http or https".

Also fixes a real bug: uppercase-scheme URLs (HTTPS://) were wrongly rejected
by the case-sensitive startswith guard; lowercase before comparing so valid
HTTPS:// URLs pass while SSRF protection is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant