Skip to content

feat(gh-wrapper): org-migration identity guard (F3) and token-scope escape hatch (F4) - #306

Merged
twistedmelonman merged 7 commits into
mainfrom
claude/feat-gh-wrapper-org-migration-01RUgidK
Sep 4, 2026
Merged

feat(gh-wrapper): org-migration identity guard (F3) and token-scope escape hatch (F4)#306
twistedmelonman merged 7 commits into
mainfrom
claude/feat-gh-wrapper-org-migration-01RUgidK

Conversation

@twistedmelonman

Copy link
Copy Markdown
Owner

Summary

Plan tasks 1–2 of the org migration (spec + plan: smartwatermelon/dev-env#80, tooling: smartwatermelon/dev-env#81).

  • F3 identity guard — personal-account owners resolve to twistedmelonman (the post-rename login), with smartwatermelon kept as a temporary alias until the GitHub account is renamed; the alias is removed by plan Task 12. Auth-switch target resolves through the alias.
  • F4 scope hint — when an env-var token (GH_TOKEN, or GITHUB_TOKEN as gh's fallback) lacks a scope and gh says so, the wrapper prints one pasteable escape hatch: env -u GH_TOKEN gh <argv>. Secret-bearing flag values (-b/--body, -f/-F/--field/--raw-field, -H/--header, stuck short forms, --flag=value) print as <redacted>; -- is honored.

Capture design (F4)

stderr is captured only when an env token is set and stderr is not a terminal. Otherwise the wrapper execs gh as before, so a human with GH_TOKEN exported keeps gh's stderr TTY (gh auth login / gh pr create render prompts there). In the capture path gh runs in the background and the wrapper waits — measured: bash defers a trapped signal behind a foreground pipeline, so only wait lets the handler forward SIGTERM to gh, remove the temp file, and re-raise. stderr goes through exec {errfd}> >(tee ...); the fd is closed and tee waited for before the file is read.

Verification

  • bash/tests/test-gh-wrapper-identity.sh, bash/tests/test-gh-wrapper-scope-hint.sh (26 assertions incl. redaction, exec-vs-resident via parent-process check, pty via BSD script(1), SIGTERM within 3s / no temp-file leak / no orphaned gh)
  • Every new assertion observed failing against the pre-fix commit before landing
  • Full suite 25/25 (also run by the pre-push hook); shellcheck clean
  • Four review rounds (commit hooks + scoped adversarial re-reviews); pre-push codebase reviewer dry-run PASS

Parked

  • wait "${tee_pid}" || true hides a dead tee (worst case: no hint, gh's own message still prints)
  • Redaction covers gh's known secret-bearing flags only; a secret in an unlisted flag prints as today

https://claude.ai/code/session_01B7KFdvsQq7eLUGEi4pRQmX

Claude Code Bot added 7 commits September 3, 2026 13:27
Prepares for the 2026-09 org migration: the smartwatermelon user is
renamed to twistedmelonman and the name is re-claimed as an org. The
owner table now maps smartwatermelon, nightowlstudiollc, and
twistedmelonman to the person, and the force-draft in-org list gains
twistedmelonman.

A single dated alias (twistedmelonman=smartwatermelon) keeps every
wrapped call working between this change landing and the rename. It is
removed in a follow-up once all machines have re-logged in.

Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6
The alias suppressed the identity COMPARISON but not the switch TARGET.
Pre-rename the keyring holds smartwatermelon, not twistedmelonman, so a
personal repo resolving desired=twistedmelonman made the wrapper run
`gh auth switch --user twistedmelonman` against an account gh does not
have. That hard-failed on the first personal-repo call after any
Beacon-repo call — precisely the window the alias exists to cover, on
all three machines.

Resolve the target against the logins the keyring actually holds: use
`desired` when present, else the first held login the alias table
equates to it, else `desired` unchanged so the caller still fails
closed. No reverse alias is introduced.

_gh_wrapper_keyring_users parses the `users:` block by indent depth so
a login's own nested settings (oauth_token:) are not mistaken for
additional accounts, and so a second host cannot leak into the list.

Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6
GH_TOKEN and the keyring token are the same login; only the scopes
differ. When gh fails with its own "needs the ... scope" text, the
standalone wrapper now prints the exact re-run command with GH_TOKEN
unset (or `gh auth refresh -s <scope>` when no GH_TOKEN is set). stderr
is mirrored live through tee; stdout and the exit code pass through.

Detection keys off gh's ScopesSuggestion output rather than classifying
commands, so there is no list of scope-needing subcommands to keep
current. The quote character is not pinned: gh has emitted both '...'
and "..." across versions, and matching one silently disables the hint.

The final exec becomes a run-then-exit, because the hint needs gh's
exit status and stderr after it returns. stdout is routed around the
tee pipe on fd 3, so gh's TTY detection there -- which drives color,
paging and prompts -- is unchanged; only stderr becomes a pipe.
Verified: stdin, interactive stderr prompts, live (unbuffered) stderr,
exit-code fidelity and temp-file cleanup all hold.

Claude-Session: https://claude.ai/code/session_01RUgidKkV54aNnH1rRNfUq6
…e signals in the scope hint

Four findings from the Task 2 review of a383a5c:

- The hint re-quoted argv verbatim, so `gh secret set FOO --body <token>`
  echoed the token into stderr (scrollback, CI logs, transcripts). Values
  following -b/--body, -f/--field, -F/--raw-field, -H/--header, in both the
  `--flag VALUE` and `--flag=VALUE` spellings, now print as <redacted>.
  Redaction is by flag, not by value pattern, so it cannot fail open on an
  unanticipated token format.
- gh reads GH_TOKEN then GITHUB_TOKEN. When only GITHUB_TOKEN was set the
  hint told the user to `gh auth refresh`, which rewrites a keyring token
  the env var overrides. The hint now names whichever variable is set.
- A SIGTERM/SIGHUP mid-run skipped the trailing rm -f and left one temp
  file per interrupted run; a RETURN trap alone does not fire on signal
  death (measured). An explicit TERM/HUP/INT trap cleans up and re-raises.
- The test clears GH_HOST and GITHUB_TOKEN as well as GH_TOKEN, and
  sandboxes TMPDIR so the leak assertions see only this run's files.

New assertions (redaction x4, GITHUB_TOKEN x2, SIGTERM leak x2) all fail
against a383a5c and pass here.

Claude-Session: https://claude.ai/code/session_01B7KFdvsQq7eLUGEi4pRQmX
The first redaction pass matched flags as exact tokens, so pflag's stuck
short form (`-bSECRET`, `-fkey=SECRET`) fell through to the verbatim
branch and printed the secret. Handle all three spellings pflag accepts.

Also from the re-review of c695838:
- Pair the flags as gh defines them (-F/--field, -f/--raw-field).
- For key=value flags keep the key and redact only the value, so the
  suggested command still says which field carried the secret.
- Stop flag parsing at a literal `--`, matching the file's other scanners.

Three new assertions fail against c695838 and pass here; the `--` case
guards a path that did not leak before but had no test.

Claude-Session: https://claude.ai/code/session_01B7KFdvsQq7eLUGEi4pRQmX
…ground so signals reach it

Whole-branch review found three findings with one root cause: the stderr
tee pipe interposed on every gh call, costing gh its stderr TTY, reordering
stderr against stdout, and orphaning gh on a targeted SIGTERM.

- Standalone mode now execs the real gh unless GH_TOKEN or GITHUB_TOKEN is
  set; the hint only matters when an env token overrides the keyring.
- _gh_wrapper_run_with_scope_hint runs gh in the background and `wait`s.
  Measured: bash defers a trapped signal behind a foreground pipeline, so
  the old handler could neither forward nor clean up until gh exited on its
  own. `wait` returns at once, the handler kills gh, removes the temp file,
  and re-raises. Explicit `<&0` keeps stdin; INT is forwarded as TERM
  because background children start with SIGINT ignored.
- stderr goes through `exec 4> >(tee ...)`; the fd is closed and tee waited
  for before the file is read, so no partial-file race.
- Hint names whichever env var is set; the `gh auth refresh` branch is gone
  (unreachable once capture is gated).
- Tests: no-token path asserts exec via parent-process check and untouched
  stderr; SIGTERM case asserts the wrapper dies within 3s (a plain `wait`
  passed 30s late against 42a5502), no temp file leak, and no orphaned gh.
  All five new assertions fail against 42a5502. Identity test unsets
  GITHUB_TOKEN too, so an ambient token cannot flip the exec path.

Claude-Session: https://claude.ai/code/session_01B7KFdvsQq7eLUGEi4pRQmX
…apture

Re-review of f27a340 found two real gaps:

- The capture gate was "env token set", but the rationale is "an agent
  session cannot act on gh's own message". A human with GH_TOKEN exported
  in an interactive shell took the capture path on every call and lost
  gh's stderr TTY — the fd `gh auth login` and `gh pr create` render their
  prompts on. The gate is now: exec unless an env token is set AND stderr
  is not a terminal. New test allocates a pty via BSD script(1) and asserts
  the stub sees a stderr tty with GH_TOKEN set; fails against f27a340.
- `exec 4> >(tee ...)` used a fixed fd. The function is exported, so a
  repeat call in one shell would clobber a still-open fd 4 and orphan the
  first tee. Now `exec {errfd}> >(...)` with the fd closed for gh and after
  wait. Verified: two sourced calls leave no extra fds and no tee children.

Comment fix: `<&0` is defensive — only a job-control shell gives a
background child /dev/null as stdin, and this path is non-interactive.

Claude-Session: https://claude.ai/code/session_01B7KFdvsQq7eLUGEi4pRQmX
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Scope: Signal handling in F4, identity resolution and alias logic in F3, secret redaction on hint output.

F3: Identity resolution with login aliases

  • Alias function (line 193-205) correctly implements one-directional matching: _gh_wrapper_logins_equal desired actual returns 0 only if actual is desired or an alias of desired
  • Token validation (line 351-353) guards against multi-line/punctuated garbage from API responses
  • Identity switch target resolution (line 248-266) prefers exact match, falls back to alias, returns desired unchanged
  • Alias properly threaded through all identity checks (lines 366, 377)

F4: Signal handling in background stderr capture

  • RETURN trap (line 749) runs on normal paths; signal trap (line 750) forwards SIGTERM/HUP/INT to gh and re-raises
  • Critical design: gh runs in background, wrapper waits — bash only returns from wait immediately on signal (lines 726-730)
  • Process substitution fd lifecycle correct: dynamically allocated {errfd} prevents collision on repeat calls
  • Graceful degradation: mktemp/tee failures handled without losing gh output

Secret redaction

  • Covers all documented flags (-b/--body, -f/-F/--field/--raw-field, -H/--header, stuck forms)
  • Correctly honors -- to end flag parsing
  • Keyed flags preserve field name, redact value only
  • Tests verify no secret leakage (test-gh-wrapper-scope-hint.sh Case 6)

Exit codes

  • rc capture (line 757) preserves gh exit status even when set -e is active
  • Signalled-death codes propagate correctly via re-raise

No bugs, regressions, security issues, or data-loss risks identified.

VERDICT: PASS

@twistedmelonman
twistedmelonman merged commit 37f9b6f into main Sep 4, 2026
4 checks passed
@twistedmelonman
twistedmelonman deleted the claude/feat-gh-wrapper-org-migration-01RUgidK branch September 4, 2026 02:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant