feat(privacy): detect public-to-private visibility transitions in reconcile#3338
Conversation
Three follow-up nits from PR #3330 review: - Drop SlugResolutionResult.failures (structurally dead; throw is the only failure path) - Switch GraphQL nodeId arg to --field (forces string, avoids type coercion) - Document the single-slash nameWithOwner invariant
…oncile Reconcile probe now compares each tracked entry's stored `private` flag against the probe result. A `false -> true` flip (including via `access-lost`, which fails closed as private per Unit 2 semantics) queues a `reconcile:visibility-transition` integrity-alert issue. The issue identifies the entry by `node_id` only in both title and body. Operators look up the canonical owner/repo locally via the operator tool (deferred to a later unit). This is stricter than the plan's permissive wording (which allowed canonical owner/name in the body when the probe response still had it) and matches the always- redacted-everywhere privacy posture. Private-to-public transitions emit no alert; the next mutator write recanonicalizes the entry. Newcomers (no stored `private` field) get no alert; that is an initial categorization, not a flip. Dedup-before-create matches the maybeCreateStaleDivergenceAlert pattern: an existing open issue with the same node_id in the title suppresses a new one. Auto-close is intentionally not wired — the operator closes the issue manually after acting. Adds reconcile:visibility-transition label to .github/settings.yml.
…ent failures
Round-2 review remediation for the Unit-9 visibility-transition path.
Durability (review P1):
- Fail open on dedup-list failure: if paginate(issues.listForRepo) throws,
proceed with issue creation instead of silently dropping the alert.
- Pre-flight label creation: ensure reconcile:visibility-transition and
reconcile:integrity-alert exist in the live repo before issues.create;
defends against the Update-Repo-Settings propagation race.
- Drop labels gracefully if their createLabel call 422s — the alert still
ships under whatever labels do exist.
Operator UX (review P1):
- Replace the dangling scripts/resolve-private.ts reference in the issue
body with an inline `gh api graphql` command operators can paste
directly into a terminal.
Correctness + types (review P2 safe_auto):
- runIssueQueue success counters: explicit switch over IssueQueueEntry
kinds with a never exhaustiveness guard; visibility-transition no
longer inflates the rollup counter.
- Dedup uses exact-title equality (functionally equivalent to NODE_ID
marker match given the title is derived entirely from node_id), not
substring matching — closes the false-positive/false-negative window.
- Replace the double-cast `as unknown as OpenIssueEntry[]` with an
Octokit-derived type alias.
- Drop `as Map<string, {status: string}> as never` from test fixtures
by typing makeProbeMap to the real RepoStatusProbe shape.
- Stale comment cleanup; minor doc drift in NBC #2's neighborhood.
Test coverage (review P2):
- Rendering test pins R15 privacy guarantee: node_id only in title and
body, no canonical-slug separator (`owner--repo` pattern).
- Dedup branch test: existing open issue with matching node_id title
suppresses new issues.create.
- Durability tests: paginate-throws and label-404 paths both still
result in issues.create being attempted.
… set Round-3 review remediation. The previous round's 'drop labels gracefully' claim was incomplete — ensureLabelExists swallowed exceptions but the caller still passed the full label list to issues.create, so a transient getLabel/createLabel failure still produced a 422 and dropped the alert. - ensureLabelExists returns a Set<string> of confirmed-usable labels; non-404 getLabel and non-422 createLabel failures exclude that label. - runIssueQueue filters the issue payload to only confirmed labels. - All-labels-unconfirmed edge case ships unlabeled rather than dropping the alert. Tighten test coverage to actually exercise the contract: - RED-against-old-code dedup test using a victim node_id that is a strict prefix of an existing open-issue title. Substring dedup would suppress; exact-title dedup creates the new issue. - Label preflight tests now assert call order (getLabel + createLabel before issues.create) and inspect issues.create's labels payload. - Race vs transient createLabel split into two cases: 422 keeps the label, 500 excludes it. - Privacy regex broadens to catch dot-prefixed slugs like fro-bot--.github. Defense-in-depth at the schema boundary: node_id must match ^[\w\-+/=]+$. Rejected at metadata read time so the operator copy-paste in the issue body can't be coerced into a different shell command by a malformed entry.
fro-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS
Clean privacy work. The defense-in-depth here — node_id-only public surface, fail-open dedup, label pre-flight with 422-as-success, schema-level node_id sanitization — closes the three silent-drop failure modes called out in the PR description without leaking canonical identity into the public issue stream. The exhaustiveness guard in runIssueQueue is the right shape for keeping future IssueQueueEntry additions honest.
The false → true flip with undefined excluded as "initial categorization" is the correct sticky semantics. access-lost failing closed as private is consistent with the survey workflow's posture.
Blocking issues
None.
Non-blocking concerns
scripts/reconcile-repos.ts:2528— the inlinegh api graphqlcommand in the rendered body embeds${issue.node_id}inside single quotes. The schema regex^[\w\-+/=]+$correctly rejects single quotes, backticks,$,;, and whitespace at metadata-read time, so this is safe today. The safety is implicit in two distant files coupled by a regex though — a one-line comment at the render site pointing at the schema invariant (or vice versa) would make the contract auditable without chasing references. Not blocking; the schema test pins the invariant.scripts/reconcile-repos.ts:~2475—ensureLabelsExistissues two sequentialgetLabel/createLabelround-trips per visibility-transition issue even on the steady-state happy path (labels already exist via.github/settings.yml). For the expected cardinality (at most a handful of transitions per run), the latency is negligible. Worth noting only because the cost scales linearly with transition count; if you ever see a thundering herd, cache the confirmed set across calls within onehandleReconcileinvocation.scripts/reconcile-repos.test.ts:~525— the privacy regression pin uses/[\w.-]+--[\w.-]+/to assert no canonical slug. This catches the canonicalowner--repoform but would not catch aowner/repoform if a future renderer change introduced one. The companion test on line ~535 (.not.toContain('fro-bot/tracked-repo')) covers the slash form for the specific fixture, but a generic\w+\/[\w.-]+regression pin would be sturdier against unknown future leak shapes. Optional hardening.- Follow-up #3335 (legacy entries without
node_idhave no alertable subject when access is revoked) is the right deferral —maybeQueueVisibilityTransitioncorrectly skips whennode_id === undefined, so the silent drop is bounded to legacy entries that pre-date node_id capture. Acceptable as long as #3335 stays open.
Missing tests
None. Coverage is thorough: all 9 plan scenarios, privacy regression pin (including dot-prefixed slugs), exact-title dedup with the RED-against-substring proof, paginate-throws fail-open, label 404 → createLabel call ordering, 422-as-success vs 500-as-exclude with payload inspection, and schema rejection of shell metacharacters with both new-style and legacy base64 acceptance. The dedup branch coverage in particular is well-constructed — the R_kgDOZAAAA vs R_kgDOZAAAAA test is the kind of regression pin that actually prevents recurrence.
Risk assessment: LOW
Reasoning:
- Engine-side change is additive and gated on
storedPrivate === false— no behavior change for entries already marked private or newcomers. - Issue-creation path is idempotent at the GitHub layer (exact-title dedup) and resilient to transient failures (fail-open dedup, label pre-flight with 422 race handling, graceful payload degradation to unlabeled).
- Schema tightening on
node_idis strict-but-correct: real base64ish GitHub IDs pass, shell metacharacters fail at metadata-read time. Worst case for a malformed-but-previously-accepted entry:assertReposFilethrows on next run, which is the intended fail-loud behavior. - No workflow/CI changes. No new dependencies. No
any, no@ts-expect-error, no console (usesparams.logger). Action SHA pinning N/A. - Privacy posture stricter than the plan specified (always omits canonical owner/repo even when resolvable), which is the right call for a public issue stream.
Run Summary
| Field | Value |
|---|---|
| Event | pull_request |
| Repository | fro-bot/.github |
| Run ID | 26151057003 |
| Cache | hit |
| Session | ses_1bb7b78e5ffeKPqzxmNm2UXq5U |
…tion Fro Bot review polish on the visibility-transition path: - Cross-reference comment between the node_id regex in scripts/schemas.ts and the inline gh-api-graphql interpolation in renderVisibilityTransitionIssue so future maintainers see the schema invariant the render site depends on. - Generic slash-form privacy regression pin in the rendering test catches any future renderer change that introduces a canonical owner/repo mention beyond the specific fixture string already asserted. Pattern uses a negative lookahead to exclude the prose placeholder 'owner/repo' that appears legitimately in the body's operator instructions.
fro-bot
left a comment
There was a problem hiding this comment.
Verdict: PASS
Re-review after my prior approval was dismissed. Commit 6e4f9d3 cleanly addresses both NBCs from the first pass:
- Schema-render contract is now auditable. The cross-reference comment at
scripts/reconcile-repos.ts:2528points at the schema invariant (/^[\w\-+/=]+$/) that makes the inlinegh api graphqlinterpolation shell-safe, andscripts/schemas.ts:259points the other direction. Two distant files no longer coupled by an implicit regex — the contract is documented at both ends. - Generic slash-form privacy pin landed. The added
expect(payload.body).not.toMatch(/(?!owner\/repo)\b\w[\w-]*\/[\w.-]+/)inscripts/reconcile-repos.test.tscatches unknown future leak shapes while the(?!owner\/repo)lookahead correctly excludes the prose placeholder in the operator instructions. Verified empirically:fro-bot/testmatches,owner/repodoes not,nameWithOwnerdoes not, and the actual rendered body produces no match.
Everything else from the prior review still holds: defense-in-depth posture (node_id-only public surface, fail-open dedup, label pre-flight with 422-as-success, schema-level node_id sanitization), exhaustiveness guard in runIssueQueue, sticky false → true flip semantics with undefined excluded as initial categorization, no workflow/CI churn, no new deps, no banned constructs.
Blocking issues
None.
Non-blocking concerns
scripts/reconcile-repos.ts:~2475—ensureLabelsExiststill issues two sequentialgetLabel/createLabelround-trips per visibility-transition issue even on the steady-state happy path where labels already exist via.github/settings.yml. For the expected cardinality of a handful of transitions per run this is negligible; cache the confirmed set across calls within onehandleReconcileinvocation only if you ever observe a thundering herd.- Follow-up #3335 (legacy entries without
node_idhave no alertable subject when access is revoked) remains the right deferral —maybeQueueVisibilityTransitioncorrectly skips whennode_id === undefined, so the silent drop is bounded to pre-node_id entries. Acceptable as long as #3335 stays open.
Missing tests
None. Coverage is unchanged from the prior pass plus the new generic slash-form regression pin. Plan scenarios, dedup branch coverage (including the RED-against-substring proof), durability triplet (paginate-throws, label 404→createLabel→create call ordering, 422-vs-500 payload inspection), and schema rejection with new-style and legacy base64 acceptance are all present.
Risk assessment: LOW
Reasoning:
- Engine-side change is additive and gated on
storedPrivate === false— no behavior change for entries already private or for newcomers. - Issue-creation path is idempotent at the GitHub layer (exact-title dedup) and resilient to transient failures (fail-open dedup, label pre-flight with 422 race handling, graceful payload degradation to unlabeled).
- Schema tightening on
node_idis strict-but-correct: real base64ish GitHub IDs pass, shell metacharacters fail at metadata-read time. Worst case for a malformed-but-previously-accepted entry:assertReposFilethrows on next run, which is the intended fail-loud behavior. - No workflow/CI changes. No new dependencies. No
any, no@ts-expect-error, no console (usesparams.logger). Action SHA pinning N/A. - Privacy posture stricter than the plan specified (always omits canonical owner/repo even when resolvable), which is the right call for a public issue stream.
Run Summary
| Field | Value |
|---|---|
| Event | pull_request |
| Repository | fro-bot/.github |
| Run ID | 26151452280 |
| Cache | hit |
| Session | ses_1bb7b78e5ffeKPqzxmNm2UXq5U |
Reconcile now compares each tracked entry's stored
privateflag against the live probe result. Afalse → trueflip (including viaaccess-lost, which fails closed as private) queues areconcile:visibility-transitionintegrity-alert issue identifying the entry bynode_idonly — title and body both omit canonical owner/repo to keep the public issue stream safe.The body includes a copy-paste
gh api graphqlcommand operators can run locally to resolve the node_id to its canonical owner/repo when investigating which wiki pages need manual review.Durability
The privacy alert is one-shot: once metadata is committed
private: true, the next reconcile run seesstored.private === probe.private === trueand won't re-queue. Three failure modes that previously dropped the alert silently are now closed:paginate(issues.listForRepo)for dedup throws → fail open and create the issue anywayreconcile:visibility-transitionorreconcile:integrity-alertlabel not yet propagated to the live repo → pre-flightgetLabel/createLabel; treat 422 fromcreateLabelas the label-exists racegetLabelor non-422createLabelfailure) → exclude that label from the payload; ship the issue with the surviving labels (or unlabeled with a loud warn) rather than droppingPrivacy posture
The implementation is stricter than the plan's permissive wording: even when the probe could resolve canonical owner/repo, the issue body omits it. Operators run the inline lookup command on demand. This matches the always-redacted-everywhere posture established by the survey workflow's visibility gate.
Defense-in-depth at the schema boundary:
node_idmust now match^[\w\-+/=]+$, validated in bothisRepoEntryandassertRepoEntry. Real base64ish GitHub IDs pass; shell metacharacters that would corrupt the operator's copy-paste are rejected at metadata-read time.What stays open
Private → public transitions emit no alert; the next mutator write recanonicalizes the entry. Newcomers (no stored
privatefield) emit no alert either — that's an initial categorization, not a flip.autoCloseStaleIssuesis deliberately not extended to this label; operators close transition issues manually after acting.Tests
649 passing + 3 todo (was 564 on main). New coverage includes:
owner--reposlug (including dot-prefixed slugs likefro-bot--.github)node_idshell metacharactersFollow-ups (deferred)
These are tracked separately rather than expanding this PR:
visibilityTransitionscounter in the reconcile Step Summarynode_idhave no alertable subject when access is revokedissues.createerror paths