Skip to content

Commit a61de55

Browse files
authored
feat: git-over-HTTPS credentials via App tokens (#43)
* feat: git-over-HTTPS credentials via App tokens (/git-credential + ghp helper) Closes the last long-lived GitHub credential gap for agents: git push now authenticates with a short-lived, single-repo GitHub App installation token instead of a PAT or user OAuth session. Server (/git-credential, GET, opt-in via [mcp].enable_git_credentials): - Same hard gate as MCP writes: [[mcp.agents]] + App backend + [mcp.audit], validated at startup - Policy stack, all fail-closed: X-Ghpool-Key auth → repository-scoped agents only → repo allowlist → installation routing (multi-app by owner, single-app owner match) → fail-closed audit record → single-repo token mint (GitHub enforces the boundary) - Response { username: x-access-token, password, expires_at } with cache-control: no-store; token value never audited Client (ghp git-credential): - Standard git credential helper protocol (get/store/erase); get exchanges GHPOOL_KEY for a token, store/erase are no-ops - Declines quietly (exit 1) on missing key/path/non-github hosts so git can fall through to other helpers; requires credential.useHttpPath Verified: ghpool clippy -D warnings clean, 105 passed / 0 failed (6 new); ghp clippy clean, 14 passed / 0 failed (2 new) * fix(git-credential): bind installations to owners, downscope tokens, fail closed everywhere Hardening from self-review of #43: - BLOCKER: verify_owner() binds explicit installation IDs to the actual installation account (GET /app/installations/{id}) before any issuance; mislabeled config entries are refused (403) and audited - MAJOR: git tokens mint with permissions={contents:write} and a git-specific cache namespace — never the App's full permission set, never shared with MCP token cache entries - MAJOR: ghp helper emits quit=true for every unservable recognized github.com request (missing key/path, policy denial, network failure) so git never falls through to broader stored helpers; non-GitHub hosts still decline quietly; gist.github.com unsupported - MAJOR: two-phase audit — durable request preflight BEFORE owner verification/mint/cache lookup, result record before the token is returned; either write failing rejects the credential (503) - singular [mcp.github_app] now requires owner when git credentials are enabled (startup validation + defense-in-depth in the handler) - /git-credential always registered before the /{*path} catch-all; disabled mode answers 404 locally instead of proxying to GitHub Tests: mint-envelope/cache-isolation, owner verification (unit + endpoint mismatch), two-phase audit records, no-mint-on-preflight- failure, production route precedence, helper plan/fetch fail-closed matrix, config validation gates. README/config.example document the --replace-all helper reset and quit=true semantics. * fix(git-credential): normalize helper host matching, TTL owner re-verification, strict charset, CI for ghp Round-2 review fixes: - BLOCKER: helper host matching is now case-insensitive and port-aware — host=GitHub.com and github.com:443 are recognized github.com requests (Fetch/Quit), never declined to a broader helper; github.com on a non-443 port quits (recognized but unsupported) - MAJOR: verify_owner caches with a 1h TTL and re-checks the installation account with GitHub after expiry (orgs can rename) - MAJOR: strict charset validation of repo owner/name on the server (400 before allowlist/preflight/mint) and in the helper's repo_from_path (quit) — percent-encoded/exotic input never reaches the audit log, the mint path, or an unencoded query string - MAJOR: CI now builds/lints/tests the ghp crate, plus an integration test suite that spawns the real binary and asserts quit=true on stdout for recognized-github failure modes and quiet decline (exit 1) for other hosts - MINOR: token mint is singleflight — concurrent cache misses for the same key mint exactly once (asserted by test) Tests: ghpool 111, ghp 20 unit + 5 integration, clippy clean both. * fix(app-token): key-scoped singleflight and hard mint timeout Round-3 review: the per-provider singleflight mutex was held across mint network I/O, serializing distinct cache keys (different repos, MCP vs git purposes) behind one another — and the reqwest client had no timeout, so one stalled mint could block all issuance for an owner indefinitely. - Singleflight is now per cache key (Arc<tokio::sync::Mutex> map): same-key misses still mint exactly once; distinct keys mint in parallel - App API client now has a 30s hard timeout (MINT_TIMEOUT) covering mint, installation resolution, and owner verification calls - New test: a stalled mint for one key must not delay another key (test_distinct_keys_mint_in_parallel); same-key singleflight test retained Tests: ghpool 112, ghp 20 unit + 5 integration, clippy clean both. * fix(app-token): evict singleflight lock entries when mints complete Round-4 review: mint_locks entries were never removed, so unique request-supplied repo names (e.g. failed mints under a wildcard allowlist) accumulated lock entries for the process lifetime — unbounded memory growth on an authenticated path. Entries are now evicted after the mint completes (success or failure): waiters already holding the Arc still serialize and re-check the cache; the next fresh miss creates a new lock. The map is bounded by in-flight mints. Test: test_mint_locks_evicted_after_success_and_failure (success, repeated failures, and the parallel test now asserts an empty map). Tests: ghpool 113, ghp 20 unit + 5 integration, clippy clean. * fix(app-token): generation-guard singleflight eviction Round-5 review: unconditional remove(&key) let a stale waiter from an evicted generation remove a NEWER generation's in-flight lock, re-opening the duplicate-mint race repeatedly. evict_mint_lock() now removes the entry only when the map still holds the caller's own Arc (ptr_eq guard): each generation removes at most itself, bounding duplicate mints to one per evicted generation while keeping the map bounded by in-flight mints. Test: test_evict_mint_lock_is_generation_guarded (stale generation cannot evict, owner can, absent-key no-op). Tests: ghpool 114, ghp 20 unit + 5 integration, clippy clean. --------- Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent 072e16d commit a61de55

11 files changed

Lines changed: 1792 additions & 27 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ on:
66
- "src/**"
77
- "Cargo.toml"
88
- "Cargo.lock"
9+
- "ghp/**"
10+
- ".github/workflows/ci.yml"
911

1012
env:
1113
CARGO_TERM_COLOR: always
@@ -19,9 +21,21 @@ jobs:
1921
with:
2022
components: clippy
2123
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 (2026-03-12)
24+
with:
25+
workspaces: |
26+
.
27+
ghp
2228
- name: cargo check
2329
run: cargo check
2430
- name: cargo clippy
2531
run: cargo clippy -- -D warnings
2632
- name: cargo test
2733
run: cargo test
34+
# ghp is a separate crate (not a workspace member): the git credential
35+
# helper's fail-closed contract must be enforced in CI too.
36+
- name: ghp clippy
37+
run: cargo clippy --all-targets -- -D warnings
38+
working-directory: ghp
39+
- name: ghp test
40+
run: cargo test
41+
working-directory: ghp

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,74 @@ Trade-off vs. one key per org: a leaked key reaches the allowlisted repos of
459459
**all** configured installations. Prefer separate agents/keys when you want
460460
per-org blast-radius isolation.
461461

462+
#### Git over HTTPS via App tokens (`/git-credential`)
463+
464+
MCP covers issues and PRs, but `git push` speaks the Git protocol — it needs
465+
a real credential at push time. `enable_git_credentials` lets a
466+
repository-scoped agent exchange its ghpool key for a **short-lived,
467+
single-repo** App installation token, eliminating the last long-lived GitHub
468+
credential in the agent container:
469+
470+
```toml
471+
[mcp]
472+
enable_git_credentials = true # same hard gate as writes:
473+
# agents + App backend + [mcp.audit]
474+
```
475+
476+
The App needs **Contents: Read & write** on the target repositories. With
477+
the singular `[mcp.github_app]` form, `owner` is **required** when git
478+
credentials are enabled — the explicit `installation_id` is verified against
479+
the installation's actual account before any token is issued.
480+
481+
Agent side, `ghp` doubles as a standard git credential helper. Register it
482+
as the **only** helper for `github.com``--replace-all` with an empty
483+
first entry clears any inherited helpers (osxkeychain, GCM, `gh auth
484+
git-credential`) that would otherwise supply broader credentials:
485+
486+
```sh
487+
git config --global --replace-all credential."https://github.com".helper ""
488+
git config --global --add credential."https://github.com".helper "!ghp git-credential"
489+
git config --global credential."https://github.com".useHttpPath true
490+
```
491+
492+
Every `git push` then flows:
493+
494+
```
495+
git push → ghp git-credential (GHPOOL_KEY from env)
496+
→ GET /git-credential?repo=owner/name (X-Ghpool-Key)
497+
→ key auth → repo allowlist → installation routing
498+
→ durable audit preflight (phase: git_credential_request)
499+
→ installation owner verified against GitHub
500+
→ Contents-only single-repo token (~1h, GitHub-enforced scope)
501+
→ audit result (phase: git_credential_result)
502+
→ push authenticated as <app>[bot]
503+
```
504+
505+
Properties:
506+
507+
- **Single-repo, Contents-only tokens** — each credential is minted with
508+
exactly the repo being pushed and `contents: write` only, never the App's
509+
full permission set; GitHub itself enforces both boundaries. Git tokens
510+
are cached separately from MCP tokens.
511+
- **Owner binding** — the configured owner label is verified against the
512+
actual installation account (`GET /app/installations/{id}`) before
513+
issuance and re-verified hourly (accounts can rename); a mislabeled
514+
installation ID is refused.
515+
- **Fail-closed audit** — a request record is persisted *before* any mint or
516+
cache lookup, and a result record before the token is returned; if either
517+
write fails, no credential (503). The token value is never written to the
518+
audit log.
519+
- **Deny-by-default** — repo-less agents, off-allowlist repos, and owners
520+
without an installation are refused before any mint.
521+
- **Fail-closed helper** — once a request is recognized as `github.com`
522+
HTTPS, any failure (missing `GHPOOL_KEY`, missing path, policy denial,
523+
network error) makes `ghp` emit `quit=true`, telling git to stop the
524+
helper cascade instead of falling through to broader stored credentials
525+
or prompting. Non-GitHub hosts are declined quietly so other helpers can
526+
serve them. `gist.github.com` is not supported.
527+
- **Nothing to store**`store`/`erase` are no-ops; tokens expire on their
528+
own. `cache-control: no-store` on the response.
529+
462530
Deployment notes:
463531

464532
- Requires egress to `api.githubcopilot.com` (the only additional external dependency).

config.example.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ enabled = false
4747
# PATs, and are always fail-closed audited. When set, the default upstream
4848
# switches to the full write-capable surface.
4949
# enable_writes = false
50+
# Enable GET /git-credential: repository-scoped agents exchange their
51+
# X-Ghpool-Key for a short-lived GitHub App installation token scoped to
52+
# EXACTLY ONE repository with Contents-only permissions, usable as a
53+
# git-over-HTTPS credential (username `x-access-token`). Closes the
54+
# long-lived-PAT gap for `git push`. Same hard gate as writes: agents +
55+
# App backend + audit (durable preflight before mint, result before the
56+
# token is returned). The App needs Contents: read/write for pushes. With
57+
# the singular [mcp.github_app] form, `owner` is REQUIRED — the explicit
58+
# installation_id is verified against the installation's actual account
59+
# before issuance. Client side: `ghp git-credential` is a drop-in git
60+
# credential helper (see README for the fail-closed helper setup).
61+
# enable_git_credentials = false
5062
# Max concurrent write calls per agent (0 = unlimited).
5163
# max_inflight_writes = 4
5264
# Upstream MCP endpoint. Default: hosted read-only variant, or the full

0 commit comments

Comments
 (0)