* 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.
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)
- Open Settings → CLI Credentials → User Credentials → Add.
- Select user.
- Choose Credential Type:
Personal Access TokenorSSH Private Key. - 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.
- Examples:
- Paste the token (PAT) or the unencrypted PEM body (SSH).
- 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:
clonefetchpullpushsubmodule
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>.extraheaderconfig entry withAUTHORIZATION: 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
gitprocess 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 inos.TempDir()(per-user on POSIX) with agoclaw-gitkey-*prefix. -
GIT_SSH_COMMANDis set tossh -i <tmpfile> -F /dev/null -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new. -
The tmpfile is removed via
deferon the exec wrapper. SIGKILL of goclaw leaves the file orphaned — see the Operator Notes section below. -
StrictHostKeyChecking=accept-newaccepts 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_hostsv2 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.Contentreturned 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 byTestEmitSystemEnvInjectionAudit_*— 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_hostsper 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/gcloudto non-passthrough adapters as use cases arise (e.g.aws assume-roleneeds argv mutation). - v2: Dedicated
audit_logtable forsecurity.system_env_injectionevents.