#!/usr/bin/env node
/**
* pgv3-git-shell — forced-command SSH wrapper for the classic-git-auth SSH
* push path (Phase 2, Option B). See spec/flows/classic-git-auth.md.
*
* Installed as the `command="…"` for every enrolled key in the dedicated `git`
* account's authorized_keys. sshd runs it (never a shell) with the client's
* request in $SSH_ORIGINAL_COMMAND. It:
*
* 1. parses + sanitizes the git command (git-shell-parse — git only)
* 2. asks the daemon over UDS whether THIS fingerprint may run it
* (git-acl-check — reads open, push gated, revocation live)
* 3. execs the real git pack process on the bare repo, wiring its stdio to
* the SSH channel so the pack protocol flows through.
*
* The daemon bakes --fp/--sock/--repos into the forced command at enrollment,
* so this wrapper is self-configuring regardless of the git account's HOME.
* It is intentionally tiny and fail-closed: any error denies.
*/
import process from '#node:process'
import { spawn } from '#node:child_process'
import { join } from '#node:path'
import { homedir } from '#node:os'
import fs from '#node:fs'
import { Client } from 'hyper-svc'
import { parseGitCommand, safeRepoPath } from '../lib/auth/git-shell-parse.js'
// Synchronous write to fd 2 — an async process.stderr.write() is truncated by
// the immediate process.exit() after a UDS round-trip, swallowing the reason
// (the deny-message-lost bug caught by the live smoke test).
function deny (msg) {
try { fs.writeSync(2, 'pgv3: ' + msg + '\n') } catch {}
process.exit(1)
}
function argval (args, name) {
const pfx = '--' + name + '='
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith(pfx)) return args[i].slice(pfx.length)
if (args[i] === '--' + name && i + 1 < args.length) return args[i + 1]
}
return null
}
async function main () {
const args = process.argv.slice(2)
const fingerprint = argval(args, 'fp')
// Daemon bakes these in; env + sensible defaults are a fallback.
const sock = argval(args, 'sock') || process.env.PGV3_DAEMON_SOCK ||
join(homedir(), 'pgv3-storage', 'svc.sock')
const reposDir = argval(args, 'repos') || process.env.PEAR_GIT_REPOS ||
join(homedir(), 'git-repos')
const parsed = parseGitCommand(process.env.SSH_ORIGINAL_COMMAND || '')
if (!parsed.ok) return deny(parsed.error)
// Ask the daemon whether this fingerprint may run this op on this repo.
let verdict = null
let client = null
try {
client = await Client.connectUds(sock)
verdict = await client.call('git-acl-check', {
fingerprint,
repo: parsed.repo,
op: parsed.op
})
} catch (err) {
return deny('authorization unavailable (daemon unreachable): ' + (err && err.message ? err.message : err))
} finally {
if (client) { try { await client.close() } catch {} }
}
if (!verdict || !verdict.allowed) {
return deny(verdict && verdict.reason ? verdict.reason : 'access denied')
}
// Resolve the repo to an absolute bare-repo path and PROVE containment in
// reposDir before we exec anything (#74 Phase 4 — defense in depth, even
// though parseGitCommand already rules out traversal).
const repoPath = safeRepoPath(reposDir, parsed.repo)
if (!repoPath) return deny('repository outside the allowed directory')
// Run the real git pack process; its stdio IS the SSH channel. spawn with an
// ARG ARRAY (never a shell) — gitCmd is a fixed string from the closed op
// allowlist and repoPath is an absolute, contained path, so nothing from
// $SSH_ORIGINAL_COMMAND is ever interpreted by a shell or seen as an option.
const gitCmd = 'git-' + parsed.op // git-receive-pack | git-upload-pack
const child = spawn(gitCmd, [repoPath], { stdio: 'inherit' })
child.on('error', (err) => deny('failed to start ' + gitCmd + ': ' + (err && err.message ? err.message : err)))
child.on('exit', async (code, signal) => {
// Attribute a successful SSH push for the public profile "repos pushed"
// list. Best-effort over a fresh UDS connection; never blocks or fails
// the push (the pack has already been transferred at this point).
if (!signal && code === 0 && parsed.op === 'receive-pack') {
try {
const c = await Client.connectUds(sock)
try { await c.call('record-push', { repo: parsed.repo, fingerprint }) } finally { await c.close() }
} catch {}
}
process.exit(signal ? 1 : (code || 0))
})
}
main().catch((err) => deny(err && err.message ? err.message : String(err)))