Files
goclaw/docs/git-credential-adapter.md
T
Duy /zuey/andGitHub a591473546 feat(secure-cli): CLI credential adapters framework + git adapter (#82) (#89)
* feat(secure-cli): Phase 1 schema + storage delta (issue #82)

Adds `adapter_name` column to secure_cli_binaries and `credential_type` + `host_scope` columns to secure_cli_user_credentials. LookupByBinary LEFT JOIN now projects AdapterName, UserCredentialType, and UserHostScope. Extends SecureCLIStore with SetUserCredentialsTyped(ctx, binaryID, userID, env, credType, hostScope); legacy SetUserCredentials delegates to typed variant with nil/nil for backward compat.

PG migration 73 + RequiredSchemaVersion bumped to 73. SQLite incremental migrations (versions 39–41) + SchemaVersion 42. 7 SQLite + 6 PG integration tests covering schema, round-trip, NULL legacy, LookupByBinary projection.

Fixes #82

* feat(secure-cli): Phase 2 CredentialAdapter framework + passthrough (issue #82)

Adds CredentialAdapter interface + Injection{ArgvPrefix,Env,Cleanup,ScrubValues} struct. Registry resolves by name; falls back to passthrough for empty/unknown. passthroughAdapter is default no-op — all existing presets (gh/aws/gcloud/kubectl/terraform/gws) behave bit-for-bit identically.

Per-request WithScrubBag(ctx) + AddScrubValuesCtx + ScrubCredentialsCtx for multi-tenant secret isolation (replaces package-global slice). Hook in executeCredentialed between env merge and exec: resolve adapter by bin.AdapterName, reject non-passthrough in sandbox, call Prepare, splice ArgvPrefix, merge Env, defer Cleanup, register ScrubValues.

Audit log security.system_env_injection records adapter name + env key names + argv_prefix_len + sha256(host_scope) — NEVER values. CLIPreset.AdapterName field added; empty default for legacy presets.

12 unit tests covering passthrough no-op, registry fallback + nil safety, Injection shape, hashHostScope determinism, sortedKeys, scrub bag per-request isolation + concurrent + short-value guard.

Fixes #82

* feat(secure-cli): Phase 2b extensibility helpers + psql stub adapter (issue #82)

Proves CredentialAdapter framework generalizes beyond git. Adds materializeEphemeral(ctx, content, prefix) shared helper — 0600 tmpfile + idempotent atomic.Bool cleanup latch; memfd intentionally rejected (resolves "self" against child process).

Adds psqlAdapter consuming framework end-to-end via PGPASSFILE pattern; libpq-spec .pgpass escaping for `:` and `\`. Registers psql preset with AdapterName: "psql" (production UI for typed creds lands in v2).

Interface validation gate passes: Injection shape unchanged, hook is psql-agnostic, no special branch needed.

Tests: ephemeral write/cleanup/concurrent/zero-content; psql routing/content/escaping/error paths/registration.

Fixes #82

* feat(secure-cli): git adapter PAT + SSH implementation (issue #82)

Phase 3 — PAT path
- gitAdapter PAT branch via GIT_CONFIG_COUNT/KEY_0/VALUE_0 env (git 2.31+)
  so the token never lands on argv, .git/config, or remote URL
- Host-scope enforcement with IDN normalization (golang.org/x/net/idna)
  and embedded-userinfo rejection in URL parsing
- CVE-2018-17456 mitigation: resolve remote URLs via `git config --get`
  not `git remote get-url` to dodge ext::sh protocol handler injection
- Case-insensitive DenyArgs blocking `-c http.`, `-c credential.`,
  `-c core.sshcommand`, `config --global/--system`, `credential-helper`,
  bare `daemon`
- New WithExecCwd / ExecCwdFromContext context helpers — fixes a latent
  design gap where the adapter's pre-flight `git config --get` ran in
  goclaw's daemon CWD instead of the agent's repo
- 14 unit tests covering subcommand routing, host normalization,
  scp-form parsing, userinfo rejection, CRLF token rejection,
  CVE-2018-17456 regression, DenyArgs preset coverage
- 3 integration tests against a local TLS git-http-backend server
  proving end-to-end clone + fetch + host-mismatch rejection with
  zero token leakage into the cloned .git/config

Phase 4 — SSH path
- gitAdapter ssh_key branch materializes per-call 0600 tmpfile via
  the Phase 2b materializeEphemeral helper, injects GIT_SSH_COMMAND
  with -o IdentitiesOnly=yes -o BatchMode=yes
  -o StrictHostKeyChecking=accept-new, idempotent cleanup
- ValidateSSHKey using golang.org/x/crypto/ssh rejects
  passphrase-protected keys via ErrSSHKeyPassphraseUnsupported sentinel
- 8 unit tests covering passphrase rejection, env shape, cleanup
  lifecycle, host-mismatch reuse, malformed-blob rejection
- 3 integration tests proving tmpfile 0600 lifecycle, env propagation
  to child process, cleanup-on-exec-failure, no-orphan-on-rejection
- 2 new i18n keys (en/vi/zh) for SSH-passphrase and SSH-key-invalid

Verification: go vet clean, go build ./... clean,
go build -tags sqliteonly ./... clean, go test -race
./internal/tools/ pass, go test -tags integration
./tests/integration/ -run TestGitAdapter pass.

Refs #82

* feat(secure-cli): UI presets + typed credential HTTP path + i18n (issue #82)

Phase 5. Typed PUT envelope with {error:{code,message}, error_key} for
field-level errors. CliCredentialGitFields React component (PAT/SSH picker,
host_scope input, CRLF→LF normalization, masked-edit). 17 i18n keys × 3
locales (backend + frontend). i18n parity test.

* feat(secure-cli): audit log schema + adapter framework docs (issue #82)

Phase 6. emitSystemEnvInjectionAudit helper centralizes
security.system_env_injection slog with host_scope_hash (SHA-256 8 hex,
plaintext hostname omitted for PII safety). Audit shape pinned by
TestEmitSystemEnvInjectionAudit_*. New docs: git-credential-adapter.md
(user guide), credential-adapter-playbook.md (R1 implementer guide w/
kubectl/docker/npm/aws/psql worked mappings). 09-security.md § 14
trust-boundary diagram + SSH TOFU + SIGKILL caveats. 03-tools-system.md
§ 8a. Changelog entry.

* docs(journal): issue #82 CLI credential adapters shipped

Retrospective covering 6 commits across phases 1-6: framework, git PAT/SSH, psql stub, UI/i18n, audit log schema, docs. Notes memfd-drop rationale, TOFU/SIGKILL caveats, sentinel-length lesson from audit-shape tests.

* fix(secure-cli): honor per-request scrub bag on success+failure paths (#82)

Sandbox and host exec paths called the non-Ctx ScrubCredentials, so
adapter ScrubValues registered into the per-request bag during Prepare
(e.g. GitLab glpat-, Bitbucket app-passwords, Azure DevOps PATs, Gitea
tokens, SSH key tmpfile paths) were ignored on stdout/stderr returned
to the agent. Only the package-global regex pass ran — covering ghp_
but nothing else.

Switch all four exec/sandbox call sites to ScrubCredentialsCtx so the
bag is consulted. Also scrub the slog adapter_cleanup_failed line —
os.Remove errors embed the full tmpfile path.

Add 5 regression tests that pin success path, failure path, negative
control (proves the bag is what catches the sentinel), classic-PAT
sanity, and timeout path no-leak.

Locks AC6 against non-GitHub PAT providers.

* fix(secure-cli): address review-pr #89 findings

- ui/web: SSH key Textarea was 'text-xs' on all viewports, triggering
  iOS Safari auto-zoom on focus. Switch to 'text-base md:text-xs' so
  mobile renders 16px (no zoom) while desktop keeps compact mono font.
- psql adapter: add on-disk pgpass tmpfile path to ScrubValues. psql
  echoes 'could not open password file "<path>"' on IO errors, so
  the path needs scrubbing alongside the password. Mirrors the git SSH
  adapter pattern. Update test assertion accordingly.
- psql adapter: replace literal 'nil' context with 'context.TODO()'
  to silence staticcheck SA1012.
2026-05-28 18:17:49 +07:00

8.4 KiB

Git Credential Adapter

User-facing guide for the git typed credential adapter (issue #82, ships with v3.x).

Why a typed adapter?

The legacy CLI credential flow asks the user to paste arbitrary environment variables (GH_TOKEN, KUBECONFIG, etc.). git does not read its authentication from a stable, single env var — credentials live in .git/config, ~/.git-credentials, the OS credential helper, or per-remote URLs. Pasting a PAT into GIT_TOKEN did nothing, which surprised users and silently failed every clone.

The typed git adapter accepts either a Personal Access Token (PAT) or an SSH private key, validates it server-side, then injects it into the spawned git process via a transient mechanism that never touches disk or argv.

When to use which credential type

Type Use when Limits
PAT GitHub/GitLab/Gitea over HTTPS. You already have a ghp_… or glpat-… token. Token must be unscoped to specific repos, OR cover all repos goclaw will touch.
SSH Self-hosted git over SSH. You manage ~/.ssh/known_hosts or accept TOFU risk. Passphrase-protected keys are NOT supported (see below).
Env Legacy path — you have a custom env-var-driven workflow. Loses host-scoped routing; same trust profile as other CLIs.

Adding a credential (UI)

  1. Open Settings → CLI Credentials → User Credentials → Add.
  2. Select user.
  3. Choose Credential Type: Personal Access Token or SSH Private Key.
  4. Enter Host Scope (required for PAT/SSH): the hostname the credential authenticates to.
    • Examples: github.com, gitlab.example.com, gitea.internal:8443.
    • Case-insensitive. Punycode normalized via idna.ToASCII.
    • Port included only when non-default for the scheme.
  5. Paste the token (PAT) or the unencrypted PEM body (SSH).
  6. Save.

The stored secret is encrypted (AES-256-GCM) and can never be read back through the API or UI. Editing the row shows a •••••••• placeholder; leaving the secret field blank preserves the stored value, typing a new value replaces it.

What gets auto-injected

The adapter runs ONLY for these subcommands:

  • clone
  • fetch
  • pull
  • push
  • submodule

Any other subcommand (status, log, diff, commit, branch, etc.) runs WITHOUT credentials — these are local operations and never reach a remote.

Implementation: see internal/tools/credential_adapter_git.go::ShouldInject.

Host-scope semantics

host_scope is the exact ASCII hostname (with optional port) the credential is valid for. v1 does NOT support wildcards.

Stored github.com matches:

  • git clone https://github.com/org/repo.git
  • git clone https://api.github.com/... (different host)
  • git clone https://github.com:8443/... (different port — port is part of the scope key)

Stored gitea.example.com:8443 matches:

  • git clone https://gitea.example.com:8443/...
  • git clone https://gitea.example.com/... (default port — still mismatch)

If you run a self-hosted server on the scheme's default port (443 HTTPS, 22 SSH), omit the port. If you run on a non-default port, include it.

When no stored credential matches the resolved remote host, the adapter falls through to the un-credentialed path: git runs with whatever credentials the calling shell already has (typically none, in which case the remote will reject the operation with a 401/403).

Security model

PAT path

  • Injected via GIT_CONFIG_COUNT + GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* environment variables.
  • The PAT itself goes into a value that synthesizes an http.<remote>.extraheader config entry with AUTHORIZATION: basic <base64(token)>.
  • The PAT never appears on argv — so ps, /proc/<pid>/cmdline, and shell-history echoes don't expose it.
  • The injected env vars are scoped to the spawned git process only; they are NOT inherited by goclaw, by other tools, or by sibling exec calls.

SSH path

  • The PEM key is written to an 0600-mode tmpfile in os.TempDir() (per-user on POSIX) with a goclaw-gitkey-* prefix.

  • GIT_SSH_COMMAND is set to ssh -i <tmpfile> -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new.

  • The tmpfile is removed via defer on the exec wrapper. SIGKILL of goclaw leaves the file orphaned — see the Operator Notes section below.

  • StrictHostKeyChecking=accept-new accepts unknown host keys on first contact (TOFU). A network attacker positioned between goclaw and the git host CAN capture the SSH session on the first connection. Operators should pre-seed ~/.ssh/known_hosts:

    ssh-keyscan github.com >> ~/.ssh/known_hosts
    

    v2 will support per-credential pinned host keys.

Passphrase-protected SSH keys: rejected

The adapter rejects encrypted SSH keys at validation time with error_key = git.cred_ssh_passphrase_unsupported. Reason: we have no UX or storage slot for the passphrase, and ssh-agent forwarding is outside the goclaw security model. Re-export your key without a passphrase, or use a dedicated deploy key.

Redaction across output channels

Every credential adapter registers its secret bytes with the per-request ScrubCredentials bag (internal/tools/scrub.go). The scrubber removes the secret from:

  • Live stdout / stderr streamed to the agent.
  • The final Result.Content returned by the tool.
  • Error messages bubbled up to the agent.
  • The audit log line (security.system_env_injection) — see below.

Plaintext hostnames are also kept out of the audit log: host_scope_hash is the SHA-256 first 8 hex chars of the normalized scope.

Auditability

Every successful credential injection emits exactly one structured log line:

level=WARN msg=security.system_env_injection
  adapter=git binary=git user_id=<uuid>
  env_keys=[GIT_CONFIG_COUNT,GIT_CONFIG_KEY_0,GIT_CONFIG_VALUE_0]
  argv_prefix_len=0
  host_scope_hash=3aeb0024

env_keys lists NAMES only — values never appear. host_scope_hash is the first 8 hex chars of sha256(normalized_host_scope). Operators wanting to grep for activity against a specific host pre-compute the hash:

echo -n "github.com" | sha256sum | cut -c1-8

See docs/09-security.md → "CLI credential adapters" for the full schema.

Migration from legacy env-paste

Existing rows in secure_cli_user_credentials with credential_type IS NULL or = 'env' continue to work via the passthrough adapter — they keep emitting their env vars exactly as before. There is no forced migration.

To upgrade an existing git credential, open the user-credentials dialog, pick PAT or SSH, paste the secret, and save. The legacy env-paste row is replaced atomically.

Operator notes

  • Tmpfile sweep: high-security deployments should sweep stale tmpfiles every few minutes:

    find "$TMPDIR" -name 'goclaw-gitkey-*' -mmin +60 -delete
    find "$TMPDIR" -name 'goclaw-pgpass-*' -mmin +60 -delete
    
  • Pre-seed known_hosts to defeat TOFU MITM (see SSH path above).

  • Log aggregation: route security.* slog events to your SIEM. The schema is pinned by TestEmitSystemEnvInjectionAudit_* — alert on any change.

  • No sandbox support v1: the adapter mutates the parent process's forked-child environment, which is incompatible with the bind-mount-based sandbox path. Sandbox + credentialed exec is on the v2 roadmap.

Known limitations (v1)

  • One credential per (user, binary, host_scope) row.
  • No multi-host wildcard (*.github.com).
  • No passphrase-protected SSH keys.
  • No persistent known_hosts per credential (TOFU only).
  • No sandbox support.
  • PAT scope cannot be inspected — goclaw stores the token opaquely.

Future work

Tracked separately:

  • v2: OAuth device-flow for GitHub/GitLab — eliminates PAT paste.
  • v2: Multi-credential per user with host routing logic.
  • v2: Sandbox/Docker exec path support (per-call key bind-mount).
  • v2: Pinned SSH host keys per credential.
  • v2: Migrate gh/aws/gcloud to non-passthrough adapters as use cases arise (e.g. aws assume-role needs argv mutation).
  • v2: Dedicated audit_log table for security.system_env_injection events.