| name | fork |
| description | Stage a scanned repository into a private repo in the configured GitHub organisation. Creates the repo (no fork relationship), seeds it with the upstream tree, writes scrutineer metadata under the configured metadata directory, files one issue per open finding, and gives an org team push access. The staging repo is the per-project working surface for triage, fix development, and the disclosure paper trail. |
| license | MIT |
| compatibility | Needs the gh CLI authenticated with a token that can create private repos in `fork_org`, write to issues there, and manage team repo access. Needs network access to api.github.com and the scrutineer API. github.com upstreams only for now; other hosts will route through the same skill once `host__owner__repo` naming generalises. |
| metadata | {"scrutineer.version":1,"scrutineer.output_file":"report.json","scrutineer.output_kind":"freeform","scrutineer.requires_remote":true} |
fork
Stand up a private staging repo for one scanned upstream. The staging repo is a plain clone of the upstream tree (no GitHub fork relationship), lives in fork_org, and is the working surface for everything that follows: finding issues, PoC files, patch diffs, and validation reports all live here. Scrutineer's metadata sits at the repo root in the directory named by scrutineer.metadata_dir (default .scrutineer/). Run after a scan has produced findings; idempotent on re-runs.
Below, the placeholder {metadata_dir} stands for the value of scrutineer.metadata_dir from ./context.json. Substitute it verbatim before issuing any git or shell command.
Workspace
./src — the upstream clone at the commit that was scanned
./context.json — has repository.url, repository.full_name, repository.default_branch, scrutineer.api_base, scrutineer.token, scrutineer.repository_id, scrutineer.scan_id, scrutineer.fork_org, and scrutineer.metadata_dir
./report.json — write what you did
Use the gh CLI for every GitHub call. Do not use curl against api.github.com.
Preconditions
Read ./context.json. Refuse to continue (write {"error": "..."} to report.json and exit 0) if:
scrutineer.fork_org is missing or empty — the operator has not configured fork_org in scrutineer.yaml
repository.url does not have host github.com — other hosts get the same treatment but the host-prefix mapping (below) is hard-coded to gh for now
gh auth status fails — the runner has no GitHub credentials
Derive {owner}/{repo} from repository.full_name (fall back to parsing the path of repository.url, stripping a trailing .git).
1. Resolve the staging repo name
The staging repo lives at {fork_org}/{host}__{owner}__{repo}, lowercased:
host is gh for github.com
owner and repo come straight from the upstream URL
So https://github.com/madler/zlib becomes {fork_org}/gh__madler__zlib. Deterministic and collision-free across hosts, so no probing for free slots.
If scrutineer already knows the staging repo (GET {api_base}/repositories/{repository_id} has a non-empty fork), use it as {staging} and skip to step 3. The field is named fork for legacy reasons; semantically it is the staging repo URL.
2. Create and seed the staging repo
Check whether it already exists:
gh repo view {fork_org}/{host}__{owner}__{repo}
If view succeeds, record "created": "exists" and skip to step 3.
Otherwise create it:
gh repo create {fork_org}/{host}__{owner}__{repo} --private \
--description "scrutineer staging for {owner}/{repo}"
Then seed it from ./src. The goal is a default branch on the staging repo that contains the upstream tree at its full history (so VIDs, fingerprints, locations, and patches all resolve the same way they do on the upstream):
cd ./src
# Make sure history is complete enough to push. Shallow clones cannot be pushed.
git fetch --unshallow origin || true
git remote add scrutineer-staging https://github.com/{fork_org}/{host}__{owner}__{repo}.git
git push scrutineer-staging HEAD:{repository.default_branch}
If git fetch --unshallow fails (the clone was already complete) ignore it. If the push fails because of unshallow, record "error": "could not push full history" and exit 0 — half-seeded repos cause more confusion than they fix.
Record "created": "created" and persist the resolved name back to scrutineer so the next run skips the probe:
PATCH {api_base}/repositories/{repository_id}
Authorization: Bearer {token}
{"fork": "{fork_org}/{host}__{owner}__{repo}"}
3. Write the repo-level metadata
Build the per-repo metadata block:
upstream:
url: {repository.url}
host: github.com
owner: {owner}
repo: {repo}
seeded_at: {ISO-8601 timestamp, UTC}
seeded_commit: {git -C ./src rev-parse HEAD}
last_scan_at: {same timestamp, refreshed on every run}
last_scan_id: {scan_id}
last_scan_commit: {same as seeded_commit on first run; refresh on re-runs}
On re-runs only the last_scan_* fields change.
The simplest write path is one commit per skill run carrying every file under {metadata_dir} that changed in this run. Clone the staging repo into a temp working tree, write the files, commit, push:
TMP=$(mktemp -d)
git clone --depth 1 https://github.com/{fork_org}/{host}__{owner}__{repo}.git "$TMP/staging"
cd "$TMP/staging"
mkdir -p {metadata_dir}
# write {metadata_dir}/metadata.yaml and per-finding files (see step 4) here
git add {metadata_dir}
git -c user.email=scrutineer@local -c user.name=scrutineer \
commit -m "scrutineer: scan {scan_id} at {short-commit}" -m "Updated {metadata_dir} from scrutineer run."
git push origin {repository.default_branch}
The commit author is local; the push uses the gh CLI's credentials. One commit per run keeps the history readable.
4. Per-finding metadata files
Fetch the repository's findings: GET {api_base}/repositories/{repository_id}/findings with Authorization: Bearer {token}. Include findings whose status is one of new, enriched, triaged, ready, reported, acknowledged, fixed. Skip rejected, duplicate, and published — those do not have a staging-side life. Record skipped ones under "skipped" with the status as reason.
For each remaining finding fetch the full record (GET {api_base}/findings/{id}) so you have its prose, severity, status, CVSS, CWE, disclosure_draft, and suggested_fix.
In the staging working tree, write the following files under {metadata_dir}/findings/{finding_id}/:
metadata.yaml:
finding_id: {id}
title: {title}
severity: {severity}
status: {status}
cwe: {cwe}
cvss_vector: {cvss_vector}
location: {location}
opened_at: {ISO-8601, set on first appearance; do not overwrite on re-runs}
last_updated_at: {ISO-8601, refreshed every run}
issue_url: {will be set in step 5 once the issue is filed; leave empty for now}
disclosure-draft.md: contents of the finding's disclosure_draft field. Omit if empty.
patch.diff: contents of suggested_fix. Omit if empty.
If a finding directory already exists in the clone, preserve the opened_at field from the existing metadata.yaml; everything else is overwritten by the new run's values. Validation reports and PoC files written by other skills (fix-validation, future PoC writer) live in the same directory and are also preserved across re-runs.
5. File one issue per finding
List existing issues on the staging repo once, capturing the marker line from each body:
gh api repos/{fork_org}/{host}__{owner}__{repo}/issues --paginate \
--jq '.[] | {number, body, state, labels: [.labels[].name]}'
Every issue this skill files carries a [scrutineer-finding:{finding_id}] marker on its last line. Skip a finding whose marker already appears in any existing issue body; back-fill its issue_url in metadata.yaml from the existing issue. Record it under "skipped_issues" with reason "already filed".
For each remaining finding, build the issue body. Use disclosure_draft when present; otherwise assemble from the finding's six-step prose. Drop any section whose source field is empty.
{title}
> Staged by scrutineer from finding {finding_id} (scan {scan_id}).
## Summary
{first sentence of rating, or "Severity: {severity}" if rating is empty}
## Location
`{location}` on `{owner}/{repo}`
## Details
{trace}
## Trigger
{boundary}
## Reproduction
{validation}
## Impact
{rating}
## Reach
{reach}
## References
- {repository.html_url}/blob/{default_branch}/{location path without :line}
- https://cwe.mitre.org/data/definitions/{n}.html (one per CWE)
- {each URL in prior_art}
[scrutineer-finding:{finding_id}]
The marker on the last line is the dedup contract — do not move it, do not change its shape.
File the issue with severity labelled:
gh issue create -R {fork_org}/{host}__{owner}__{repo} \
--title "{title}" \
--body-file ./issue-{finding_id}.md \
--label "severity:{severity-lowercase}"
Create the severity:{level} label first if it does not exist:
gh label create "severity:{level}" -R {fork_org}/{host}__{owner}__{repo} \
--color "{matching colour}" --description "Finding severity" --force
Colours: severity:critical #8b0000, severity:high #dc3545, severity:medium #ffc107, severity:low #6c757d. --force makes label creation idempotent across runs.
Status labels (status:*) are not applied by this skill yet; they belong with a future state-sync pass. Filing them prematurely would conflict with the future taxonomy.
Capture the issue URL from the create response. Update {metadata_dir}/findings/{finding_id}/metadata.yaml with issue_url. Then write the issue link back to scrutineer:
POST {api_base}/findings/{id}/references with {"url": "<issue_url>", "tags": "staging-issue", "summary": "Issue on {fork_org} staging repo"}
POST {api_base}/findings/{id}/communications with {"channel": "github-issue", "direction": "outbound", "actor": "fork", "body": "Issue #{number} opened on {fork_org}/{host}__{owner}__{repo}"}
Do not change the finding's status. A staging issue is not a report upstream.
After all writes, the working tree should contain one updated {metadata_dir}/metadata.yaml, the per-finding directories with refreshed metadata and (where present) draft/patch files, and an unmodified upstream tree. Commit and push as described in step 3.
6. Pick a team and give it push access
List the org's teams once:
gh api orgs/{fork_org}/teams --paginate --jq '.[].slug'
Pick at most one team whose slug matches the repository, trying these signals in order and stopping at the first hit:
- Foundation / upstream org. Lowercase the upstream
{owner} and check whether any team slug is a substring of it or vice versa. eclipse-platform or eclipse-ee4j matches an eclipse team, apache matches apache, a kubernetes-sigs repo matches kubernetes or cncf. Also check GET {api_base}/repositories/{repository_id}/maintainers — if a maintainer's affiliation or the repo's funding/SECURITY.md (already in ./src) names a foundation that appears as a team slug, prefer that.
- Ecosystem.
package_managers[0].name from brief ./src, mapped the same way as the GHSA ecosystem enum: Bundler→ruby/rubygems, npm/Yarn/pnpm→npm/javascript/nodejs, Cargo→rust, Go Modules→go/golang, pip/Poetry→python/pypi, Maven/Gradle→java/maven, Composer→php.
- Primary language.
languages[0].name from brief ./src, lowercased.
Match by normalising both the candidate and each team slug (lowercase, strip non-alphanumerics) and testing whether either contains the other. If nothing matches, leave "team": null and move on — do not invent a team.
Give the team push access on the staging repo:
gh api -X PUT orgs/{fork_org}/teams/{team-slug}/repos/{fork_org}/{host}__{owner}__{repo} \
-f permission=push
Idempotent — re-running with the same team produces the same access.
Output
Write ./report.json:
{
"fork_org": "fork-central",
"upstream": "owner/repo",
"staging": "fork-central/gh__owner__repo",
"created": "created",
"seeded_commit": "abc123...",
"scanned_at": "2026-05-04T12:00:00Z",
"issues": [
{"finding_id": 17, "number": 3, "url": "https://github.com/fork-central/gh__owner__repo/issues/3"}
],
"skipped_issues": [{"finding_id": 18, "reason": "already filed"}],
"skipped": [{"finding_id": 19, "reason": "duplicate"}],
"team": "rust",
"notes": "anything that did not go cleanly",
"error": null
}
created is one of created, exists. team is the slug you gave push access, or null.
Constraints
- Do not touch the upstream repository's settings or file anything against it. Everything in this skill targets the staging repo.
- Do not run if
fork_org is unset; the operator must opt in.
- Do not delete or overwrite an existing staging repo.
- Do not invent CVE IDs, CWEs, or package names that are not on the finding.
- Do not file or modify draft GHSA advisories. Private staging repos have no PVR / no advisory surface; that path belongs to
report-upstream against the github.com upstream.
- Do not apply
status:* labels — that taxonomy belongs to the future state-sync pass.