Skip to content

feat: add pat plugin — GitHub App installation token (no manual PAT) - #7

Merged
axisrow merged 8 commits into
mainfrom
feat/claude-tools-pat
Jul 12, 2026
Merged

axisrow merged 8 commits into
mainfrom
feat/claude-tools-pat

Conversation

@axisrow

@axisrow axisrow commented Jul 12, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds a new pat plugin to the marketplace — a CLI that replaces manually-created GitHub PATs.

pat mints 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:

pat grant --secret APP_TOKEN --repo axisrow/profile

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

pat lives in its own plugin (plugins/pat/), separate from claude-tools (whose rate-limit cron is unrelated):

  • plugins/pat/scripts/pat.sh — CLI entry: install / token / grant
  • plugins/pat/scripts/pat_lib/jwt.sh — RS256 JWT via openssl (no Python deps)
  • plugins/pat/scripts/pat_lib/bw.sh — Bitwarden unlock-if-needed + PEM retrieval
  • plugins/pat/commands/pat.md — /pat:pat slash command
  • plugins/pat/README.md — docs + one-time App setup
  • plugins/pat/tests/run_tests.sh — 13 unit tests (no network/Bitwarden/App)

Also: pat was briefly added to plugins/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 standalone pat in marketplace.json.

How it works

Bitwarden (PEM) → RS256 JWT (openssl) → POST /app/installations/<id>/access_tokens → installation token (1h) → gh secret set

The token never persists: the PEM lives only in Bitwarden, never on disk.

Verified end-to-end (live)

pat install  → all ✓ (App reachable, JWT valid, Bitwarden item found, gh authed)
pat token    → ghs_… token, 180 repos visible to it
pat grant    → secret lands in target repo (confirmed via gh secret list)

Used to replace the last manual PAT (GH_PAT in 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 passed
  • plugins/claude-tools/tests/run_tests.sh: 41/41 passed (unchanged, cron-only)
  • shellcheck clean (only SC1091 source-follow)
  • All 3 JSON files (marketplace + 2 plugin.json) valid

One-time App setup (documented in README)

  1. Create a GitHub App: Contents = Read & write, Workflows = Read & write; install on the account.
  2. Download private key .pem.
  3. Bitwarden secure note with a private-key custom field holding the PEM.
  4. ~/.config/pat/config with PAT_APP_ID + PAT_BW_ITEM.
  5. pat install → all ✓.

🤖 Generated with Claude Code

axisrow and others added 4 commits July 12, 2026 14:40
`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]>
@axisrow axisrow changed the title feat(claude-tools): pat — GitHub App installation token CLI (no manual PAT) feat: add plugin — GitHub App installation token (no manual PAT) Jul 12, 2026
@axisrow axisrow changed the title feat: add plugin — GitHub App installation token (no manual PAT) feat: add pat plugin — GitHub App installation token (no manual PAT) Jul 12, 2026
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]>
@axisrow

axisrow commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Local review (cycle 1/3)

Reviewed locally (Codex companion). Claude subagent still running — will append if it surfaces more.

Verdict Reviewer Finding Location
FIX → applied (5e8108f) codex PEM temp-file leak: pat_get_pem_file called via $(...) ran in a subshell, so pat_PEM_TMP never reached the parent and the EXIT trap left every PEM copy on disk. Accumulated readable App private keys. pat.sh:94-106
FIX → applied (5e8108f) codex Master password passed as bw unlock argv — visible to ps//proc while bw ran. pat_lib/bw.sh:32-40

Both CONFIRMED (reproduced the leak: 8 stale PEM copies from earlier runs found in $TMPDIR, deleted). Fixed + added 2 regression tests (15/15 pass). Awaiting Claude subagent before next cycle.

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]>
@axisrow

axisrow commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Local review — cycle 1/3 complete

Codex companion + Claude subagent. 4 FIX items, all confirmed by reproduction and applied:

Verdict Reviewer Finding Fix
FIX ✓ codex PEM temp-file leak (subshell $(...)) pat_with_pem w/ printf-v + subshell trap (5e8108f)
FIX ✓ codex master password in bw unlock argv stdin via < <(printf) (5e8108f)
FIX ✓ claude BSD-sed lazy +? broke grant auto-detect on macOS greedy [^/]+ + .git strip + tests (dfe4de1)
FIX ✓ claude gawk-only awk fallback + injectable python fallback jq hard-dep, fallbacks removed (dfe4de1)
FIX ✓ claude (root cause) PEM leak in piped callers (`cmd_token cut, $(pat_fresh_token)`) — subshell EXIT trap unreliable on bash 3.2

Verified live: 0 leftover PEM temp files across token + grant + install. 18/18 unit tests (added leak-regression + URL-normalization). shellcheck clean.

Cycle 1 had FIX verdicts → starting cycle 2 on the fixed diff.

axisrow and others added 2 commits July 12, 2026 20:04
- 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]>
@axisrow

axisrow commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 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.

Verdict Reviewer Finding Resolution
FIX ✓ codex installation token in gh --body argv stdin pipe (ef0761c)
SKIP (minor, accepted) codex→claude JWT in curl -H argv Claude subagent verified safe (macOS no /proc, 9-min TTL); still applied defense-in-depth via curl --config in cleanup (0c6baee)
SKIP→applied (cleanup) claude head -1 SIGPIPE rc=141 at >5000 installs replaced with `jq '[...]
SKIP (minor) claude pat_die escapes if in cmd_install diagnostic cmd, abort defensible — left as-is
bonus FIX ✓ — local token expires shadowed caller vars (expires empty) dropped local (ef0761c)

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 (gh pr merge 7 --squash).

@axisrow
axisrow merged commit ee0f624 into main Jul 12, 2026
1 check passed
@axisrow
axisrow deleted the feat/claude-tools-pat branch July 12, 2026 12:11
axisrow added a commit that referenced this pull request Jul 12, 2026
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]>
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