Repository navigation
feat: add pat plugin — GitHub App installation token (no manual PAT) - #7
Conversation
`pat` replaces manually-created PATs. It mints a short-lived GitHub App installation access token (1h, fresh on every run) from a private key stored in Bitwarden, and optionally stores it as a repo Actions secret. Because the App is installed on the whole account, every new repo needs zero extra steps — no more github.com/settings/tokens. - scripts/pat.sh: CLI (install / token / grant subcommands) - scripts/pat_lib/jwt.sh: RS256 JWT via openssl (no Python deps) - scripts/pat_lib/bw.sh: Bitwarden unlock-if-needed + PEM retrieval - commands/pat.md + plugin.json: /claude-tools:pat slash command - tests: 13 new unit tests (b64url, JWT sign/structure, bw parse, argparse) Co-Authored-By: Claude <[email protected]>
bw 2026.x rejects `bw get field <name> <item>` ("too many arguments") and the
old 3-arg form no longer exists. Switch pat_bw_get_field to fetch the full
item JSON and extract the field value with jq (python3 fallback).
Also drop the stray `head -1` that truncated multi-line PEM values to just
the `-----BEGIN...` header, which produced an invalid key and a 401 from /app.
Verified end-to-end: pat install → pat token (ghs_…, 180 repos visible) →
pat grant (secret lands in target repo).
Co-Authored-By: Claude <[email protected]>
Prevents GitHub App private keys and other credentials from being committed even if dropped into the repo working tree. Co-Authored-By: Claude <[email protected]>
`pat` (GitHub App installation token) is unrelated to claude-tools' rate-limit
cron — it gets its own home as a standalone plugin.
- new plugins/pat/: plugin.json, scripts/{pat.sh, pat_lib/}, commands/pat.md,
README.md, tests/run_tests.sh (13/13)
- remove pat from plugins/claude-tools/ (scripts, command, plugin.json entry,
README section, test cases) — claude-tools tests back to 41/41 cron-only
- register `pat` in marketplace.json
- claude-tools/README gets a one-line "moved out" pointer
Co-Authored-By: Claude <[email protected]>
pat — GitHub App installation token CLI (no manual PAT)pat plugin — GitHub App installation token (no manual PAT)
1. PEM temp-file leak. pat_get_pem_file was called via command substitution (`pem_file=$(...)`), running in a subshell — the pat_PEM_TMP assignment never reached the parent, so the EXIT trap saw an empty value and left every PEM file on disk. Repeated install/token/grant runs accumulated readable copies of the App private key. Replaced with pat_with_pem <var>, which sets the path in the calling shell (printf -v, no subshell); trap always cleans up. Added a leak-regression test + a guard proving the old $(...) pattern is still detected. 2. Master password in argv. `bw unlock --raw "$pw"` exposed the Bitwarden master password to `ps` / /proc while bw ran. Switched to stdin (`bw unlock --raw < <(printf '%s\n' "$pw")`) and clear the variable immediately after. Verified: 15/15 tests pass (added 2). Manually confirmed the leak was real — 8 stale PEM copies found in $TMPDIR from earlier runs, deleted. Co-Authored-By: Claude <[email protected]>
🔍 Local review (cycle 1/3)Reviewed locally (Codex companion). Claude subagent still running — will append if it surfaces more.
Both CONFIRMED (reproduced the leak: 8 stale PEM copies from earlier runs found in |
Three FIX items from the Claude subagent review: 1. pat_resolve_repo: lazy quantifier [^/]+? is invalid on BSD sed (macOS default) — every `grant` relying on git-origin auto-detection failed. Switched to greedy [^/]+ and a second sed to strip a trailing .git. Added URL-normalization tests (ssh/https, with/without .git). 2. Made jq a hard dependency (pat_require_deps) and removed the BSD-incompatible awk fallback (gawk-only match($0,re,arr)) and the injectable python3 fallback in pat_bw_get_field. jq is already required everywhere; the fallbacks were dead code that broke on macOS and added an injection surface. 3. PEM leak in piped callers — the real root cause. cmd_token ran pat_fresh_token | cut and cmd_grant used pair=$(pat_fresh_token); both execute the function in a SUBSHELL whose EXIT trap (bash 3.2 on macOS) does not reliably clean up the PEM temp file. Replaced with pat_fresh_token_into <tok_var> <exp_var> (printf -v, no subshell) so the trap always runs in the shell that created the file. Verified live: 0 leftover PEM files across token+grant+install. Verified: 18/18 tests pass (added 3 URL-normalization tests). shellcheck clean. Co-Authored-By: Claude <[email protected]>
🔍 Local review — cycle 1/3 completeCodex companion + Claude subagent. 4 FIX items, all confirmed by reproduction and applied:
Verified live: 0 leftover PEM temp files across Cycle 1 had FIX verdicts → starting cycle 2 on the fixed diff. |
- gh secret set: pass the installation token via STDIN instead of --body, so it never appears in the `gh` process argv (ps-visible while gh runs). - pat_fresh_token_into: drop `local token expires` — those are the CALLER's variables (named by $_tok_var/$_exp_var, set via printf -v). Declaring them local shadowed the caller's vars, so expires_at never reached cmd_grant (printed "valid until" empty). pem_file/jwt/inst_json stay local. Verified live: grant prints real expiry, token still minted (40 chars), 18/18 tests. Co-Authored-By: Claude <[email protected]>
…cle 2 cleanup) Final cleanup pass applying deferred minor findings (both reviewers): - jwt.sh: route the signed JWT through a 0600 curl --config temp file (pat_gh_curl) instead of curl -H argv, so the JWT is not visible in `ps` while curl runs. Config file removed right after the call (verified: 0 leftover patcurl files across token+install+grant). - pat_installation_id_for: replace `jq ... | head -1` with `jq '[...] | .[0]` to avoid a SIGPIPE-driven rc=141 abort under set -euo pipefail at very high installation counts (theoretical, but the fix is strictly safer). Verified live: install/token/grant all green, no leaked temp files, 18/18 tests. Co-Authored-By: Claude <[email protected]>
🔍 Local review — cycle 2/3 complete (FINAL)Codex + Claude subagent. No remaining FIX verdicts. Two minor findings applied in the cleanup pass; one Codex "critical" was re-triaged down to accepted-minor.
Final state: 18/18 tests, shellcheck clean, no credential leakage (PEM/JWT/token all out of argv + temp files cleaned on every path), macOS/BSD portable. Cycle 2 had no outstanding FIX → review complete. Local mode does not auto-merge — merge is yours to trigger ( |
Squash-merge of #7 didn't capture the run_tests.sh additions for pat_resolve_repo's BSD-sed-safe URL normalization (ssh/https, with/without .git). Adds them here so the function stays covered. 15 -> 18 assertions. Co-Authored-By: Claude <[email protected]>
What
Adds a new
patplugin to the marketplace — a CLI that replaces manually-created GitHub PATs.patmints a short-lived GitHub App installation access token (1 hour, fresh on every run) from a private key stored in Bitwarden, and optionally stores it as a repo Actions secret:Because the App is installed on the whole account, every new repo is covered with zero extra steps — no more opening github.com/settings/tokens every time a project needs cross-repo access from Actions.
Structure
patlives in its own plugin (plugins/pat/), separate fromclaude-tools(whose rate-limit cron is unrelated):plugins/pat/scripts/pat.sh— CLI entry:install/token/grantplugins/pat/scripts/pat_lib/jwt.sh— RS256 JWT viaopenssl(no Python deps)plugins/pat/scripts/pat_lib/bw.sh— Bitwarden unlock-if-needed + PEM retrievalplugins/pat/commands/pat.md—/pat:patslash commandplugins/pat/README.md— docs + one-time App setupplugins/pat/tests/run_tests.sh— 13 unit tests (no network/Bitwarden/App)Also:
patwas briefly added toplugins/claude-tools/in an earlier commit on this branch — this PR's final state removes it from there (claude-tools stays cron-only, 41/41 tests) and registers the standalonepatinmarketplace.json.How it works
Bitwarden (PEM) → RS256 JWT (openssl) → POST /app/installations/<id>/access_tokens → installation token (1h) → gh secret setThe token never persists: the PEM lives only in Bitwarden, never on disk.
Verified end-to-end (live)
Used to replace the last manual PAT (
GH_PATin travelpayouts_bot, used by deploy.yml to clone private axisrow/* repos during docker build) — App-token clones the private repo successfully.Tests
plugins/pat/tests/run_tests.sh: 13/13 passedplugins/claude-tools/tests/run_tests.sh: 41/41 passed (unchanged, cron-only)One-time App setup (documented in README)
.pem.private-keycustom field holding the PEM.~/.config/pat/configwithPAT_APP_ID+PAT_BW_ITEM.pat install→ all ✓.🤖 Generated with Claude Code