* 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.
14 KiB
Credential Adapter Playbook
Developer guide for authoring a CredentialAdapter. Use this when adding a new
typed-credential CLI binary (kubectl, docker, npm, aws, psql…) beyond the
shipped git adapter.
This is the R1 extensibility deliverable from the issue #82 brainstorm: proof that the framework generalizes past git. Read git-credential-adapter.md first for the user-facing story; this doc is the implementer's manual.
Interface contract
Source of truth: internal/tools/credential_adapter.go.
type CredentialAdapter interface {
Name() string // adapter_name column value
ShouldInject(argv []string) bool // gate: skip local-only subcommands
Prepare(ctx context.Context,
bin *store.SecureCLIBinary,
cred *store.SecureCLIUserCredential,
argv []string) (*Injection, error)
}
Name()is the string operators set insecure_cli_binaries.adapter_name. It is also the value logged insecurity.system_env_injection.adapter.ShouldInjectdecides whether the adapter runs for THIS invocation. For tools without subcommands (psql,docker push, etc.) returntrue. For tools where some subcommands are local-only (git status,kubectl version) returnfalseto skip injection and audit-noise.Prepareproduces a single*Injectionper exec. Returning a non-nil error aborts the exec — no fallback to un-credentialed run, so reserve errors for malformed credentials, not "credential not found" (return&Injection{}for that case).
Register in your adapter file's init():
func init() { RegisterAdapter(myAdapter{}) }
Lookup falls back to the passthrough no-op adapter on unknown names, so a
typo in adapter_name degrades to legacy behavior with a clear audit trail
rather than breaking exec.
The four Injection fields
type Injection struct {
ArgvPrefix []string // spliced between binary and user args
Env map[string]string // merged on top of base env
Cleanup func() error // deferred after exec
ScrubValues []string // redacted from stdout/stderr/Result/errors
}
ArgvPrefix
Use when the tool reads auth from CLI flags AND those flags do NOT carry the
secret directly. Example: kubectl --kubeconfig <path> is fine (path, not
secret). aws --profile <name> is fine. NEVER use for the secret itself —
argv is world-readable via /proc/<pid>/cmdline on Linux.
The PAT path of the git adapter deliberately uses Env (not ArgvPrefix)
for http.<remote>.extraheader precisely because git -c http.…=<token>
would leak the token via ps.
Env
The primary injection channel. Tools that read auth from a config file env
var (KUBECONFIG, DOCKER_CONFIG, NPM_CONFIG_USERCONFIG,
AWS_SHARED_CREDENTIALS_FILE, PGPASSFILE) all flow through here.
Env is scoped to the spawned child process; goclaw's own env is unchanged.
Cleanup
Required when Prepare writes any filesystem material. Always pair with
materializeEphemeral's returned cleanup closure (see below).
ScrubValues
List every secret byte sequence the subprocess might echo back. The
per-request ScrubCredentials bag (see internal/tools/scrub.go)
strips these from:
- live stdout/stderr streamed to the agent,
- the final
Result.Content, - error messages,
- the slog audit line.
If the tool's error path embeds the secret in a URL or config dump (git's
fatal: unable to access 'https://<token>@host/'…), this is your only
defense — register both the raw secret AND any predictable wrapping.
Ephemeral filesystem material — materializeEphemeral
Source: internal/tools/credential_ephemeral.go.
path, cleanup, err := materializeEphemeral(ctx, []byte(content), "kubecfg")
if err != nil { return nil, err }
return &Injection{
Env: map[string]string{"KUBECONFIG": path},
Cleanup: cleanup,
ScrubValues: []string{bearerToken},
}, nil
Guarantees:
0600perms, per-useros.TempDir()on POSIX.- Idempotent cleanup (concurrent callers don't double-remove).
- Prefix becomes
goclaw-<prefix>-<random>so operators can sweep stale files with one glob.
Why not memfd?
Originally proposed (brainstorm R6); dropped during validation. /proc/self/fd/N
resolves "self" against the calling process. For grandchildren (e.g. git → ssh),
the kernel resolves against the child, which sees EBADF unless the parent
passed the fd via ExecCommand.ExtraFiles AND the child binary reads from that
fd number. None of git/psql/docker/kubectl have an API to forward fds to their
subprocesses. Tmpfile + defer remove is the safe, portable default.
SIGKILL caveat: if goclaw is killed with SIGKILL (-9), defer cleanup()
never fires and the 0600 tmpfile lingers. Operators should sweep — see
git-credential-adapter.md → Operator notes.
Host-scope semantics
secure_cli_user_credentials.host_scope is the exact ASCII hostname (with
optional port) the credential authenticates to. v1 does not support wildcards.
| Tool | host_scope value | Matched against |
|---|---|---|
| git | github.com, gitea.internal:8443 |
remote URL host from argv |
| kubectl | prod.example.com:6443 |
current-context cluster API endpoint |
| docker | registry.gitlab.com, ghcr.io |
first argv after push/pull |
| npm | registry.npmjs.org or npm.pkg.github.com |
argv --registry or .npmrc default |
| aws | <account>:<region> (composite) |
parsed from --profile/region flags |
| psql | db.example.com:5432 |
-h/-p argv or PGHOST env |
Normalize via idna.ToASCII and lowercase. Hash with the shared
hashHostScope() helper for audit logs — never log plaintext hostname.
Worked mappings
Each subsection below is a sketch. Production adapters should add validation,
edge-case handling, and per-adapter tests mirroring credential_adapter_git_test.go.
kubectl
func (kubectlAdapter) Prepare(ctx context.Context, _ *store.SecureCLIBinary,
cred *store.SecureCLIUserCredential, _ []string) (*Injection, error) {
if cred == nil { return &Injection{}, nil }
var k struct {
Kubeconfig string `json:"kubeconfig"` // full YAML body
BearerToken string `json:"bearer_token"` // optional
}
if err := json.Unmarshal(cred.EncryptedEnv, &k); err != nil {
return nil, fmt.Errorf("decode kubeconfig cred: %w", err)
}
path, cleanup, err := materializeEphemeral(ctx, []byte(k.Kubeconfig), "kubecfg")
if err != nil { return nil, err }
scrub := []string{}
if k.BearerToken != "" { scrub = append(scrub, k.BearerToken) }
return &Injection{
Env: map[string]string{"KUBECONFIG": path},
Cleanup: cleanup,
ScrubValues: scrub,
}, nil
}
- Injection fields:
Env+Cleanup+ScrubValues. - Host scope: cluster API server hostname:port from current context.
- Skip subcommands:
kubectl version,kubectl config view --raw,kubectl --help. Most subcommands DO hit the API server so default-true is acceptable; refine if false-positive audit noise becomes an issue. - Tests to write: kubeconfig path injected into env not argv; tmpfile
removed post-exec; bearer token scrubbed from
kubectl get pods401 stderr.
docker
// DOCKER_CONFIG points to a DIRECTORY containing config.json, not the file.
func (dockerAdapter) Prepare(ctx context.Context, _ *store.SecureCLIBinary,
cred *store.SecureCLIUserCredential, _ []string) (*Injection, error) {
if cred == nil { return &Injection{}, nil }
var d struct {
Auths map[string]struct{ Auth string `json:"auth"` } `json:"auths"`
}
if err := json.Unmarshal(cred.EncryptedEnv, &d); err != nil {
return nil, fmt.Errorf("decode docker cred: %w", err)
}
dir, cleanup, err := materializeEphemeralDir(ctx, "dockercfg")
if err != nil { return nil, err }
body, _ := json.Marshal(d)
if err := os.WriteFile(filepath.Join(dir, "config.json"), body, 0o600); err != nil {
_ = cleanup(); return nil, err
}
scrub := []string{}
for _, a := range d.Auths { scrub = append(scrub, a.Auth) }
return &Injection{
Env: map[string]string{"DOCKER_CONFIG": dir},
Cleanup: cleanup,
ScrubValues: scrub,
}, nil
}
- Note:
DOCKER_CONFIGis a directory, not a file. Requires a smallmaterializeEphemeralDirsibling helper (not yet shipped — add when first docker adapter lands). - Host scope: registry hostname (
registry.gitlab.com,ghcr.io,<account>.dkr.ecr.<region>.amazonaws.com). - Scrub: the base64-encoded
authvalue AND the decodeduser:passform, because docker error output can decode and echo either.
npm
func (npmAdapter) Prepare(ctx context.Context, _ *store.SecureCLIBinary,
cred *store.SecureCLIUserCredential, _ []string) (*Injection, error) {
if cred == nil { return &Injection{}, nil }
var n struct {
Registry string `json:"registry"`
AuthToken string `json:"auth_token"`
}
if err := json.Unmarshal(cred.EncryptedEnv, &n); err != nil {
return nil, fmt.Errorf("decode npm cred: %w", err)
}
line := fmt.Sprintf("//%s/:_authToken=%s\n",
strings.TrimPrefix(strings.TrimPrefix(n.Registry, "https://"), "http://"),
n.AuthToken)
path, cleanup, err := materializeEphemeral(ctx, []byte(line), "npmrc")
if err != nil { return nil, err }
return &Injection{
Env: map[string]string{"NPM_CONFIG_USERCONFIG": path},
Cleanup: cleanup,
ScrubValues: []string{n.AuthToken},
}, nil
}
- Host scope: registry hostname.
- Subcommand gate: only
install/publish/ci/audit(network ops). Skipnpm list,npm version,npm run ….
aws
func (awsAdapter) Prepare(ctx context.Context, _ *store.SecureCLIBinary,
cred *store.SecureCLIUserCredential, _ []string) (*Injection, error) {
if cred == nil { return &Injection{}, nil }
var a struct {
AccessKeyID string `json:"access_key_id"`
SecretAccessKey string `json:"secret_access_key"`
SessionToken string `json:"session_token,omitempty"`
Profile string `json:"profile"`
}
if err := json.Unmarshal(cred.EncryptedEnv, &a); err != nil {
return nil, fmt.Errorf("decode aws cred: %w", err)
}
body := fmt.Sprintf("[%s]\naws_access_key_id=%s\naws_secret_access_key=%s\n",
a.Profile, a.AccessKeyID, a.SecretAccessKey)
if a.SessionToken != "" {
body += fmt.Sprintf("aws_session_token=%s\n", a.SessionToken)
}
path, cleanup, err := materializeEphemeral(ctx, []byte(body), "awscreds")
if err != nil { return nil, err }
return &Injection{
Env: map[string]string{
"AWS_SHARED_CREDENTIALS_FILE": path,
"AWS_PROFILE": a.Profile,
},
Cleanup: cleanup,
ScrubValues: []string{a.SecretAccessKey, a.SessionToken},
}, nil
}
- Host scope: composite
<account-id>:<region>(composite key for the rare case where the same operator runs multi-account workflows). - v2 flag — credential refresh:
aws sts assume-rolereturns a short-lived STS credential. v1's "one credential per exec" model cannot refresh mid-flight. Defer until a refresh primitive is added (track in roadmap).
psql
Already shipped as the framework-validation stub.
See internal/tools/credential_adapter_psql.go.
- Injection fields:
Env: {PGPASSFILE: path}+Cleanup+ScrubValues. - Host scope:
db.example.com:5432. - Subcommand gate: psql has no subcommands;
ShouldInjectreturnstruealways. - Edge case handled:
escapePgpass()escapes backslash and colon per the libpq.pgpassspec to prevent injection of a second entry via a:in the password.
Interface validation gate
Before merging a new adapter, answer these three gate questions in writing (PR description or phase file). Phase 2b introduced this discipline; reuse it verbatim:
-
Does
Preparefit the four Injection fields cleanly, or did you need a fifth? If the latter, the framework needs a change BEFORE your adapter lands — not a bypass. -
Can the secret reach the subprocess without ever touching
ArgvPrefix? If no, document why and accept the/proc/<pid>/cmdlineexposure explicitly in the security section of your phase file. -
Does the tool's error path emit the secret in a form
ScrubValueswouldn't catch? (e.g. base64-wrapped, URL-encoded, partially echoed.) Enumerate the wrappings and add each toScrubValues.
If any answer is "no" or "unsure", stop and revise the design.
Anti-patterns — do NOT
- Do NOT add adapter-specific branches to
credentialed_exec.go. The whole point of the framework is that the hot path stays agnostic. If your adapter needs special handling, put it inPrepareor extend theInjectionshape (and update every other adapter). - Do NOT introduce a parallel
materializeFoohelper. Use the sharedmaterializeEphemeral(or extend it). One helper means one place to audit the perms/cleanup contract. - Do NOT log plaintext
host_scopeto audit. UsehashHostScope()— operators recover the host by pre-computing the hash, not by reading it out of logs. - Do NOT inject the secret via
ArgvPrefix. See gate question #2. - Do NOT skip
ShouldInjectgating for tools with mixed local/remote subcommands. Every unnecessary injection is an extra audit-log line, a tmp file, and a potential leak surface. - Do NOT return a hard error for "no credential matches". Return
&Injection{}, niland let the subprocess fail with its own clear auth error. Hard error here breaks the un-credentialed fallback path.